Class: RuboCop::Cop::Hecks::SequentialHashRenameInLoop
- Inherits:
-
Base
- Object
- Base
- RuboCop::Cop::Hecks::SequentialHashRenameInLoop
- Defined in:
- lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb
Overview
FLAGS hash[new] = hash.delete(old) INSIDE A LOOP — a method that
both READS and WRITES the same hash across a loop's iterations,
in the specific rename/permutation shape this codebase has
already lost real data to once.
THE BUG THIS IS THE MECHANICAL FOLLOW-UP TO (M27,
docs/audits/2026-08-10-main-bug-audit.md,
docs/audits/2026-08-11-bug-triage.md; fixed in
Hecks::Ports::Persistence::Lineage#apply_renames,
lib/hecks/ports/persistence/plugins/era/lineage.rb): a rename
rule set applied one rule at a time, state[new_name] = state.delete(old_name) per rule, against the SAME hash it was
reading from — so a swap (rename :a, to: :b alongside rename :b, to: :a) on {a: 1, b: 2} collapsed to {a: 1}. The first
rule wrote b: 1 (clobbering the real b: 2) before the second
rule ever got a chance to read the value that was supposed to
move to :a — sequential edits stepping on each other, not the
simultaneous permutation a rename set actually means. The same
shape loses data identically for ANY chain longer than one link
(a->b->c->a), not just a two-element swap.
THE FIX THIS COP POINTS AT — snapshot every old key's value FIRST, delete all old keys, THEN write all new keys, so no rename in the pass ever reads a key this same pass has already written:
snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
snapshot.each { |_old_name, new_name, value| state[new_name] = value }
WHAT THIS COP DOES NOT ATTEMPT — the general checklist item is
broader than any AST pattern can safely automate ("any method
that both reads and writes the same collection across a loop"
covers plenty of correct code too — accumulating into a result
hash, memoizing into a cache). This cop stays narrow and
precise: only the EXACT recv[x] = recv.delete(y) shape, same
receiver both sides, inside something that iterates. A single
rename outside any loop is not this bug (nothing else can have
run first); hash.store(...) instead of hash[...] =, or
hash.delete(...) whose result is not immediately written back
into the SAME hash, are not this shape either and are left to
the broader code-review checklist item, not this cop.
Constant Summary collapse
- MSG =
"`%<recv>s[new] = %<recv>s.delete(old)` inside a loop applies one rename at a time against the " \ "SAME hash it reads from — a swap (`{a: :b, b: :a}`) on `{a: 1, b: 2}` collapses to `{a: 1}` " \ "because the first rule's write clobbers the second rule's read target before it runs (the exact " \ "bug fixed for Lineage#apply_renames). Snapshot every old key's value FIRST, delete all old keys, " \ "then write all new keys, so the pass applies as one simultaneous permutation instead of a " \ "sequence of edits each stepping on the last."
- RESTRICT_ON_SEND =
[:[]=].freeze
- LOOP_METHODS =
%i[ each each_pair each_with_index each_with_object with_index map collect flat_map each_entry each_key each_value inject reduce each_slice each_cons ].freeze
Instance Method Summary collapse
Instance Method Details
#on_send(node) ⇒ Object
85 86 87 88 89 90 91 92 |
# File 'lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb', line 85 def on_send(node) rename_write?(node) do |recv, recv2| next unless same_receiver?(recv, recv2) next unless in_loop?(node) add_offense(node, message: format(MSG, recv: recv.source)) end end |