skip to content

After a one-off rubocop -A run on a Ruby 4.0 app, tests fail with FrozenError and NoMethodError; what did the run change, and how should it have been done?

level: seniorimportance: should knowfreq 35%

answer

  1. -A applied unsafe corrections
  2. frozen_string_literal comment freezes literals
  3. x && x.foo became x&.foo
  4. one cop at a time with --only
  5. --diff previews without writing

basics

~20 s

rubocop -A applied corrections marked unsafe: the frozen-string-literal comment froze mutated literals (FrozenError), and x && x.foo became x&.foo, which breaks when x is false. Run -a first, then -A one cop at a time with tests between.

solid answer

~40 s

`-A` applies corrections RuboCop marks unsafe (`Safe: false` or `SafeAutoCorrect: false`), and a single run applies hundreds at once. `FrozenError` usually comes from `Style/FrozenStringLiteralComment`: the added `# frozen_string_literal: true` freezes literals that code later appends to, where Ruby 4.0 had only treated them as chilled; `Style/MutableConstant` adding `.freeze` does the same to constants. `NoMethodError` fits `Style/SafeNavigation`: `user && user.name` returns `false` for a `false` user, while `user&.name` calls `name` on `false`. The recovery is `git revert`, then redo it properly: run `-a` and commit; run `-A --only <Cop>` for one unsafe cop at a time, run the tests, commit each separately; preview with `rubocop --diff -A` (1.91+); and set `SafeAutoCorrect: false` or `AutoCorrect: disabled` for cops your code cannot take.

code

ruby · 13 lines
ruby
# before rubocop -A
ACTIVE_STATES = ["new", "paid"]

def label(user)
  user && user.name
end

# after Style/MutableConstant and Style/SafeNavigation
ACTIVE_STATES = ["new", "paid"].freeze   # ACTIVE_STATES << "held" now raises FrozenError

def label(user)
  user&.name                                # label(false) now raises NoMethodError
end

go deeper

for a junior

Recall that -A applies corrections RuboCop marks unsafe, so its changes need the test suite and a careful read of the diff.

for a middle

Explain how two unsafe corrections break code: the frozen-string-literal comment freezing mutated literals, and safe navigation differing when the receiver is false.

for a senior

Lead the recovery: revert, apply -a, then -A one cop per commit with tests, preview with --diff, and mark cops your code cannot take.

for a principal

Set the team rule that unattended tooling runs -a only, and that unsafe autocorrection is a reviewed, bisectable change with an owner.

## What happened A developer ran `rubocop -A` across a whole Ruby 4.0 application to clear a backlog of offenses. `-A` (`--autocorrect-all`) applies every correction RuboCop has, including those its authors marked **unsafe**: cops with `Safe: false` (they can flag correct code) and cops with `SafeAutoCorrect: false` (the offense is real, the fix can change behaviour). The run touched hundreds of files in one commit, and the test suite now fails in unrelated-looking places. ## Matching the errors to the cops | Symptom | Likely cop | Mechanism | |---|---|---| | `FrozenError` on `<<` or `concat` | `Style/FrozenStringLiteralComment` | adds `# frozen_string_literal: true`, freezing every literal in the file | | `FrozenError` on a constant | `Style/MutableConstant` | appends `.freeze` to a constant's array, hash or string literal | | `NoMethodError` on `false` | `Style/SafeNavigation` | `x && x.foo` becomes `x&.foo`, which differs when `x` is `false` | | `ArgumentError` in a block | `Style/SymbolProc` | `{ \|x\| x.foo }` becomes `&:foo`, whose proc checks arity like a lambda | The first row is the subtle one on Ruby 4.0. Without the magic comment, a string literal is **chilled**: mutating it succeeds and only warns when deprecation warnings are on, so the old code ran quietly. With `# frozen_string_literal: true`, the same literal is frozen and `buffer << line` raises `FrozenError`. ## Recovering 1. `git revert` the autocorrect commit so the main branch is green again. 2. Run `rubocop -a`, which applies safe corrections only; run the suite; commit. 3. List the remaining correctable offenses per cop (the generated todo file's comments, or `--format offenses`), and handle unsafe cops **one at a time**: - `rubocop -A --only Style/SafeNavigation` - read the diff, run the tests, fix what broke by hand; - commit with the cop's name in the message. 4. Mark cops that your code cannot take so they never run unsafely again. One commit per cop means a later failure can be bisected to a single cop and reverted alone. ## Tools that make this safer - **`--diff`** (RuboCop 1.91+) prints a unified diff of what autocorrection would change and writes nothing; combined with `-A` it previews unsafe corrections. It exits non-zero while there is anything left to correct. - **`--only`** narrows any run to named cops or departments; `-x` narrows it to `Layout`. - **`--disable-uncorrectable`** with `-a` inserts `# rubocop:todo` comments where it could not correct, including unsafe corrections it skipped (since 1.90), so the remaining work is visible in the code. - **Per-cop settings** in `.rubocop.yml`: `SafeAutoCorrect: false` makes `-a` skip a correction; `AutoCorrect: disabled` forbids it under every flag; `AutoCorrect: contextual` stops it in the editor while leaving the command line alone. ## Reading an autocorrect diff A reviewer cannot read five hundred files of mechanical change, but can check the risky shapes: - any file that gained `# frozen_string_literal: true` and also contains `<<`, `concat`, `gsub!` or another bang method on a string; - any `.freeze` added to a constant that the codebase later appends to or merges into; - any `&.` introduced where the receiver might be `false` rather than `nil`; - any block turned into `&:method` where the block took more or fewer arguments than one. Searching the diff for those patterns finds most behaviour changes in minutes. It is still no substitute for splitting the run by cop, because the search only covers the cops you thought of. ## Why it keeps happening - `-A` looks like "fix everything", and its unsafety is invisible in the command. - A large mechanical diff gets rubber-stamped in review, because no human can read hundreds of files of it. - Test suites rarely cover every mutation path, so a `FrozenError` may reach production rather than CI. The rule of thumb: tooling that rewrites code without a reviewer (editor save, pre-commit) uses `-a`; `-A` is a deliberate, reviewed change, one cop per commit.

  • How do you stop -a from ever applying Style/SafeNavigation's rewrite in this project?
    It already does not: `Style/SafeNavigation` ships with `SafeAutoCorrect: false`, so only `-A` applies it. To forbid the rewrite under `-A` too, set `AutoCorrect: disabled` for the cop in `.rubocop.yml`; the offense is still reported, and a developer fixes it by hand where `false` cannot occur.
  • Why does the same code raise FrozenError after the run but not before it, on Ruby 4.0?
    Ruby 4.0 does not freeze string literals by default; a literal in a file without the magic comment is chilled, so mutating it succeeds and warns only when deprecation warnings are enabled. The correction added `# frozen_string_literal: true`, which freezes every literal in that file, so the same `<<` now raises `FrozenError`.

saying these in an interview costs you the question

  • rubocop -A only reformats code, so it cannot change behaviour.
  • Ruby 4.0 already freezes string literals, so the magic comment changes nothing.
  • user && user.name and user&.name are equivalent for every value of user.
  • One big autocorrect commit is easier to review than one commit per cop.
  • A green rubocop run after -A proves the program still behaves the same.