Skip to content

[bot] Fix Style/StringHashKeys#880

Open
6[bot] wants to merge 2 commits intomainfrom
fix/style-string_hash_keys-23729853159
Open

[bot] Fix Style/StringHashKeys#880
6[bot] wants to merge 2 commits intomainfrom
fix/style-string_hash_keys-23729853159

Conversation

@6
Copy link
Copy Markdown
Contributor

@6 6 bot commented Mar 30, 2026

Automated fix fix for Style/StringHashKeys corpus conformance.

Refs #460

Details

Cop Style/StringHashKeys
Backend codex / hard
Model gpt-5.4 (xhigh)
Mode fix
Turns 1
Run #432

Result

Updated [string_hash_keys.rs](/home/runner/work/nitrocop/nitrocop/src/cop/style/string_hash_keys.rs) so `Style/StringHashKeys` now flags Prism `SourceFileNode` keys, which is the missing `__FILE__ => ...` case RuboCop catches. I also fixed [offense.rb](/home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/style/string_hash_keys/offense.rb) to use valid minimal `__FILE__` reproductions with correctly aligned `^` markers.

Validation passed with `cargo test --lib -- cop::style::string_hash_keys`, and the required corpus check `python3 scripts/check_cop.py Style/StringHashKeys --rerun --clone --sample 15` reported `0` new FP, `0` new FN, and `2` resolved FN. Commit: `a35f4921` (`Fix Style/StringHashKeys __FILE__ keys`).

Changes

  2 files changed
Agent conversation excerpt

Reading docs/agent-ci.md first, then I’ll inspect the current cop, RuboCop’s implementation, and the existing fixtures to pin down the two false negatives before editing anything.

Reading docs/agent-ci.md first, then I’ll inspect the current cop, RuboCop’s implementation, and the existing fixtures to pin down the two false negatives before editing anything.

exec_command: sed -n '1,220p' docs/agent-ci.md

exec_command: sed -n '1,260p' src/cop/style/string_hash_keys.rs

exec_command: sed -n '1,240p' vendor/rubocop/lib/rubocop/cop/style/string_hash_keys.rb

exec_command: sed -n '1,260p' vendor/rubocop/spec/rubocop/cop/style/string_hash_keys_spec.rb

exec_command: sed -n '1,220p' tests/fixtures/cops/style/string_hash_keys/offense.rb

exec_command: sed -n '1,260p' tests/fixtures/cops/style/string_hash_keys/no_offense.rb

I have the current implementation and the two failing shapes. Next I’m checking how Prism parses __FILE__ => ... in both contexts, because these FNs usually mean the cop is only looking at one node kind or one traversal path.

I have the current implementation and the two failing shapes. Next I’m checking how Prism parses __FILE__ => ... in both contexts, because these FNs usually mean the cop is only looking at one node kind or one traversal path.

exec_command: nl -ba src/cop/style/string_hash_keys.rs | sed -n '1,260p'

exec_command: rg -n "__FILE__|and_return\(__FILE__" -n tests vendor src -S

exec_command: rg -n "parse|prism" scripts src tests | head -n 40

The fixtures already contain the two missed cases, which is useful. I’m drilling into the visitor and Prism node shapes now so I can make the smallest possible detection change and keep the existing 688k matches stable.

The fixtures already contain the two missed cases, which is useful. I’m drilling into the visitor and Prism node shapes now so I can make the smallest possible detection change and keep the existing 688k matches stable.

exec_command: nl -ba src/cop/style/string_hash_keys.rs | sed -n '80,260p'

exec_command: nl -ba src/cop/style/string_hash_keys.rs | sed -n '260,360p'

exec_command: rg -n "SourceFileNode|source_file_node|__FILE__" src/cop -S

exec_command: rg -n "debug.*prism|print.*ast|as_source_file_node|pretty_print" src tests scripts -S

exec_command: cargo test --lib -- cop::style::string_hash_keys

The targeted test run is in progress. In parallel I’m preparing to inspect the AST shape for __FILE__, because the likely gap is that Prism represents it differently from quoted strings and the visitor only handles StringNode.

The targeted test run is in progress. In parallel I’m preparing to inspect the AST shape for __FILE__, because the likely gap is that Prism represents it differently from quoted strings and the visitor only handles StringNode.

exec_command: rg -n "fn .*source_file_node|source_file_node\(" ~/.cargo/registry/src -S | head -n 40

exec_command: rg -n "debug: prism parse|debug: AST walk|RUST_LOG|debug" src/linter.rs src/main.rs -S

exec_command: cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name=="nitrocop") | .manifest_path'

exec_command: cargo metadata --format-version 1 | jq -r '.packages[] | select(.name=="ruby-prism") | .manifest_path'

exec_command: rg -n "struct SourceFileNode|enum Node|fn as_source_file_node|SourceFileNode" /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/ruby-prism-1.9.0 -S

write_stdin: ``

The failure is confirmed and isolated: the fixture expects 12 offenses, the cop only emits 10, exactly missing the two __FILE__ keys. I’m checking the generated Prism node API next so I can patch this without broadening the matcher beyond RuboCop’s behavior.

The failure is confirmed and isolated: the fixture expects 12 offenses, the cop only emits 10, exactly missing the two __FILE__ keys. I’m checking the generated Prism node API next so I can patch this without broadening the matcher beyond RuboCop’s behavior.

exec_command: find target/debug/build -path '*ruby-prism*' -type f | rg '/out/|generated|node' -n

exec_command: rg -n "SourceFileNode" target/debug/build /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/ruby-prism-1.9.0 -S | head -n 60

exec_command: sed -n '14127,14220p' target/debug/build/ruby-prism-a0d940e303385573/out/bindings.rs

exec_command: sed -n '2670,2690p' target/debug/build/ruby-prism-sys-b415b3d38a1d5133/out/bindings.rs

Plan:

  1. Confirm the failing cases are specifically __FILE__ hash keys and isolate the Prism node type involved.
  2. Patch src/cop/style/string_hash_keys.rs to treat that node like RuboCop does, while preserving the existing narrow exemptions.
  3. Re-run the targeted cop test.
  4. Run the required corpus check with scripts/check_cop.py.
  5. Commit only the cop source and its fixture files if validation stays clean.

Plan:

  1. Confirm the failing cases are specifically __FILE__ hash keys and isolate the Prism node type involved.
  2. Patch src/cop/style/string_hash_keys.rs to treat that node like RuboCop does, while preserving the existing narrow exemptions.
  3. Re-run the targeted cop test.
  4. Run the required corpus check with scripts/check_cop.py.
  5. Commit only the cop source and its fixture files if validation stays clean.

update_plan

I’ve narrowed the gap to SourceFileNode handling. Before editing, I’m doing a quick RuboCop sanity check on the exact __FILE__ patterns so the patch stays aligned with the source of truth.

I’ve narrowed the gap to SourceFileNode handling. Before editing, I’m doing a quick RuboCop sanity check on the exact __FILE__ patterns so the patch stays aligned with the source of truth.

exec_command: printf '%s ' '__FILE__ => [1]' > /tmp/string_hash_keys_fn1.rb && rubocop --only Style/StringHashKeys /tmp/string_hash_keys_fn1.rb

exec_command: printf '%s ' 'expect(Coverage).to receive(:result).once.and_return(__FILE__ => [0, 1])' > /tmp/string_hash_keys_fn2.rb && rubocop --only Style/StringHashKeys /tmp/string_hash_keys_fn2.rb

write_stdin: ``

write_stdin: ``

One thing surfaced from the RuboCop check: the standalone __FILE__ => [...] snippet isn’t valid Ruby on its own. I’m going to tighten the fixture to a valid minimal reproduction of the real corpus case before patching the cop, so the test exercises the actual AST shape instead of an error-recovery parse.

One thing surfaced from the RuboCop check: the standalone __FILE__ => [...] snippet isn’t valid Ruby on its own. I’m going to tighten the fixture to a valid minimal reproduction of the real corpus case before patching the cop, so the test exercises the actual AST shape instead of an error-recovery parse.

exec_command: nl -ba tests/fixtures/cops/style/string_hash_keys/offense.rb

exec_command: printf '%s ' 'assert_equal({' ' __FILE__ => [1]' '})' > /tmp/string_hash_keys_fn1_valid.rb && rubocop --only Style/StringHashKeys /tmp/string_hash_keys_fn1_valid.rb

I’m correcting the new fixture lines themselves too. Both were added with placeholder ^ markers at column 0, so they won’t validate the real source locations until those columns are fixed.

I’m correcting the new fixture lines themselves too. Both were added with placeholder ^ markers at column 0, so they won’t validate the real source locations until those columns are fixed.

exec_command: python3 - <<'PY' line = 'h = { __FILE__ => [1] }' print(line) print(line.index('__FILE__')) line2 = 'expect(Coverage).to receive(:result).once.and_return(__FILE__ => [0, 1])' print(line2) print(line2.

exec_command: printf '%s ' 'h = { __FILE__ => [1] }' > /tmp/string_hash_keys_fn1_simple.rb && rubocop --only Style/StringHashKeys /tmp/string_hash_keys_fn1_simple.rb

I’m editing two places now: the offense fixture to use valid, correctly annotated __FILE__ reproductions, and the cop to treat Prism SourceFileNode keys like RuboCop’s string-key handling.

I’m editing two places now: the offense fixture to use valid, correctly annotated __FILE__ reproductions, and the cop to treat Prism SourceFileNode keys like RuboCop’s string-key handling.

apply_patch

The patch is in place. I’m running the targeted cop test now to confirm the two __FILE__ cases are detected and that the existing exemptions still hold.

Task prompt (3482 tokens)

Fix Style/StringHashKeys — 0 FP, 2 FN

Instructions

You are fixing ONE cop in nitrocop, a Rust Ruby linter that uses Prism for parsing.

Current state: 688,706 matches, 0 false positives, 2 false negatives.
Focus on: FN (RuboCop flags code nitrocop misses).

⚠ 688,706 existing matches must not regress. Validate with check_cop.py before committing.

Workflow

  1. Read the Pre-diagnostic Results and Corpus FP/FN Examples sections below first
  2. Verify with RuboCop first (for FP fixes): before writing any code, confirm RuboCop's
    behavior on BOTH the specific FP case AND the general pattern:
    echo '<specific FP case>' > /tmp/test.rb && rubocop --only Style/StringHashKeys /tmp/test.rb
    echo '<general pattern>' > /tmp/test.rb && rubocop --only Style/StringHashKeys /tmp/test.rb
    If RuboCop flags the general pattern, your fix must be narrow enough to not suppress it.
  3. Add a test case FIRST:
    • FN fix: add the missed pattern to tests/fixtures/cops/style/string_hash_keys/offense.rb with ^ annotation
    • FP fix: add the false-positive pattern to tests/fixtures/cops/style/string_hash_keys/no_offense.rb
  4. Verify test fails: cargo test --lib -- cop::style::string_hash_keys
  5. Fix src/cop/style/string_hash_keys.rs
  6. Verify test passes: cargo test --lib -- cop::style::string_hash_keys
  7. Validate against corpus (REQUIRED before committing):
    python3 scripts/check_cop.py Style/StringHashKeys --rerun --clone --sample 15
    If this reports FP or FN regression, your fix is too broad — narrow it down.
  8. Add a /// doc comment on the cop struct documenting what you found and fixed
  9. Commit only your cop's files

Fixture Format

Mark offenses with ^ markers on the line AFTER the offending source line.
The ^ characters must align with the offending columns. The message format is Style/StringHashKeys: <message text>.
See the Current Fixture sections below for real examples from this cop.

If your test passes immediately

If you add a test case and it passes without code changes, the corpus mismatch is
caused by config/context differences, not a detection bug.
Do NOT loop trying to make the test fail. Instead:

  1. Investigate config resolution (Include/Exclude, cop enablement, disable comments)
  2. The fix is likely in src/config/ or the cop's config handling, not detection logic
  3. If you cannot determine the root cause within 5 minutes, document your findings as
    a /// comment on the cop struct and commit

CRITICAL: Avoid regressions in the opposite direction

When fixing FPs, your change MUST NOT suppress legitimate detections. When fixing FNs,
your change MUST NOT flag code that RuboCop accepts. A fix that eliminates a few issues
in one direction but introduces hundreds in the other is a catastrophic regression.

Before exempting a category of patterns, verify with RuboCop that the general case
is still an offense:

rubocop --only Style/StringHashKeys /tmp/test.rb

If RuboCop flags the general pattern but not your specific case, the difference is in
a narrow context (e.g., enclosing structure, receiver type, argument count) — your fix
must target that specific context, not the broad category.

Rule of thumb: if your fix adds an early return or continue that skips a whole
node type, operator class, or naming pattern, it's probably too broad. Prefer adding a
condition that matches the SPECIFIC differentiating context.

Rules

  • Only modify src/cop/style/string_hash_keys.rs and tests/fixtures/cops/style/string_hash_keys/
  • Run cargo test --lib -- cop::style::string_hash_keys to verify your fix (do NOT run the full test suite)
  • Run python3 scripts/check_cop.py Style/StringHashKeys --rerun --clone --sample 15 before committing to catch regressions
  • Do NOT touch unrelated files
  • Do NOT use git stash
  • Do NOT push — you do not have push permission; the workflow handles pushing after you exit

Prism Notes

  • hash splits into HashNode (literal {}) and KeywordHashNode (keyword args foo(a: 1)). If you handle one, check if you need the other.
  • const splits into ConstantReadNode (simple Foo) and ConstantPathNode (qualified Foo::Bar). If you handle one, check if you need the other.

Current Fixture: offense.rb

tests/fixtures/cops/style/string_hash_keys/offense.rb

{ 'foo' => 1 }
  ^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

{ 'bar' => 2, 'baz' => 3 }
  ^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.
              ^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

x = { 'key' => 'value' }
      ^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

# String keys in a hash that is the receiver of gsub (not an argument)
{ 'expiration' => time, 'conditions' => conds }.to_json.gsub("\n", "")
  ^^^^^^^^^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.
                        ^^^^^^^^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

# String keys in a hash nested inside an array argument of IO.popen
IO.popen([{"FOO" => "bar"}, "ruby", "foo.rb"])
           ^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

# String keys in a block inside spawn/system (not direct arg)
system("cmd") do
  x = { 'inner' => 1 }
        ^^^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.
end

# Non-identifier string keys are also flagged (RuboCop autocorrects to :"Content-Type" etc.)
{ "Content-Type" => "text/html" }
  ^^^^^^^^^^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.
{ "foo bar" => 1 }
  ^^^^^^^^^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

Current Fixture: no_offense.rb

tests/fixtures/cops/style/string_hash_keys/no_offense.rb

{ foo: 1 }
{ bar: 2, baz: 3 }
{ :foo => 1 }
x = { key: 'value' }
y = {}
z = { 1 => 'a' }

# IO.popen with env hash (string keys for env vars are exempted)
IO.popen({"RUBYOPT" => '-w'}, 'ruby', 'foo.rb')
IO.popen({"FOO" => "bar"}, 'cmd') do |io|
  io.read
end

# Open3 methods with env hash
Open3.capture3({"RUBYOPT" => '-w'}, 'ruby', 'foo.rb')
Open3.capture2({"PATH" => '/usr/bin'}, 'ls')
Open3.popen3({"HOME" => '/tmp'}, 'bash')

# Open3.pipeline with env hash inside array
Open3.pipeline([{"RUBYOPT" => '-w'}, 'ruby', 'foo.rb'], ['wc', '-l'])

# spawn/system with env hash
spawn({"FOO" => "bar"}, "cmd")
system({"FOO" => "bar"}, "cmd")
Kernel.spawn({"FOO" => "bar"}, "cmd")
Kernel.system({"FOO" => "bar"}, "cmd")

# gsub/gsub! with string replacement hash
"hello".gsub(/pattern/, "old" => "new")
"hello".gsub!(/pattern/, "old" => "new")

# Heredoc used as hash key (Parser gem treats as dstr, not str)
produces(<<-EXAMPLE => 'defined(foo)')
  class bar { }
EXAMPLE

# Another heredoc key style
x = { <<~KEY => 'value' }
  multiline content
KEY

# String keys with invalid encoding (RuboCop skips via valid_encoding? check)
h = { "\x80" => 1 }
h = { "\xC0" => 2 }
h = { "\xFF" => 3 }
h = { "\251" => 4 }

# Multi-line string keys (Parser gem treats as dstr, not str; RuboCop skips)
h = { "first line
second line" => 1 }
h = { %(UPDATE
  dogs
SET
  name = 'fido') => false }
h = { 'multi
line
key' => :value }

Key Source Files

  • Rust implementation: src/cop/style/string_hash_keys.rs
  • RuboCop Ruby source (ground truth): vendor/rubocop/lib/rubocop/cop/style/string_hash_keys.rb
  • RuboCop test excerpts: vendor/rubocop/spec/rubocop/cop/style/string_hash_keys_spec.rb

Read these files before making changes.

Start Here

Use the existing corpus data to focus on the most concentrated regressions first.

Helpful local commands:

  • python3 scripts/investigate_cop.py Style/StringHashKeys --repos-only
  • python3 scripts/investigate_cop.py Style/StringHashKeys --context
  • python3 scripts/verify_cop_locations.py Style/StringHashKeys

Top FN repos:

  • archan937__motion-bundler__9cbfc90 (1 FN) — example test/unit/require/test_tracer.rb:81
  • simplecov-ruby__simplecov__522dc7d (1 FN) — example spec/simplecov_spec.rb:35

Representative FN examples:

  • archan937__motion-bundler__9cbfc90: test/unit/require/test_tracer.rb:81 — Prefer symbols instead of strings as hash keys.
  • simplecov-ruby__simplecov__522dc7d: spec/simplecov_spec.rb:35 — Prefer symbols instead of strings as hash keys.

Pre-diagnostic Results

Diagnosis Summary

Each example was tested by running nitrocop on the extracted source in isolation
with --force-default-config to determine if the issue is a code bug or config issue.
Note: source context is truncated and may not parse perfectly. If a diagnosis
seems wrong (e.g., your test passes immediately for a 'CODE BUG'), treat it as
a config/context issue instead.

  • FN: 2 code bug(s)

FN #1: archan937__motion-bundler__9cbfc90: test/unit/require/test_tracer.rb:81

NOT DETECTED — CODE BUG
The cop fails to detect this pattern. Fix the detection logic.

Full-file enclosing chain: block ({..}) (line 80: assert_equal({) > block (do..end) (line 71: it "should log dependencies as expected" do) > block (do..end) (line 70: describe "tracing require statements" do) > block (do..end) (line 7: describe MotionBundler::Require::Tracer do) > class body (line 5: class TestTracer < MiniTest::Unit::TestCase) > module body (line 4: module Require) > module body (line 3: module Unit)

Message: Prefer symbols instead of strings as hash keys.

Ready-made test snippet (add to offense.rb, adjust ^ count):

              __FILE__ => [
^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

Full file context (30 lines before offense):

       51:               MotionBundler::Require::Tracer.send :start
       52:               MotionBundler::Require::Tracer.log.expects(:clear)
       53:               MotionBundler::Require::Tracer.send :start
       54:             end
       55:           end
       56: 
       57:           it "should hook into require relatied core methods" do
       58:             MotionBundler::Require::Tracer.expects :hook
       59:             MotionBundler::Require::Tracer.send :start
       60:           end
       61:         end
       62: 
       63:         describe "calling `stop`" do
       64:           it "should unhook form require relatied core methods" do
       65:             MotionBundler::Require::Tracer.expects :unhook
       66:             MotionBundler::Require::Tracer.send :stop
       67:           end
       68:         end
       69: 
       70:         describe "tracing require statements" do
       71:           it "should log dependencies as expected" do
       72:             assert_equal({}, MotionBundler::Require::Tracer.log.instance_variable_get(:@files_dependencies))
       73: 
       74:             MotionBundler::Require::Tracer.yield do
       75:               require "d/a"
       76:               require "d/b"
       77:               require "d/c"
       78:             end
       79: 
       80:             assert_equal({
>>>    81:               __FILE__ => [
       82:                 lib_file("d/a.rb"),
       83:                 lib_file("d/b.rb"),
       84:                 lib_file("d/c.rb")
       85:               ],
       86:               lib_file("d/b.rb") => [
       87:                 lib_file("d/b/a.rb"),
       88:                 lib_file("d/b/b.rb")

FN #2: simplecov-ruby__simplecov__522dc7d: spec/simplecov_spec.rb:35

NOT DETECTED — CODE BUG
The cop fails to detect this pattern. Fix the detection logic.

Full-file enclosing chain: block (do..end) (line 34: it "uses the result from Coverage" do) > block (do..end) (line 29: context "when running" do) > block (do..end) (line 13: context "with merging disabled" do) > block (do..end) (line 7: describe ".result" do) > block (do..end) (line 6: describe SimpleCov do)

Message: Prefer symbols instead of strings as hash keys.

Ready-made test snippet (add to offense.rb, adjust ^ count):

          expect(Coverage).to receive(:result).once.and_return(__FILE__ => [0, 1])
^ Style/StringHashKeys: Prefer symbols instead of strings as hash keys.

Full file context (30 lines before offense):

        5: 
        6: describe SimpleCov do
        7:   describe ".result" do
        8:     before do
        9:       SimpleCov.clear_result
       10:       allow(Coverage).to receive(:result).once.and_return({})
       11:     end
       12: 
       13:     context "with merging disabled" do
       14:       before do
       15:         allow(SimpleCov).to receive(:use_merging).once.and_return(false)
       16:         expect(SimpleCov).not_to receive(:wait_for_other_processes)
       17:       end
       18: 
       19:       context "when not running" do
       20:         before do
       21:           allow(SimpleCov).to receive(:running).and_return(false)
       22:         end
       23: 
       24:         it "returns nil" do
       25:           expect(SimpleCov.result).to be_nil
       26:         end
       27:       end
       28: 
       29:       context "when running" do
       30:         before do
       31:           allow(SimpleCov).to receive(:running).and_return(true, false)
       32:         end
       33: 
       34:         it "uses the result from Coverage" do
>>>    35:           expect(Coverage).to receive(:result).once.and_return(__FILE__ => [0, 1])
       36:           expect(SimpleCov.result.filenames).to eq [__FILE__]
       37:         end
       38: 
       39:         it "adds not-loaded-files" do
       40:           expect(SimpleCov).to receive(:add_not_loaded_files).once.and_return({})
       41:           SimpleCov.result
       42:         end

@6 6 bot force-pushed the fix/style-string_hash_keys-23729853159 branch from a35f492 to 0c01ce7 Compare March 30, 2026 06:00
@6 6 bot marked this pull request as ready for review March 30, 2026 06:00
@6 6 bot enabled auto-merge (squash) March 30, 2026 06:00
@github-actions
Copy link
Copy Markdown
Contributor

Cop-check results (8 shards)

Cop Baseline FP Baseline FN Local FP Local FN FP Δ FN Δ Result
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 2 0 0 0 -2
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 0 0 0 0 0
Style/StringHashKeys 0 0 0 0 0 0

All shards passed — ready to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants