Skip to content

[bot] Fix Style/HashExcept#898

Open
6[bot] wants to merge 4 commits intomainfrom
fix/style-hash_except-23730408076
Open

[bot] Fix Style/HashExcept#898
6[bot] wants to merge 4 commits intomainfrom
fix/style-hash_except-23730408076

Conversation

@6
Copy link
Copy Markdown
Contributor

@6 6 bot commented Mar 30, 2026

Automated fix fix for Style/HashExcept corpus conformance.

Refs #368

Details

Cop Style/HashExcept
Backend claude-oauth / hard
Model Claude Opus 4.6 (OAuth, high)
Mode fix
Turns 39
Run #454

Result

Done. Here's a summary of the changes:

**Problem:** Style/HashExcept had 189 false negatives — all caused by missing `include?` pattern detection. The cop only handled `==`/`!=` comparisons.

**Fix:** Added detection for two `include?` patterns:
- `hash.reject { |k, _| COLLECTION.include?(k) }` → `except(*COLLECTION)`
- `hash.select/filter { |k, _| !COLLECTION.include?(k) }` → `except(*COLLECTION)`

**Key details:**
- Array literal receivers (e.g., `[:foo, :bar]`) produce expanded args: `except(:foo, :bar)`
- Variables/constants produce splatted args: `except(*KEYS)`
- Correctly skips Range receivers, value-parameter usage, and inverted semantics
- Works with both `{ }` and `do...end` block syntax

**Results:** 0 FP, 16 FN remaining (down from 189) — 173 FN resolved with zero regressions.

Changes

  1 files changed
Agent conversation excerpt

Read: /home/runner/work/nitrocop/nitrocop/docs/agent-ci.md

Read: /home/runner/work/nitrocop/nitrocop/src/cop/style/hash_except.rs

Read: /home/runner/work/nitrocop/nitrocop/vendor/rubocop/lib/rubocop/cop/style/hash_except.rb

Now let me read the HashSubset mixin and the RuboCop spec to understand the full detection patterns.

Agent

Read: /home/runner/work/nitrocop/nitrocop/vendor/rubocop/spec/rubocop/cop/style/hash_except_spec.rb

Read: /home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/style/hash_except/offense.rb

Read: /home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/style/hash_except/no_offense.rb

Now let me understand how diagnostics work and look at how other cops handle span calculation.

Agent

Read: /home/runner/work/nitrocop/nitrocop/vendor/rubocop/lib/rubocop/cop/mixin/hash_subset.rb

Let me check how Prism array nodes work and look at the test utility for fixture formatting.

Grep: as_array_node

Read: /home/runner/work/nitrocop/nitrocop/src/testutil.rs

Now let me check the cop_fixture_tests macro to understand how the test uses fixtures, and verify how as_range_node is available.

Grep: macro_rules! cop_fixture_tests

Grep: as_range_node

Good. Now I have all the information needed. Let me implement the changes. First, I'll update the offense.rb fixture with correct test cases, then implement the include? detection logic.

ToolSearch

TodoWrite

Now let me write the updated offense.rb fixture with correct ^ positions for the include? patterns.

Write

TodoWrite

Bash: cargo test --lib -- cop::style::hash_except 2>&1 | tail -30

Tests fail as expected (4 detected, 6 new ones missing). Now let me implement the include? pattern detection.

TodoWrite

Write

TodoWrite

Bash: cargo test --lib -- cop::style::hash_except 2>&1 | tail -30

Need to fix a borrow issue - outer_call from pattern match needs &.

Edit: /home/runner/work/nitrocop/nitrocop/src/cop/style/hash_except.rs

Bash: cargo test --lib -- cop::style::hash_except 2>&1 | tail -20

Both tests pass. Now let me add no_offense cases for include? edge cases, then validate against the corpus.

TodoWrite

Read: /home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/style/hash_except/no_offense.rb

Edit: /home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/style/hash_except/no_offense.rb

Bash: cargo test --lib -- cop::style::hash_except 2>&1 | tail -10

All tests pass. Now let me validate against the corpus.

TodoWrite

Bash: cargo build --release 2>&1 | tail -5

Bash: python3 scripts/check_cop.py Style/HashExcept --rerun --clone --sample 15 2>&1

Excellent! The corpus check passes with zero regressions. Results:

  • 0 new FP (no false positives introduced)
  • 0 new FN regressions (no existing detections broken)
  • 173 FN resolved (down from 189 to 16)
  • All 192 existing matches preserved

TodoWrite

The doc comment is already on the struct. Let me commit.

Bash: git status

Bash: git diff

Bash: git log --oneline -5

Everything looks clean. Let me commit.

Bash: git add src/cop/style/hash_except.rs tests/fixtures/cops/style/hash_except/offense.rb tests/fixtures/cops/style/hash_except/no_offense.rb

Bash: `git commit -m "$(cat <<'EOF'
Fix Style/HashExcept: detect include? patterns (173 FN resolved)

Add detection for the include? family of patterns that RuboCop flags
but nitrocop previously missed:

-`

TodoWrite

Done. Here's a summary of the changes:

Problem: Style/HashExcept had 189 false negatives — all caused by missing include? pattern detection. The cop only handled ==/!= comparisons.

Fix: Added detection for two include? patterns:

  • hash.reject { |k, _| COLLECTION.include?(k) }except(*COLLECTION)
  • hash.select/filter { |k, _| !COLLECTION.include?(k) }except(*COLLECTION)

Key details:

  • Array literal receivers (e.g., [:foo, :bar]) produce expanded args: except(:foo, :bar)
  • Variables/constants produce splatted args: except(*KEYS)
  • Correctly skips Range receivers, value-parameter usage, and inverted semantics
  • Works with both { } and do...end block syntax

Results: 0 FP, 16 FN remaining (down from 189) — 173 FN resolved with zero regressions.

Task prompt (7420 tokens)

Fix Style/HashExcept — 0 FP, 189 FN

Instructions

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

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

⚠ 192 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/HashExcept /tmp/test.rb
    echo '<general pattern>' > /tmp/test.rb && rubocop --only Style/HashExcept /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/hash_except/offense.rb with ^ annotation
    • FP fix: add the false-positive pattern to tests/fixtures/cops/style/hash_except/no_offense.rb
  4. Verify test fails: cargo test --lib -- cop::style::hash_except
  5. Fix src/cop/style/hash_except.rs
  6. Verify test passes: cargo test --lib -- cop::style::hash_except
  7. Validate against corpus (REQUIRED before committing):
    python3 scripts/check_cop.py Style/HashExcept --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/HashExcept: <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/HashExcept /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/hash_except.rs and tests/fixtures/cops/style/hash_except/
  • Run cargo test --lib -- cop::style::hash_except to verify your fix (do NOT run the full test suite)
  • Run python3 scripts/check_cop.py Style/HashExcept --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

Current Fixture: offense.rb

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

{foo: 1, bar: 2, baz: 3}.reject { |k, v| k == :bar }
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Style/HashExcept: Use `except(:bar)` instead.
{foo: 1, bar: 2, baz: 3}.select { |k, v| k != :bar }
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Style/HashExcept: Use `except(:bar)` instead.
{foo: 1, bar: 2, baz: 3}.filter { |k, v| k != :bar }
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Style/HashExcept: Use `except(:bar)` instead.
hash.reject { |k, v| k == 'str' }
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Style/HashExcept: Use `except('str')` instead.

Current Fixture: no_offense.rb

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

{foo: 1, bar: 2, baz: 3}.except(:bar)
{foo: 1, bar: 2, baz: 3}.reject { |k, v| k != :bar }
{foo: 1, bar: 2, baz: 3}.select { |k, v| k == :bar }
{foo: 1, bar: 2, baz: 3}.reject { |k, v| v.eql? :bar }
{foo: 1, bar: 2, baz: 3}.reject
hash.reject { |k, v| k == 0.0 }

Key Source Files

  • Rust implementation: src/cop/style/hash_except.rs
  • RuboCop Ruby source (ground truth): vendor/rubocop/lib/rubocop/cop/style/hash_except.rb
  • RuboCop test excerpts: vendor/rubocop/spec/rubocop/cop/style/hash_except_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/HashExcept --repos-only
  • python3 scripts/investigate_cop.py Style/HashExcept --context
  • python3 scripts/verify_cop_locations.py Style/HashExcept

Top FN repos:

  • drhenner__ror_ecommerce__b7142f0 (17 FN) — example spec/controllers/admin/customer_service/comments_controller_spec.rb:34
  • cenit-io__cenit__0106da3 (8 FN) — example app/controllers/api/v1/api_controller.rb:402
  • DataDog__dd-trace-rb__e3406de (4 FN) — example lib/datadog/appsec/contrib/rails/gateway/request.rb:58

Representative FN examples:

  • AndyObtiva__glimmer-dsl-web__fd77e68: lib/rails/resource_service.rb:92 — Use except(*TIMESTAMP_ATTRIBUTES) instead.
  • Arachni__arachni__c1710d6: lib/arachni/element/capabilities/analyzable/signature.rb:211 — Use except(*opts[:payload_platforms]) instead.
  • Arachni__arachni__c1710d6: lib/arachni/element/dom.rb:86 — Use except(:@parent, :@page, :@browser, :@element) instead.

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: 15 code bug(s)

FN #1: AndyObtiva__glimmer-dsl-web__fd77e68: lib/rails/resource_service.rb:92

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

Full-file enclosing chain: method body (line 88: def create_update_resource_params(resource: nil, resource_class: nil, resource_attributes: nil, singular_resource_name: nil, params: nil)) > class body (line 8: class << self) > class body (line 5: class ResourceService) > module body (line 1: module Rails)

Prism AST at offense line: LocalVariableOrWriteNode > CallNode > CallNode > LocalVariableReadNode > BlockNode > BlockParametersNode > ParametersNode > RequiredParameterNode > RequiredParameterNode > StatementsNode > CallNode > ConstantReadNode > ArgumentsNode > LocalVariableReadNode

Message: Use except(*TIMESTAMP_ATTRIBUTES) instead.

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

        resource_attributes ||= resource&.to_h&.reject { |attribute, value| TIMESTAMP_ATTRIBUTES.include?(attribute) }
^ Style/HashExcept: Use `except(*TIMESTAMP_ATTRIBUTES)` instead.

Full file context (30 lines before offense):

       62:         HTTP.patch(update_resource_url, payload: create_update_resource_params(resource:, resource_class:, resource_attributes:, singular_resource_name:, params: params.to_h)) do |response|
       63:           if response.ok?
       64:             if !resource_class.nil?
       65:               resource_response_object = Native(response.body)
       66:               resource = build_resource_from_response_object(resource_class:, resource_response_object:)
       67:             end
       68:           else
       69:             errors = JSON.parse(response.body)
       70:           end
       71:           response_handler.call(response, resource, errors)
       72:         end
       73:       end
       74:       
       75:       def destroy(resource: nil, resource_class: nil, resource_id: nil, singular_resource_name: nil, plural_resource_name: nil, destroy_resource_url: nil, params: nil, &response_handler)
       76:         resource_class ||= resource&.class
       77:         resource_id ||= resource&.id
       78:         singular_resource_name ||= singular_resource_name_for_resource_class(resource_class)
       79:         plural_resource_name ||= "#{singular_resource_name}s"
       80:         destroy_resource_url ||= "/#{plural_resource_name}/#{resource_id}.json"
       81:         HTTP.delete(destroy_resource_url, payload: index_show_destroy_resource_params(params: params.to_h), &response_handler)
       82:       end
       83:       
       84:       def index_show_destroy_resource_params(params: nil)
       85:         {authenticity_token:}.merge(params.to_h)
       86:       end
       87:       
       88:       def create_update_resource_params(resource: nil, resource_class: nil, resource_attributes: nil, singular_resource_name: nil, params: nil)
       89:         resource_class ||= resource&.class
       90:         singular_resource_name ||= singular_resource_name_for_resource_class(resource_class)
       91:         resource_params = {authenticity_token:}
>>>    92:         resource_attributes ||= resource&.to_h&.reject { |attribute, value| TIMESTAMP_ATTRIBUTES.include?(attribute) }
       93:         resource_params[singular_resource_name] = resource_attributes.to_h
       94:         resource_params = resource_params.merge(params.to_h)
       95:         resource_params
       96:       end
       97:       
       98:       def authenticity_token
       99:         Element['meta[name=csrf-token]'].attr('content')

FN #2: Arachni__arachni__c1710d6: lib/arachni/element/capabilities/analyzable/signature.rb:211

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

Full-file enclosing chain: enclosing line 186: when Hash > case expression (line 179: case signatures) > method body (line 175: def find_signatures( signatures, response, opts )) > module body (line 16: module Signature)

Prism AST at offense line: CallNode > CallNode > CallNode > BlockNode > BlockParametersNode > ParametersNode > RequiredParameterNode > RequiredParameterNode > StatementsNode > CallNode > CallNode > CallNode > CallNode > ArgumentsNode > SymbolNode > ArgumentsNode > LocalVariableReadNode

Message: Use except(*opts[:payload_platforms]) instead.

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

                signatures.select { |p, _|  !opts[:payload_platforms].include?( p ) }.
^ Style/HashExcept: Use `except(*opts[:payload_platforms])` instead.

Full file context (30 lines before offense):

      181:                 [signatures].flatten.compact.each do |signature|
      182:                     res = find_signature( signature, response, opts )
      183:                     SIGNATURE_CACHE[:match][k] ||= !!res
      184:                 end
      185: 
      186:             when Hash
      187:                 if opts[:platform] && signatures[opts[:platform]]
      188:                     [signatures[opts[:platform]]].flatten.compact.each do |p|
      189:                         [p].flatten.compact.each do |signature|
      190:                             res = find_signature( signature, response, opts )
      191:                             SIGNATURE_CACHE[:match][k] ||= !!res
      192:                         end
      193:                     end
      194: 
      195:                 else
      196:                     signatures.each do |platform, p|
      197:                         dopts = opts.dup
      198:                         dopts[:platform] = platform
      199: 
      200:                         [p].flatten.compact.each do |signature|
      201:                             res = find_signature( signature, response, dopts )
      202:                             SIGNATURE_CACHE[:match][k] ||= !!res
      203:                         end
      204:                     end
      205:                 end
      206: 
      207:                 return if !opts[:payload_platforms]
      208: 
      209:                 # Find out if there are any signatures without associated payloads
      210:                 # and match them against every payload's response.
>>>   211:                 signatures.select { |p, _|  !opts[:payload_platforms].include?( p ) }.
      212:                     each do |platform, p|
      213:                         dopts = opts.dup
      214:                         dopts[:platform] = platform
      215: 
      216:                         [p].flatten.compact.each do |signature|
      217:                             res = find_signature( signature, response, dopts )
      218:                             SIGNATURE_CACHE[:match][k] ||= !!res

FN #3: Arachni__arachni__c1710d6: lib/arachni/element/dom.rb:86

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

Full-file enclosing chain: method body (line 85: def marshal_dump) > class body (line 14: class DOM < Base)

Message: Use except(:@parent, :@page, :@browser, :@element) instead.

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

        super.reject{ |k, _| [:@parent, :@page, :@browser, :@element].include? k }
^ Style/HashExcept: Use `except(:@parent, :@page, :@browser, :@element)` instead.

Full file context (30 lines before offense):

       56: 
       57:     def page
       58:         return @page if @page
       59:         @page = parent.page if parent
       60:     end
       61: 
       62:     # Triggers the event on the subject {#element}.
       63:     #
       64:     # @return   [Array<Page::DOM::Transition>]
       65:     #
       66:     # @abstract
       67:     def trigger
       68:         fail NotImplementedError
       69:     end
       70: 
       71:     # Removes the associated {#page}, {#parent} and {#browser}
       72:     def prepare_for_report
       73:         super
       74: 
       75:         @page    = nil
       76:         @parent  = nil
       77:         @element = nil
       78:         @browser = nil
       79:     end
       80: 
       81:     def dup
       82:         super.tap { |new| new.parent = parent }
       83:     end
       84: 
       85:     def marshal_dump
>>>    86:         super.reject{ |k, _| [:@parent, :@page, :@browser, :@element].include? k }
       87:     end
       88: 
       89:     def initialization_options
       90:         options = {}
       91:         options[:url]    = @url.dup    if @url
       92:         options[:action] = @action.dup if @action
       93:         # options[:page]   = @page       if @page

FN #4: DataDog__datadog-ci-rb__cadff60: lib/datadog/ci/test_tracing/serializers/base.rb:32

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

Full-file enclosing chain: method body (line 27: def initialize(trace, span, options: {})) > class body (line 11: class Base) > module body (line 10: module Serializers) > module body (line 9: module TestTracing) > module body (line 8: module CI) > module body (line 7: module Datadog)

Message: Use except(*Ext::Test::TRANSIENT_TAGS) instead.

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

            @meta = @span.meta.reject { |key, _| Ext::Test::TRANSIENT_TAGS.include?(key) }
^ Style/HashExcept: Use `except(*Ext::Test::TRANSIENT_TAGS)` instead.

Full file context (30 lines before offense):

        2: 
        3: require "set"
        4: 
        5: require_relative "../../ext/test"
        6: 
        7: module Datadog
        8:   module CI
        9:     module TestTracing
       10:       module Serializers
       11:         class Base
       12:           MINIMUM_TIMESTAMP_NANO = 946684800000000000
       13:           MINIMUM_DURATION_NANO = 0
       14:           MAXIMUM_DURATION_NANO = 9223372036854775807
       15: 
       16:           CONTENT_FIELDS = [
       17:             "name", "resource", "service",
       18:             "error", "start", "duration",
       19:             "meta", "metrics",
       20:             "type" => "span_type"
       21:           ].freeze
       22: 
       23:           REQUIRED_FIELDS = %w[error name resource start duration].freeze
       24: 
       25:           attr_reader :trace, :span, :meta, :options
       26: 
       27:           def initialize(trace, span, options: {})
       28:             @trace = trace
       29:             @span = span
       30:             @options = options
       31: 
>>>    32:             @meta = @span.meta.reject { |key, _| Ext::Test::TRANSIENT_TAGS.include?(key) }
       33: 
       34:             @errors = {}
       35:             @validated = false
       36:           end
       37: 
       38:           def to_msgpack(packer = nil)
       39:             packer ||= MessagePack::Packer.new

FN #5: DataDog__dd-trace-rb__e3406de: lib/datadog/appsec/contrib/rails/gateway/request.rb:58

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

Full-file enclosing chain: method body (line 55: def route_params) > class body (line 11: class Request < Instrumentation::Gateway::Argument) > module body (line 9: module Gateway) > module body (line 8: module Rails) > module body (line 7: module Contrib) > module body (line 6: module AppSec) > module body (line 5: module Datadog)

Prism AST at offense line: CallNode > CallNode > CallNode > CallNode > ArgumentsNode > StringNode > BlockNode > BlockParametersNode > ParametersNode > RequiredParameterNode > RequiredParameterNode

Message: Use except(*excluded) instead.

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

              request.env['action_dispatch.request.path_parameters'].reject do |k, _v|
^ Style/HashExcept: Use `except(*excluded)` instead.

Full file context (30 lines before offense):

       28:               request.host
       29:             end
       30: 
       31:             def user_agent
       32:               request.user_agent
       33:             end
       34: 
       35:             def remote_addr
       36:               request.remote_addr
       37:             end
       38: 
       39:             def parsed_body
       40:               # force body parameter parsing, which is done lazily by Rails
       41:               request.parameters
       42: 
       43:               # usually Hash<String,String> but can be a more complex
       44:               # Hash<String,String||Array||Hash> when e.g coming from JSON or
       45:               # with Rails advanced param square bracket parsing
       46:               body = request.env['action_dispatch.request.request_parameters']
       47: 
       48:               return if body.nil?
       49: 
       50:               body.reject do |k, _v|
       51:                 request.env['action_dispatch.request.path_parameters'].key?(k)
       52:               end
       53:             end
       54: 
       55:             def route_params
       56:               excluded = [:controller, :action]
       57: 
>>>    58:               request.env['action_dispatch.request.path_parameters'].reject do |k, _v|
       59:                 excluded.include?(k)
       60:               end
       61:             end
       62:           end
       63:         end
       64:       end
       65:     end

FN #6: DataDog__dd-trace-rb__e3406de: lib/datadog/appsec/contrib/rails/request.rb:28

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

Full-file enclosing chain: method body (line 25: def self.route_params(request)) > module body (line 8: module Request) > module body (line 6: module Rails) > module body (line 5: module Contrib) > module body (line 4: module AppSec) > module body (line 3: module Datadog)

Prism AST at offense line: CallNode > CallNode > CallNode > LocalVariableReadNode > ArgumentsNode > StringNode > BlockNode > BlockParametersNode > ParametersNode > RequiredParameterNode > RequiredParameterNode

Message: Use except(*excluded) instead.

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

            request.env['action_dispatch.request.path_parameters'].reject do |k, _v|
^ Style/HashExcept: Use `except(*excluded)` instead.

Full file context (30 lines before offense):

        1: # frozen_string_literal: true
        2: 
        3: module Datadog
        4:   module AppSec
        5:     module Contrib
        6:       module Rails
        7:         # Normalized extration of data from ActionDispatch::Request
        8:         module Request
        9:           def self.parsed_body(request)
       10:             # force body parameter parsing, which is done lazily by Rails
       11:             request.parameters
       12: 
       13:             # usually Hash<String,String> but can be a more complex
       14:             # Hash<String,String||Array||Hash> when e.g coming from JSON or
       15:             # with Rails advanced param square bracket parsing
       16:             body = request.env['action_dispatch.request.request_parameters']
       17: 
       18:             return if body.nil?
       19: 
       20:             body.reject do |k, _v|
       21:               request.env['action_dispatch.request.path_parameters'].key?(k)
       22:             end
       23:           end
       24: 
       25:           def self.route_params(request)
       26:             excluded = [:controller, :action]
       27: 
>>>    28:             request.env['action_dispatch.request.path_parameters'].reject do |k, _v|
       29:               excluded.include?(k)
       30:             end
       31:           end
       32:         end
       33:       end
       34:     end
       35:   end

FN #7: DataDog__dd-trace-rb__e3406de: lib/datadog/kit/appsec/events.rb:39

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

Full-file enclosing chain: block (do..end) (line 32: set_trace_and_span_context('track_login_success', trace, span) do |active_trace, active_span|) > method body (line 31: def track_login_success(trace = nil, span = nil, user:, **others)) > class body (line 16: class << self) > module body (line 10: module Events) > module body (line 8: module AppSec) > module body (line 7: module Kit) > module body (line 6: module Datadog)

Message: Use except(*USER_LOGIN_KEYS) instead.

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

              others = others.reject { |key, _| USER_LOGIN_KEYS.include?(key) }
^ Style/HashExcept: Use `except(*USER_LOGIN_KEYS)` instead.

Full file context (30 lines before offense):

        9:       # Tracking events
       10:       module Events
       11:         LOGIN_SUCCESS_EVENT = 'users.login.success'
       12:         LOGIN_FAILURE_EVENT = 'users.login.failure'
       13:         SIGNUP_EVENT = 'users.signup'
       14:         USER_LOGIN_KEYS = ['usr.login', :"usr.login"].freeze
       15: 
       16:         class << self
       17:           # Attach login success event information to the trace
       18:           #
       19:           # This method is experimental and may change in the future.
       20:           #
       21:           # @param trace [TraceOperation] Trace to attach data to. Defaults to
       22:           #   active trace.
       23:           # @param span [SpanOperation] Span to attach data to. Defaults to
       24:           #   active span on trace. Note that this should be a service entry span.
       25:           #   When AppSec is enabled, the expected span and trace are automatically
       26:           #   used as defaults.
       27:           # @param user [Hash<Symbol, String>] User information to pass to
       28:           #   Datadog::Kit::Identity.set_user. Must contain at least :id as key.
       29:           # @param others [Hash<String || Symbol, String>] Additional free-form
       30:           #   event information to attach to the trace.
       31:           def track_login_success(trace = nil, span = nil, user:, **others)
       32:             set_trace_and_span_context('track_login_success', trace, span) do |active_trace, active_span|
       33:               user_options = user.dup
       34:               user_id = user_options.delete(:id)
       35:               user_login = user_options[:login] || others[:"usr.login"] || others['usr.login'] || user_id
       36: 
       37:               raise ArgumentError, 'missing required key: :user => { :id }' if user_id.nil?
       38: 
>>>    39:               others = others.reject { |key, _| USER_LOGIN_KEYS.include?(key) }
       40:               others[:"usr.login"] = user_login
       41:               track(LOGIN_SUCCESS_EVENT, active_trace, active_span, **others)
       42: 
       43:               user_options[:login] = user_login
       44:               Kit::Identity.set_user(active_trace, active_span, id: user_id, **user_options)
       45:             end
       46:           end

FN #8: DataDog__dd-trace-rb__e3406de: lib/datadog/kit/appsec/events.rb:94

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

Full-file enclosing chain: block (do..end) (line 87: set_trace_and_span_context('track_signup', trace, span) do |active_trace, active_span|) > method body (line 86: def track_signup(trace = nil, span = nil, user:, **others)) > class body (line 16: class << self) > module body (line 10: module Events) > module body (line 8: module AppSec) > module body (line 7: module Kit) > module body (line 6: module Datadog)

Message: Use except(*USER_LOGIN_KEYS) instead.

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

              others = others.reject { |key, _| USER_LOGIN_KEYS.include?(key) }
^ Style/HashExcept: Use `except(*USER_LOGIN_KEYS)` instead.

Full file context (30 lines before offense):

       64:               others[:"usr.login"] = user_id if user_id && !others.key?(:"usr.login") && !others.key?('usr.login')
       65:               track(LOGIN_FAILURE_EVENT, active_trace, active_span, **others)
       66: 
       67:               active_span.set_tag('appsec.events.users.login.failure.usr.id', user_id) if user_id
       68:               active_span.set_tag('appsec.events.users.login.failure.usr.exists', user_exists)
       69:             end
       70:           end
       71: 
       72:           # Attach signup event information to the trace
       73:           #
       74:           # This method is experimental and may change in the future.
       75:           #
       76:           # @param trace [TraceOperation] Trace to attach data to. Defaults to
       77:           #   active trace.
       78:           # @param span [SpanOperation] Span to attach data to. Defaults to
       79:           #   active span on trace. Note that this should be a service entry span.
       80:           #   When AppSec is enabled, the expected span and trace are automatically
       81:           #   used as defaults.
       82:           # @param user [Hash<Symbol, String>] User information to pass to
       83:           #   Datadog::Kit::Identity.set_user. Must contain at least :id as key.
       84:           # @param others [Hash<String || Symbol, String>] Additional free-form
       85:           #   event information to attach to the trace.
       86:           def track_signup(trace = nil, span = nil, user:, **others)
       87:             set_trace_and_span_context('track_signup', trace, span) do |active_trace, active_span|
       88:               user_options = user.dup
       89:               user_id = user_options.delete(:id)
       90:               user_login = user_options[:login] || others[:"usr.login"] || others['usr.login'] || user_id
       91: 
       92:               raise ArgumentError, 'missing required key: :user => { :id }' if user_id.nil?
       93: 
>>>    94:               others = others.reject { |key, _| USER_LOGIN_KEYS.include?(key) }
       95:               others[:"usr.login"] = user_login
       96:               track(SIGNUP_EVENT, active_trace, active_span, **others)
       97: 
       98:               user_options[:login] = user_login
       99:               Kit::Identity.set_user(trace, id: user_id, **user_options)
      100:             end
      101:           end

Omitted 7 additional diagnosed FN example(s) for brevity.

github-actions bot and others added 2 commits March 30, 2026 06:35
Add detection for the `include?` family of patterns that RuboCop flags
but nitrocop previously missed:

- `hash.reject { |k, _| COLLECTION.include?(k) }` → `except(*COLLECTION)`
- `hash.select { |k, _| !COLLECTION.include?(k) }` → `except(*COLLECTION)`

Handles array literal receivers (expanded args), constants, variables,
and method call receivers (splatted). Correctly skips Range receivers,
value-parameter usage, and inverted semantics (e.g. `reject` with
negated `include?`).

Corpus: 0 FP, 16 FN remaining (down from 189).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@6 6 bot force-pushed the fix/style-hash_except-23730408076 branch from af5577f to 20bc6b2 Compare March 30, 2026 06:37
@6 6 bot marked this pull request as ready for review March 30, 2026 06:37
@6 6 bot enabled auto-merge (squash) March 30, 2026 06:38
@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/HashExcept 0 21 0 2 0 -19
Style/HashExcept 0 22 0 2 0 -20
Style/HashExcept 0 17 0 0 0 -17
Style/HashExcept 0 20 0 3 0 -17
Style/HashExcept 0 25 0 3 0 -22
Style/HashExcept 0 39 0 1 0 -38
Style/HashExcept 0 20 0 4 0 -16
Style/HashExcept 0 25 0 1 0 -24

All shards passed — ready to merge.

@6
Copy link
Copy Markdown
Contributor Author

6 bot commented Mar 30, 2026

Auto-repair Started

  • Checks run: #23731371517
  • Route: easy
  • Backend: codex / hard
  • Model: gpt-5.4 (xhigh)
  • Reason: build-and-test: Clippy
  • Repair workflow: #23731498420
Task prompt (3683 tokens)

PR Repair Task: PR #898

Before making changes, read docs/agent-ci.md.

Repair the existing PR branch in place. Do not create a new branch or PR.
Preserve the intent of the current PR and make the smallest changes needed to fix the failing checks.
Do not repair this PR by reverting it back to origin/main, deleting the whole diff, or otherwise turning it into an empty/no-op PR.
If the only plausible fix is a full revert of the PR, stop and explain that clearly instead of performing the revert.
Do not edit unrelated files or do broad cleanup.

Context

  • PR: [bot] Fix Style/HashExcept #898 [bot] Fix Style/HashExcept
  • Branch: fix/style-hash_except-23730408076
  • Checks run: #1907 (Checks)
  • Route: easy
  • Selected backend: codex / hard
  • Failure summary: build-and-test: Clippy

Required Outcome

Fix the currently failing checks shown below.
Before finishing, run the targeted validations needed to make the workflow's final verification likely to pass.

Required Local Verification

The workflow will rerun these commands before pushing. Your changes should make them pass:

cargo clippy --profile ci -- -D warnings

Current PR Diff Stat

src/cop/style/hash_except.rs                       | 245 +++++++++++++++++----
 .../fixtures/cops/style/hash_except/no_offense.rb  |   5 +
 tests/fixtures/cops/style/hash_except/offense.rb   |  14 ++
 3 files changed, 220 insertions(+), 44 deletions(-)

Current PR Diff Excerpt

diff --git a/src/cop/style/hash_except.rs b/src/cop/style/hash_except.rs
index 580432741..ac1350f92 100644
--- a/src/cop/style/hash_except.rs
+++ b/src/cop/style/hash_except.rs
@@ -6,6 +6,22 @@ use crate::cop::{Cop, CopConfig};
 use crate::diagnostic::Diagnostic;
 use crate::parse::source::SourceFile;
 
+/// Detects `Hash#reject`, `Hash#select`, and `Hash#filter` calls that can be
+/// replaced with `Hash#except`.
+///
+/// Handles two families of patterns:
+///
+/// 1. **Comparison patterns** (`==` / `!=`):
+///    - `hash.reject { |k, _| k == :sym }` → `hash.except(:sym)`
+///    - `hash.select { |k, _| k != :sym }` → `hash.except(:sym)`
+///    Only flags string/symbol comparands (mirrors RuboCop safety gate).
+///
+/// 2. **`include?` patterns**:
+///    - `hash.reject { |k, _| COLLECTION.include?(k) }` → `hash.except(*COLLECTION)`
+///    - `hash.select { |k, _| !COLLECTION.include?(k) }` → `hash.except(*COLLECTION)`
+///    Works with array literals (`[:a, :b]`), constants, and variables.
+///    Array literal receivers produce `except(:a, :b)` (expanded);
+///    all others produce `except(*name)` (splatted).
 pub struct HashExcept;
 
 impl Cop for HashExcept {
@@ -91,6 +107,13 @@ impl Cop for HashExcept {
         };
         let key_name = key_param.name().as_slice();
 
+        // Get the value parameter name (needed for include? checks)
+        let value_param = match requireds[1].as_required_parameter_node() {
+            Some(p) => p,
+            None => return,
+        };
+        let value_name = value_param.name().as_slice();
+
         // Check the block body for a simple comparison pattern
         let body = match block_node.body() {
             Some(b) => b,
@@ -109,46 +132,84 @@ impl Cop for HashExcept {
 
         let expr = &body_nodes[0];
 
-        // Check for k == :sym pattern (reject) or k != :sym pattern (select/filter)
-        if let Some(cmp_call) = expr.as_call_node() {
-            let cmp_method = cmp_call.name().as_slice();
+        // Try to match the expression against known patterns
+        if let Some(outer_call) = expr.as_call_node() {
+            let outer_method = outer_call.name().as_slice();
 
-            // For reject: k == :sym -> except(:sym)
-            // For select/filter: k != :sym -> except(:sym)
-            let is_matching = (method_bytes == b"reject" && cmp_method == b"==")
-                || ((method_bytes == b"select" || method_bytes == b"filter")
-                    && cmp_method == b"!=");
+            // Pattern: !SOMETHING.include?(key) — negation wrapper
+            if outer_method == b"!" {
+                if let Some(inner) = outer_call.receiver() {
+                    if let Some(inner_call) = inner.as_call_node() {
+                        if inner_call.name().as_slice() == b"include?" {
+                            // Negated include? is except-like for select/filter only
+                            if method_bytes == b"select" || method_bytes == b"filter" {
+                                self.check_include_pattern(
+                                    source,
+                                    &call,
+                                    &inner_call,
+                                    key_name,
+                                    value_name,
+                                    diagnostics,
+                                );
+                            }
+                        }
+                    }
+                }
+                return;
+            }
 
-            if !is_matching {
+            // Pattern: SOMETHING.include?(key) — for reject only
+            if outer_method == b"include?" {
+                if method_bytes == b"reject" {
+                    self.check_include_pattern(
+                        source,
+                        &call,
+                        &outer_call,
+                        key_name,
+                        value_name,
+                        diagnostics,
+                    );
+                }
                 return;
             }
 
-            let cmp_recv = match cmp_call.receiver() {
-                Some(r) => r,
-                None => return,
-            };
+            // Pattern: k == :sym / k != :sym (existing logic)
+            if outer_method == b"==" || outer_method == b"!=" {
+                // For reject: k == :sym -> except(:sym)
+                // For select/filter: k != :sym -> except(:sym)
+                let is_matching = (method_bytes == b"reject" && outer_method == b"==")
+                    || ((method_bytes == b"select" || method_bytes == b"filter")
+                        && outer_method == b"!=");
 
-            let cmp_args = match cmp_call.arguments() {
-                Some(a) => a,
-                None => return,
-            };
+                if !is_matching {
+                    return;
+                }
 
-            let cmp_arg_list: Vec<_> = cmp_args.arguments().iter().collect();
-            if cmp_arg_list.len() != 1 {
-                return;
-            }
+                let cmp_recv = match outer_call.receiver() {
+                    Some(r) => r,
+                    None => return,
+                };
+
+                let cmp_args = match outer_call.arguments() {
+                    Some(a) => a,
+                    None => return,
+                };
 
-            // One side must be the key param, other must be a literal
-            let (is_key_left, value_node) =
-                if let Some(lvar) = cmp_recv.as_local_variable_read_node() {
+                let cmp_arg_list: Vec<_> = cmp_args.arguments().iter().collect();
+                if cmp_arg_list.len() != 1 {
+                    return;
+                }
+
+                // One side must be the key param, other must be a literal
+                let value_node = if let Some(lvar) = cmp_recv.as_local_variable_read_node() {
                     if lvar.name().as_slice() == key_name {
-                        (true, &cmp_arg_list[0])
+                        &cmp_arg_list[0]
                     } else {
                         return;
                     }
                 } else if let Some(lvar) = cmp_arg_list[0].as_local_variable_read_node() {
                     if lvar.name().as_slice() == key_name {
-                        (false, &cmp_recv)
+                        &cmp_recv
                     } else {
                         return;
                     }
@@ -156,29 +217,125 @@ impl Cop for HashExcept {
                     return;
                 };
 
-            let _ = is_key_left;
+                // Value must be a symbol or string literal
+                let is_sym_or_str =
+                    value_node.as_symbol_node().is_some() || value_node.as_string_node().is_some();
+
+                if !is_sym_or_str {
+                    return;
+                }
 
-            // Value must be a symbol or string literal
-            let is_sym_or_str =
-                value_node.as_symbol_node().is_some() || value_node.as_string_node().is_some();
+                let value_src = &source.as_bytes()
+                    [value_node.location().start_offset()..value_node.location().end_offset()];
+                let value_str = String::from_utf8_lossy(value_src);
 
-            if !is_sym_or_str {
-                return;
+                let loc = call.message_loc().unwrap_or_else(|| call.location());
+                let (line, column) = source.offset_to_line_col(loc.start_offset());
+                diagnostics.push(self.diagnostic(
+                    source,
+                    line,
+                    column,
+                    format!("Use `except({})` instead.", value_str),
+                ));
             }
+        }
+    }
+}
 
-            let value_src = &source.as_bytes()
-                [value_node.location().start_offset()..value_node.location().end_offset()];
-            let value_str = String::from_utf8_lossy(value_src);
-
-            let loc = call.message_loc().unwrap_or_else(|| call.location());
-            let (line, column) = source.offset_to_line_col(loc.start_offset());
-            diagnostics.push(self.diagnostic(
-                source,
-                line,
-                column,
-                format!("Use `except({})` instead.", value_str),
-            ));
+impl HashExcept {
+    /// Check and emit an offense for the `include?` pattern.
+    ///
+    /// `include_call` is the `SOMETHING.include?(key)` CallNode.
+    /// `outer_call` is the top-level `reject`/`select`/`filter` CallNode.
+    fn check_include_pattern(
+        &self,
+        source: &SourceFile,
+        outer_call: &ruby_prism::CallNode<'_>,
+        include_call: &ruby_prism::CallNode<'_>,
+        key_name: &[u8],
+        value_name: &[u8],
+        diagnostics: &mut Vec<Diagnostic>,
+    ) {
+        // include? must have exactly one argument
+        let args = match include_call.arguments() {
+            Some(a) => a,
+            None => return,
+        };
+        let arg_list: Vec<_> = args.arguments().iter().collect();
+        if arg_list.len() != 1 {
... (111 more lines truncated)

Failed Checks Packet

build-and-test

  • Route: easy
  • Failed steps: Clippy
build-and-test	Clippy	2026-03-30T06:38:52.9893879Z ##[group]Run cargo clippy --profile ci -- -D warnings
cargo clippy --profile ci -- -D warnings
shell: /usr/bin/bash -e {0}
env:
CARGO_HOME: /home/runner/.cargo
CARGO_INCREMENTAL: 0
CARGO_TERM_COLOR: always
CACHE_ON_FAILURE: false
UV_CACHE_DIR: /home/runner/work/_temp/setup-uv-cache
##[endgroup]
Updating crates.io index
Checking nitrocop v0.0.1-pre5 (/home/runner/work/nitrocop/nitrocop)
error: doc list item without indentation
--> src/cop/style/hash_except.rs:17:5
|
17 | ///    Only flags string/symbol comparands (mirrors RuboCop safety gate).
|     ^^^
|
= help: if this is supposed to be its own paragraph, add a blank line
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#doc_lazy_continuation
= note: `-D clippy::doc-lazy-continuation` implied by `-D warnings`
= help: to override `-D warnings` add `#[allow(clippy::doc_lazy_continuation)]`
help: indent this line
|
17 | ///      Only flags string/symbol comparands (mirrors RuboCop safety gate).
|        ++
error: doc list item without indentation
--> src/cop/style/hash_except.rs:22:5
|
22 | ///    Works with array literals (`[:a, :b]`), constants, and variables.
|     ^^^
|
= help: if this is supposed to be its own paragraph, add a blank line
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#doc_lazy_continuation
help: indent this line
|
22 | ///      Works with array literals (`[:a, :b]`), constants, and variables.
|        ++
error: doc list item without indentation
--> src/cop/style/hash_except.rs:23:5
|
23 | ///    Array literal receivers produce `except(:a, :b)` (expanded);
|     ^^^
|
= help: if this is supposed to be its own paragraph, add a blank line
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#doc_lazy_continuation
help: indent this line
|
23 | ///      Array literal receivers produce `except(:a, :b)` (expanded);
|        ++
error: doc list item without indentation
--> src/cop/style/hash_except.rs:24:5
|
24 | ///    all others produce `except(*name)` (splatted).
|     ^^^
|
= help: if this is supposed to be its own paragraph, add a blank line
= help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.94.0/index.html#doc_lazy_continuation
help: indent this line
|
24 | ///      all others produce `except(*name)` (splatted).
|        ++
error: could not compile `nitrocop` (lib) due to 4 previous errors
##[error]Process completed with exit code 101.

Constraints

  • Keep the fix scoped to the failing checks.
  • Reuse the existing PR branch and existing tests where possible.
  • Prefer the minimal patch that makes the deterministic verification pass.
  • A full revert to origin/main or an empty PR is treated as a failed repair, not a success.
  • If a fix is blocked by missing context, explain that clearly in the final message.

Available Local Helper Scripts

These helper scripts are available in this CI checkout. Prefer the stable top-level CLI paths shown below over ad hoc commands when they directly help with diagnosis or validation.

  • scripts/check_cop.py — aggregate corpus regression check for one cop
  • scripts/dispatch_cops.py — dispatch helpers for changed cops, task generation, ranking, and prior attempts
  • scripts/investigate_cop.py — inspect FP/FN examples from corpus oracle data
  • scripts/verify_cop_locations.py — verify exact known oracle FP/FN locations
  • scripts/corpus_smoke_test.py — smoke-test a few pinned repos

Typical usage when present:

python3 scripts/check_cop.py Department/CopName --verbose --rerun --clone
python3 scripts/dispatch_cops.py changed --base origin/main --head HEAD
python3 scripts/investigate_cop.py Department/CopName --context
python3 scripts/verify_cop_locations.py Department/CopName
python3 scripts/corpus_smoke_test.py --binary target/release/nitrocop

6 bot added a commit that referenced this pull request Mar 30, 2026
@6
Copy link
Copy Markdown
Contributor Author

6 bot commented Mar 30, 2026

Auto-repair Succeeded

  • Checks run: #23731371517
  • Backend: codex / hard
  • Model: gpt-5.4 (xhigh)
  • Repair workflow: #23731498420
  • Repair commit: cc34c4d94b924036b46447ff2303f2471caadbce

Auto-repair succeeded with backend codex / hard.

Validated locally before push using /home/runner/work/_temp/agent-pr-repair/repair/verify.sh.

@6 6 bot force-pushed the fix/style-hash_except-23730408076 branch from 2a25706 to cc34c4d Compare March 30, 2026 06:44
@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/HashExcept 0 21 0 2 0 -19
Style/HashExcept 0 22 0 2 0 -20
Style/HashExcept 0 17 0 0 0 -17
Style/HashExcept 0 20 0 3 0 -17
Style/HashExcept 0 25 0 3 0 -22
Style/HashExcept 0 39 0 1 0 -38
Style/HashExcept 0 20 0 4 0 -16
Style/HashExcept 0 25 0 1 0 -24

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