GitHub Copilot for Ruby
Ruby is optimised for expressiveness, and Copilot is very good at expressiveness. That combination produces suggestions that are a pleasure to read and unusually hard to verify.
The specific problem is that Ruby’s most powerful features work by making code
that does not appear in the source. method_missing, define_method,
instance_eval, included hooks, and everything Rails builds on them mean a
method call can succeed against a method that exists nowhere you can grep. When
Copilot generates that kind of code, the usual review technique — read it and
decide whether it is right — partly stops working, because a large part of the
behaviour is not written down.
Ruby quick referenceVerified August 21, 2026
- Type system
- Dynamically typed and heavily metaprogrammed; a method may not exist anywhere in the source you can grep.
- Package management
- Bundler (RubyGems)
- Manifest Copilot should see
- Gemfile
- Testing
- RSpec, Minitest
- Formatting
- RuboCop or Standard
- Linting
- RuboCop
- Static analysis
- Sorbet or RBS with Steep; Brakeman for Rails security
- Common frameworks
- Rails, Sinatra
- Typical Copilot work
- Enumerable chains, service objects, RSpec specs, Rake tasks, Rails scaffolding
- First thing to review
- Generated metaprogramming, and Rails parameter handling that trusts user input
Fastest honest checkbundle exec rubocop && bundle exec rspec
Key takeaways
- Generated Ruby is idiomatic and dense. Ask whether you could spot an off-by-one in a chained enumerable; if not, ask for the simpler form.
- Metaprogramming is where suggestions are least reviewable. Require a reason for
every
define_method,method_missingorsendthat appears. - RuboCop is the closest thing Ruby has to a compiler for reviewing generated code, and its Lint department catches real bugs rather than style.
- In Rails, the review concentrates on parameter handling, mass assignment and N+1 queries — all three are things suggestions get wrong by default.
- The fastest honest check is
bundle exec rubocop && bundle exec rspec.
Blocks, enumerables and the density problem
Ruby’s Enumerable is the best of its kind, and Copilot uses it fluently.
top_customers =
orders
.select { |o| o.paid? && o.placed_at > 30.days.ago }
.group_by(&:customer_id)
.transform_values { |group| group.sum(&:total_cents) }
.max_by(10) { |_id, total| total }
.to_hThat is correct, idiomatic, and genuinely hard to review. Every stage is a place a predicate could be inverted, a boundary could be exclusive where it should be inclusive, or an empty group could produce a surprising result — and none of those would look wrong.
Two practical habits:
Ask for intermediate names when the chain exceeds three stages. Not for style; so each step has a name you can assert on and a place to put a breakpoint.
Check the boundaries explicitly. max_by(10) on fewer than ten elements
returns what it has, which is usually fine. sum on an empty array returns 0,
which for money is fine and for an average is a division by zero waiting to
happen. first, min and max on an empty collection return nil, and the
nil propagates.
Related and worth knowing: select returns an array from a hash, not a hash, in
some Ruby versions and contexts — the .to_h at the end of chains exists for a
reason, and a suggestion that omits it produces a type the caller did not expect.
Metaprogramming: the review problem
This is the section that makes Ruby different from Python in this cluster.
A generated define_method loop that creates twelve accessors is concise and, if
the loop is right, correct. It is also invisible to grep, invisible to
go-to-definition, and produces a NoMethodError at runtime rather than an
obvious failure if a name is wrong.
The questions worth asking of any generated metaprogramming:
- Could this be plain methods? Twelve explicit methods are more lines and massively more reviewable. Repetition is not automatically a defect.
- Where do the names come from? A
define_methodover a hard-coded array is reviewable. One over a list from configuration or user input is a much larger surface. - Does
sendreceive anything derived from input?object.send(params[:action])is arbitrary method invocation.public_sendis narrower and still not safe if the name is attacker-controlled. - Is
instance_evalorclass_evalbeing given a string? String evaluation isevalwith extra steps. A block form is far more constrained.
Practical project: a small CLI
Practical example
A log-summarising CLI with explicit structure and Minitest coverage
Show the shape a well-constrained Ruby suggestion takes: plain objects, no metaprogramming, and boundaries covered by tests.
- Status
- Example implementation
- Not executed here
- This code was written against the documentation cited at the end of the lesson but was not run while writing it. Treat the commands as the ones to run, not as output that has been observed.
- Requires
- Ruby 3.x and Bundler. No gems beyond the standard library and Minitest.
Files
copilot-ruby-demo/ ├── Gemfile ├── bin/ │ └── summarise ├── lib/ │ └── summary.rb └── test/ └── summary_test.rb
The prompt
Ruby 3, standard library only.
Write a Summary class with a class method .from_lines(lines) returning an immutable Struct with line_count, error_count and longest_line.
No metaprogramming: no define_method, no method_missing, no send. Plain methods only.
Freeze the result. Handle an empty input without raising.
Then write Minitest tests covering: empty input, no matches, all matches, ERROR inside a longer word, and a line containing multi-byte characters.
The multi-byte case is deliberate. Ruby’s String#length counts characters and
#bytesize counts bytes, and a suggestion that reaches for the wrong one is a bug
that only appears on non-ASCII input.
Implementation
# frozen_string_literal: true
Summary = Struct.new(:line_count, :error_count, :longest_line) do
def to_h = { lines: line_count, errors: error_count, longest: longest_line }
end
class SummaryBuilder
ERROR_MARKER = "ERROR"
def self.from_lines(lines)
line_count = 0
error_count = 0
longest = 0
lines.each do |line|
line_count += 1
error_count += 1 if line.include?(ERROR_MARKER)
longest = line.length if line.length > longest
end
Summary.new(line_count, error_count, longest).freeze
end
endTwo details:
# frozen_string_literal: true. Without it, every string literal in the file allocates a new mutable object on each evaluation. Generated Ruby omits the magic comment; RuboCop’sStyle/FrozenStringLiteralCommentadds it..freezeon the result. AStructis mutable by default, so a returned summary can be modified by any caller. Freezing makes that aFrozenErrorrather than a mystery.
Tests
# frozen_string_literal: true
require "minitest/autorun"
require_relative "../lib/summary"
class SummaryBuilderTest < Minitest::Test
def test_empty_input_produces_zeroes
result = SummaryBuilder.from_lines([])
assert_equal 0, result.line_count
assert_equal 0, result.error_count
assert_equal 0, result.longest_line
end
def test_error_inside_a_longer_word_still_counts
result = SummaryBuilder.from_lines(["NOERRORHERE"])
assert_equal 1, result.error_count
end
def test_longest_line_counts_characters_not_bytes
result = SummaryBuilder.from_lines(["ünïcödé"])
assert_equal 7, result.longest_line
end
def test_result_is_frozen
assert_predicate SummaryBuilder.from_lines([]), :frozen?
end
endThe second test encodes a decision rather than checking behaviour: does ERROR
inside a longer word count? Both answers are defensible, and a generated
implementation picks one silently. Writing the test makes it a choice.
Run
bundle exec rubocop
bundle exec ruby test/summary_test.rbClasses, modules and the shape of generated Ruby
Ruby gives you several ways to organise the same behaviour, and generated code picks among them on the weight of the corpus rather than on the merits.
Modules as mixins versus objects as collaborators. A suggestion asked to
“extract this logic” will frequently produce a module that is included into the
class. That works, and it also means the extracted methods can see and modify the
including class’s private state — so nothing has actually been decoupled. A plain
object taking its dependencies in the constructor is more code and a real
boundary. Ask for it by name.
Service objects. The Rails community’s usual answer to “where does this
business logic go”, and one Copilot produces readily. The failure to check is a
service object with a single call method and eight constructor arguments, which
is a function with extra ceremony. If the object has no state worth holding
between calls, ask whether a module function would do.
Concerns. ActiveSupport::Concern is a mixin with lifecycle hooks, and it
shares the coupling problem above with the extra property that its included
block runs code you have to go and read elsewhere. Generated concerns are
frequently a way of moving a problem rather than solving it.
Struct and Data. For a value object, Ruby’s Data class gives you
immutability and a positional or keyword constructor with no boilerplate.
Generated code reaches for a hash or an OpenStruct instead — the latter being
particularly worth avoiding, since it responds to every method name and turns a
typo into nil.
The general heuristic: prefer the construction that makes a mistake an error.
A hash returns nil for an unknown key; a Data object raises. A mixin sees
everything; a collaborator sees what you passed it. In a language with no
compiler, each of those choices is a small amount of checking bought back.
Bundler, gems and the supply chain
Bundler with a committed Gemfile.lock gives Ruby a solid dependency story, and
the review points are the ones the lock file cannot cover.
Read the gem name. RubyGems names are first-come and flat — no vendor prefix, unlike Composer or Go modules — which makes a plausible wrong name easier to produce and harder to spot. Check the homepage, the source repository and the release history before adding one.
Check Gemfile.lock is committed and used. bundle install --deployment or
bundle config frozen true in CI makes the lock file authoritative, so a
dependency cannot drift between your machine and production.
bundle audit checks the lock file against known advisories. As with every
other language in this cluster, this is worth running regardless of who wrote the
code — the risk comes from the dependency, not from its author.
Watch for gems that are no longer maintained. Ruby’s ecosystem is mature, which means a substantial amount of what appears in public code was last released years ago. A suggestion importing a gem that solved a problem the standard library has since absorbed is common, and every dependency you accept is one you maintain forever.
Two smaller points: a git source in a Gemfile pins to a repository rather than
a released version, which is occasionally necessary and often an accidental
commit; and a gem added without a version constraint will float, which defeats
much of the point of having a lock file in the first place.
Testing
RSpec and Minitest are both mainstream and their syntax is entirely different, so name the one you use. Left alone, suggestions produce RSpec, which dominates public code.
Ruby-specific test advice:
- Ask for a
letper collaborator and asubjectin RSpec. Generated specs otherwise build the object inline in every example. - Question every mock. Ruby’s dynamic dispatch makes stubbing easy enough that generated specs stub things they should exercise, producing tests that assert the test’s own arrangement.
- Verify doubles.
instance_doublechecks the stubbed method actually exists; a plaindoubledoes not, so a spec keeps passing after the real method is renamed. In a language with no compiler this matters a great deal. - Watch shared examples. Concise and, when generated, frequently asserting less than they appear to.
Rails, briefly
Rails is where most Ruby is written, and three things change how generated Rails code should be reviewed.
Strong parameters are the security boundary. params.require(:user) .permit(:name, :email) is what stops a caller setting admin: true on a model.
A suggestion using params[:user] directly, or permit!, has removed that
boundary. This is the single most important thing to check in generated
controller code.
Callbacks hide control flow. A before_save that modifies an attribute, a
before_action that redirects — generated code adds these readily and the effect
is invisible from the method you are reading. Ask what runs, and when.
N+1 queries are the default. A view iterating records and touching an
association issues one query per row. includes fixes it, and generated code
adds it only when asked. The Bullet gem finds these in development.
Also worth knowing: find_by_sql and where with an interpolated string step
outside ActiveRecord’s parameterisation, exactly as whereRaw does in
PHP’s frameworks.
Ruby-specific risks
Over-generated metaprogramming. Covered above.
nil propagation. &. and dig are the safe navigation tools;
nil reaching arithmetic or string interpolation produces NoMethodError far
from the origin. Generated code assumes presence.
Mutable default state. A constant array or hash at class level is shared and
mutable. .freeze it.
Monkey patching. Reopening a core class in generated code changes behaviour globally. Refinements are the scoped alternative; explicit helper methods are usually better than either.
Gem version drift. Rails majors change substantially, and public code spans all of them. Name the version.
Symbol versus string keys. A hash from JSON has string keys; a hash written in
Ruby usually has symbol keys. Generated code mixes them, and the lookup silently
returns nil.
Static analysis, debugging and refactoring
RuboCop is the workhorse. Its Style department is a matter of taste; its Lint
department finds real defects — unreachable code, shadowed variables, a
useless assignment, a comparison that is always true. Run the whole thing and
treat Lint offences as errors.
Sorbet or RBS with Steep add gradual typing. Neither is universal in the Ruby community, and both add real value specifically for reviewing generated code, because they give the language something to check that it otherwise lacks.
For debugging, binding.irb (or debug) at the point of confusion is the fastest
route, and Ruby’s backtraces are readable enough that pasting one into chat works
well. The Ruby-specific tip: when a NoMethodError names a method you cannot
find, ask what defines it — that is a metaprogramming question and exactly the
kind of reading task where chat helps.
Refactoring is the weakest area, and honestly so. With no compiler and dynamic dispatch throughout, a rename is a text substitution and a moved method may be called from somewhere no static tool can see. The mitigation is the same one that applies to every dynamic language here: the safety of the refactor is exactly the quality of the test suite.
Review workflow
- Run RuboCop and the test suiteLint offences first; they are the ones that are bugs rather than opinions.
- Read every metaprogramming constructHuman judgementdefine_method, method_missing, send, instance_eval. What methods now exist, and where do the names come from?
- Trace nil through the chainHuman judgementWhich stages can produce nil, and what happens next?
- In Rails: check strong parameters and any raw SQLHuman judgement
- Check for N+1 queries in anything that iterates recordsHuman judgement
- Confirm shared constants and struct results are frozenHuman judgement
Steps marked Human judgement are the ones that do not get delegated — they are where you decide whether what Copilot produced is actually right.
Best practices
- Say “no metaprogramming unless you explain why it is needed” in the prompt.
- Name RSpec or Minitest, and ask for verifying doubles.
- Ask for
# frozen_string_literal: trueand frozen constants. - Break enumerable chains at three stages and name the intermediates.
- Run Brakeman on any Rails codebase, regardless of authorship.
Common mistakes
- Accepting a dense chain because it reads well.
- Letting
permit!or rawparamsthrough a review. - Trusting a plain
doublethat stubs a method which no longer exists. - Mixing symbol and string keys and reading
nil. - Interpolating into a
whereclause because the value “came from our own code.”
Where to go next
GitHub Copilot for Python is the closest neighbour — another expressive dynamic language where the review burden lands on tooling you have to choose to run. GitHub Copilot for SQL covers the query layer underneath ActiveRecord.
Sources
Every version-sensitive claim on this page was checked against first-party documentation. Only sources actually used are listed.
Your progress
Saved in this browser only. No account, no server, and nothing leaves your device. Clearing site data resets it.
Was this lesson helpful?
Your answer is stored in this browser and is not sent anywhere.