Skip to content

[bot] Fix Rails/NegateInclude#179

Closed
6[bot] wants to merge 1 commit intomainfrom
fix/rails-negate_include-23512801123
Closed

[bot] Fix Rails/NegateInclude#179
6[bot] wants to merge 1 commit intomainfrom
fix/rails-negate_include-23512801123

Conversation

@6
Copy link
Copy Markdown
Contributor

@6 6 bot commented Mar 24, 2026

Status: Agent is working on this fix...

Cop: Rails/NegateInclude | Backend: claude / hard | Model: Claude Opus 4.6 (OAuth, high) | Mode: fix
Code bugs: 5 | Run: https://github.com/6/nitrocop/actions/runs/23512801123

Closes #166

Task prompt (4567 tokens)

Fix Rails/NegateInclude — 0 FP, 4 FN

Instructions

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

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

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 Rails/NegateInclude /tmp/test.rb
    echo '<general pattern>' > /tmp/test.rb && rubocop --only Rails/NegateInclude /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/rails/negate_include/offense.rb with ^ annotation
    • FP fix: add the false-positive pattern to tests/fixtures/cops/rails/negate_include/no_offense.rb
  4. Verify test fails: cargo test --lib -- cop::rails::negate_include
  5. Fix src/cop/rails/negate_include.rs
  6. Verify test passes: cargo test --lib -- cop::rails::negate_include
  7. Add a /// doc comment on the cop struct documenting what you found and fixed
  8. Commit only your cop's files

Fixture Format

Mark offenses with ^ markers on the line AFTER the offending source line:

x = 1
     ^^ Rails/NegateInclude: Trailing whitespace detected.

The ^ characters must align with the offending columns. The message format is Rails/NegateInclude: <message text>.

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 Rails/NegateInclude /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/rails/negate_include.rs and tests/fixtures/cops/rails/negate_include/
  • Run cargo test --lib -- cop::rails::negate_include to verify your fix (do NOT run the full test suite)
  • Do NOT touch unrelated files
  • Do NOT use git stash

Start Here

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

Helpful local commands:

  • python3 scripts/investigate-cop.py Rails/NegateInclude --repos-only
  • python3 scripts/investigate-cop.py Rails/NegateInclude --context
  • python3 scripts/verify-cop-locations.py Rails/NegateInclude

Top FN repos:

  • cjstewart88__Tubalr__f6956c8 (2 FN) — example heroku/ruby/1.9.1/gems/rdoc-3.8/lib/rdoc/method_attr.rb:157
  • liaoziyang__stackneveroverflow__8f4dce2 (1 FN) — example vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/method_attr.rb:184
  • pitluga__supply_drop__d64c50c (1 FN) — example examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/settings.rb:800

Representative FN examples:

  • cjstewart88__Tubalr__f6956c8: heroku/ruby/1.9.1/gems/rdoc-3.8/lib/rdoc/method_attr.rb:157 — Use .exclude? and remove the negation part.
  • cjstewart88__Tubalr__f6956c8: heroku/ruby/1.9.1/gems/rdoc-3.9.4/lib/rdoc/method_attr.rb:157 — Use .exclude? and remove the negation part.
  • liaoziyang__stackneveroverflow__8f4dce2: vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/method_attr.rb:184 — Use .exclude? and remove the negation part.

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: 4 code bug(s), 0 config/context issue(s)

FN #1: cjstewart88__Tubalr__f6956c8: heroku/ruby/1.9.1/gems/rdoc-3.8/lib/rdoc/method_attr.rb:157

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

Enclosing structure: enclosing line: searched << kernel if kernel &&
The offense is inside this structure — the cop may need
to handle this context to detect the pattern.

Message: Use .exclude? and remove the negation part.

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

      parent != kernel && !searched.include?(kernel)
^ Rails/NegateInclude: Use `.exclude?` and remove the negation part.

Full source context:

  def find_method_or_attribute name # :nodoc:
    return nil unless parent.respond_to? :ancestors

    searched = parent.ancestors
    kernel = RDoc::TopLevel.all_modules_hash['Kernel']

    searched << kernel if kernel &&
      parent != kernel && !searched.include?(kernel)

    searched.each do |ancestor|
      next if parent == ancestor
      next if String === ancestor

      other = ancestor.find_method_named('#' << name) ||
              ancestor.find_attribute_named(name)

FN #2: cjstewart88__Tubalr__f6956c8: heroku/ruby/1.9.1/gems/rdoc-3.9.4/lib/rdoc/method_attr.rb:157

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

Enclosing structure: enclosing line: searched << kernel if kernel &&
The offense is inside this structure — the cop may need
to handle this context to detect the pattern.

Message: Use .exclude? and remove the negation part.

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

      parent != kernel && !searched.include?(kernel)
^ Rails/NegateInclude: Use `.exclude?` and remove the negation part.

Full source context:

  def find_method_or_attribute name # :nodoc:
    return nil unless parent.respond_to? :ancestors

    searched = parent.ancestors
    kernel = RDoc::TopLevel.all_modules_hash['Kernel']

    searched << kernel if kernel &&
      parent != kernel && !searched.include?(kernel)

    searched.each do |ancestor|
      next if parent == ancestor
      next if String === ancestor

      other = ancestor.find_method_named('#' << name) ||
              ancestor.find_attribute_named(name)

FN #3: liaoziyang__stackneveroverflow__8f4dce2: vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/method_attr.rb:184

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

Enclosing structure: enclosing line: searched << kernel if kernel &&
The offense is inside this structure — the cop may need
to handle this context to detect the pattern.

Message: Use .exclude? and remove the negation part.

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

      parent != kernel && !searched.include?(kernel)
^ Rails/NegateInclude: Use `.exclude?` and remove the negation part.

Full source context:

  def find_method_or_attribute name # :nodoc:
    return nil unless parent.respond_to? :ancestors

    searched = parent.ancestors
    kernel = @store.modules_hash['Kernel']

    searched << kernel if kernel &&
      parent != kernel && !searched.include?(kernel)

    searched.each do |ancestor|
      next if String === ancestor
      next if parent == ancestor

      other = ancestor.find_method_named('#' << name) ||
              ancestor.find_attribute_named(name)

FN #4: pitluga__supply_drop__d64c50c: examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/settings.rb:800

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

Message: Use .exclude? and remove the negation part.

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

      if group = setting.group and ! %w{root wheel}.include?(group) and catalog.resource(:group, group).nil?
^ Rails/NegateInclude: Use `.exclude?` and remove the negation part.

Full source context:

      next unless sections.nil? or sections.include?(setting.section)

      if user = setting.owner and user != "root" and catalog.resource(:user, user).nil?
        resource = Puppet::Resource.new(:user, user, :parameters => {:ensure => :present})
        resource[:gid] = self[:group] if self[:group]
        catalog.add_resource resource
      end
      if group = setting.group and ! %w{root wheel}.include?(group) and catalog.resource(:group, group).nil?
        catalog.add_resource Puppet::Resource.new(:group, group, :parameters => {:ensure => :present})
      end
    end
  end

  # Yield each search source in turn.
  def each_source(environment)

Current Rust Implementation

src/cop/rails/negate_include.rs

use crate::cop::node_type::CALL_NODE;
use crate::cop::{Cop, CopConfig};
use crate::diagnostic::{Diagnostic, Severity};
use crate::parse::source::SourceFile;

/// ## Corpus investigation (2026-03-07)
///
/// FP=26, FN=1. FPs from safe navigation (`!arr&.include?(x)`) and multi-arg
/// `include?` calls. RuboCop's pattern `(send (send $!nil? :include? $_) :!)`
/// uses `send` (not `csend`) and `$_` (exactly one arg).
/// Fixed by checking for safe navigation and argument count.
///
/// ## Corpus investigation (2026-03-16)
///
/// FP=0, FN=1. The remaining FN is in `rubocop__rubocop__b210a6e` at
/// `lib/rubocop/cop/lint/cop_directive_syntax.rb:74` —
/// `elsif !DirectiveComment::AVAILABLE_MODES.include?(mode)`. Verified that
/// the cop logic correctly detects `!` calls with constant path receivers in
/// both `if` and `elsif` conditions (test fixtures added). The FN is a
/// corpus config artifact — likely the rubocop repo's config resolution
/// differs from the baseline, causing this cop to not run on that file.
///
/// ## Corpus investigation (2026-03-19)
///
/// FP=3, FN=0. All 3 FPs are `![TkFOR, TkWHILE, TkUNTIL].include?(...)`
/// in vendored gem files:
///   - `heroku/ruby/1.9.1/gems/rdoc-*/lib/rdoc/ruby_lex.rb` (cjstewart88__Tubalr, 2 FPs)
///   - `vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/ruby_lex.rb` (liaoziyang__stackneveroverflow, 1 FP)
///
/// Root cause: file-exclusion path resolution, NOT cop logic. RuboCop
/// correctly flags `![...].include?(x)` too (verified locally). The corpus
/// oracle runs nitrocop on `repos/REPO_ID/`, producing paths like
/// `repos/REPO_ID/vendor/bundle/...` which don't match the `vendor/**/*`
/// AllCops.Exclude glob because the repo prefix prevents matching. RuboCop
/// uses `--force-exclusion` which handles this correctly. The `heroku/`
/// paths aren't under `vendor/` at all and are likely excluded by RuboCop's
/// file discovery or `.gitignore` handling. No cop-level fix needed.
pub struct NegateInclude;

impl Cop for NegateInclude {
    fn name(&self) -> &'static str {
        "Rails/NegateInclude"
    }

    fn default_severity(&self) -> Severity {
        Severity::Convention
    }

    fn interested_node_types(&self) -> &'static [u8] {
        &[CALL_NODE]
    }

    fn check_node(
        &self,
        source: &SourceFile,
        node: &ruby_prism::Node<'_>,
        _parse_result: &ruby_prism::ParseResult<'_>,
        _config: &CopConfig,
        diagnostics: &mut Vec<Diagnostic>,
        _corrections: Option<&mut Vec<crate::correction::Correction>>,
    ) {
        let call = match node.as_call_node() {
            Some(c) => c,
            None => return,
        };

        if call.name().as_slice() != b"!" {
            return;
        }

        let receiver = match call.receiver() {
            Some(r) => r,
            None => return,
        };

        let inner_call = match receiver.as_call_node() {
            Some(c) => c,
            None => return,
        };

        if inner_call.name().as_slice() != b"include?" {
            return;
        }

        // RuboCop uses `send` not `csend` — skip safe navigation (&.include?)
        if let Some(op) = inner_call.call_operator_loc() {
            if op.as_slice() == b"&." {
                return;
            }
        }

        // RuboCop: receiver must exist ($!nil?)
        if inner_call.receiver().is_none() {
            return;
        }

        // RuboCop: exactly one argument ($_)
        let arg_count = inner_call
            .arguments()
            .map(|a| a.arguments().len())
            .unwrap_or(0);
        if arg_count != 1 {
            return;
        }

        let loc = node.location();
        let (line, column) = source.offset_to_line_col(loc.start_offset());
        diagnostics.push(self.diagnostic(
            source,
            line,
            column,
            "Use `exclude?` instead of `!include?`.".to_string(),
        ));
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    crate::cop_fixture_tests!(NegateInclude, "cops/rails/negate_include");
}

RuboCop Ruby Implementation (ground truth)

vendor/rubocop-rails/lib/rubocop/cop/rails/negate_include.rb

# frozen_string_literal: true

module RuboCop
  module Cop
    module Rails
      # Enforces the use of `collection.exclude?(obj)`
      # over `!collection.include?(obj)`.
      #
      # @safety
      #   This cop is unsafe because false positive will occur for
      #   receiver objects that do not have an `exclude?` method. (e.g. `IPAddr`)
      #
      # @example
      #   # bad
      #   !array.include?(2)
      #   !hash.include?(:key)
      #
      #   # good
      #   array.exclude?(2)
      #   hash.exclude?(:key)
      #
      class NegateInclude < Base
        extend AutoCorrector

        MSG = 'Use `.exclude?` and remove the negation part.'
        RESTRICT_ON_SEND = %i[!].freeze

        def_node_matcher :negate_include_call?, <<~PATTERN
          (send (send $!nil? :include? $_) :!)
        PATTERN

        def on_send(node)
          return unless (receiver, obj = negate_include_call?(node))

          add_offense(node) do |corrector|
            corrector.replace(node, "#{receiver.source}.exclude?(#{obj.source})")
          end
        end
      end
    end
  end
end

RuboCop Test Excerpts

vendor/rubocop-rails/spec/rubocop/cop/rails/negate_include_spec.rb

  it 'registers an offense and corrects when using `!include?`' do

    expect_offense(<<~RUBY)
      !array.include?(2)
      ^^^^^^^^^^^^^^^^^^ Use `.exclude?` and remove the negation part.
    RUBY

  it 'does not register an offense when using `!include?` without receiver' do

    expect_no_offenses(<<~RUBY)
      !include?(2)
    RUBY

  it 'does not register an offense when using `include?` or `exclude?`' do

    expect_no_offenses(<<~RUBY)
      array.include?(2)
      array.exclude?(2)
    RUBY

Current Fixture: offense.rb

tests/fixtures/cops/rails/negate_include/offense.rb

!items.include?(x)
^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.

!users.include?(current_user)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.

!%w[admin mod].include?(role)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.

# constant path receiver
!Config::MODES.include?(mode)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.

# inside if condition
if !Config::MODES.include?(mode)
   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.
  handle_invalid
end

# inside elsif condition
if x.nil?
  handle_nil
elsif !Config::MODES.include?(mode)
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Rails/NegateInclude: Use `exclude?` instead of `!include?`.
  handle_invalid
end

Current Fixture: no_offense.rb

tests/fixtures/cops/rails/negate_include/no_offense.rb

items.include?(x)
items.exclude?(x)
!items.empty?
items.any? { |i| i > 0 }
items.none? { |i| i.nil? }

# safe navigation — not flagged
!arr&.include?(x)

# multi-arg — not flagged
!arr.include?(x, y)

# no receiver — not flagged
!include?(x)

@6
Copy link
Copy Markdown
Contributor Author

6 bot commented Mar 24, 2026

Agent failed. See run: https://github.com/6/nitrocop/actions/runs/23512801123

@6 6 bot closed this Mar 24, 2026
@6 6 bot deleted the fix/rails-negate_include-23512801123 branch March 24, 2026 21:26
@6 6 restored the fix/rails-negate_include-23512801123 branch March 24, 2026 21:28
@6 6 reopened this Mar 24, 2026
@6 6 closed this Mar 24, 2026
@6 6 deleted the fix/rails-negate_include-23512801123 branch March 25, 2026 02:07
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.

[cop] Rails/NegateInclude

1 participant