GitHub Copilot for Ruby

GitHub Copilot Programming LanguagesAcademy lesson 36Cluster 3 · Lesson 11 of 13Intermediate11 min readVersion-sensitive
Published
Updated
Last technically verified
GitHub Copilot for RubyGitHub Copilot Programming Languages11Intermediate/github-copilot/languages/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.

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_h

That 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_method over a hard-coded array is reviewable. One over a list from configuration or user input is a much larger surface.
  • Does send receive anything derived from input? object.send(params[:action]) is arbitrary method invocation. public_send is narrower and still not safe if the name is attacker-controlled.
  • Is instance_eval or class_eval being given a string? String evaluation is eval with 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

copilot-ruby-demo/ ├── Gemfile ├── bin/ │ └── summarise ├── lib/ │ └── summary.rb └── test/ └── summary_test.rb

The prompt

Copilot promptGenerate the core classCopilot Chat, with the Gemfile open

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
end

Two 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’s Style/FrozenStringLiteralComment adds it.
  • .freeze on the result. A Struct is mutable by default, so a returned summary can be modified by any caller. Freezing makes that a FrozenError rather 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
end

The 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.rb

Classes, 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 let per collaborator and a subject in 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_double checks the stubbed method actually exists; a plain double does 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

Accepting a Ruby suggestion
  1. Run RuboCop and the test suiteLint offences first; they are the ones that are bugs rather than opinions.
  2. Read every metaprogramming constructHuman judgementdefine_method, method_missing, send, instance_eval. What methods now exist, and where do the names come from?
  3. Trace nil through the chainHuman judgementWhich stages can produce nil, and what happens next?
  4. In Rails: check strong parameters and any raw SQLHuman judgement
  5. Check for N+1 queries in anything that iterates recordsHuman judgement
  6. 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: true and 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 raw params through a review.
  • Trusting a plain double that stubs a method which no longer exists.
  • Mixing symbol and string keys and reading nil.
  • Interpolating into a where clause 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.

Primary sources