Skip to content

[bot] Fix Lint/UnusedMethodArgument#152

Closed
6[bot] wants to merge 2 commits intomainfrom
fix/lint-unused_method_argument-23474452426
Closed

[bot] Fix Lint/UnusedMethodArgument#152
6[bot] wants to merge 2 commits intomainfrom
fix/lint-unused_method_argument-23474452426

Conversation

@6
Copy link
Copy Markdown
Contributor

@6 6 bot commented Mar 24, 2026

Automated fix fix for Lint/UnusedMethodArgument corpus conformance.

Details

Cop Lint/UnusedMethodArgument
Backend codex / normal
Model gpt-5.3-codex (high)
Mode fix
Turns 1
Run #73

Result

Implemented and committed a narrow FP fix for `Lint/UnusedMethodArgument`.

### What changed
- Added corpus-backed `no_offense` fixture cases for parser-incompatible multibyte regexp escapes in:
  - [no_offense.rb](/home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/lint/unused_method_argument/no_offense.rb#L144)
- Updated the cop to bail out when the file contains parser-gem-incompatible multibyte regexp escape ranges (`\200-\377`, `\x80-\xFF`) in:
  - [unused_method_argument.rs](/home/runner/work/nitrocop/nitrocop/src/cop/lint/unused_method_argument.rs#L206)
  - helper logic added at [unused_method_argument.rs](/home/runner/work/nitrocop/nitrocop/src/cop/lint/unused_method_argument.rs#L281)
- Added the required `///` investigation note on the cop struct at:
  - [unused_method_argument.rs](/home/runner/work/nitrocop/nitrocop/src/cop/lint/unused_method_argument.rs#L48)

### RuboCop verification (before fix)
- Specific parser-incompatible case: RuboCop reports `Lint/Syntax` only (no `UnusedMethodArgument`).
- General pattern (`def ... debug = false` without parser-incompatible escape): RuboCop flags `Lint/UnusedMethodArgument`.

### Validation run
- `cargo test --lib -- cop::lint::unused_method_argument` passed.
- `cargo run --quiet -- --force-default-config --format json --only Lint/UnusedMethodArgument /tmp/uma_specific_parser_incompat.rb` now reports 0 offenses.
- `cargo run --quiet -- --force-default-config --format json --only Lint/UnusedMethodArgument /tmp/uma_general_unused.rb` still reports the expected offense.

### Commit
- `61bd1b6e` — `Fix UnusedMethodArgument FP on parser-incompatible regex escapes`

Changes

  2 files changed
Agent conversation excerpt

Reading docs/agent-ci.md first as requested, then I’ll verify RuboCop behavior on the two FP patterns before touching fixtures or cop logic.

Reading docs/agent-ci.md first as requested, then I’ll verify RuboCop behavior on the two FP patterns before touching fixtures or cop logic.

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

I’ve finished the CI rules read. Next I’m running RuboCop directly on the reported FP shape and a broader control case to pin down the exact context RuboCop exempts.

I’ve finished the CI rules read. Next I’m running RuboCop directly on the reported FP shape and a broader control case to pin down the exact context RuboCop exempts.

exec_command: cat > /tmp/test_unused_method_argument_fp_specific.rb <<'RUBY' if respond_to? :yaml_new and yaml_new(self, :tag, "message" => "blurp").message != "blurp" def self.yaml_new( klass, tag, val ) o =

RuboCop flags the extracted yaml_new(..., tag, ...) snippet as an offense in my local run, so I’m now checking full corpus context with the helper script to find what suppresses RuboCop there (likely config/disable context).

RuboCop flags the extracted yaml_new(..., tag, ...) snippet as an offense in my local run, so I’m now checking full corpus context with the helper script to find what suppresses RuboCop there (likely config/disable context).

exec_command: python3 scripts/investigate-cop.py Lint/UnusedMethodArgument --context

The default investigation output only covers the standard corpus (which is already perfect), so I’m checking the script options to pull the extended divergence context you cited.

The default investigation output only covers the standard corpus (which is already perfect), so I’m checking the script options to pull the extended divergence context you cited.

exec_command: python3 scripts/investigate-cop.py --help

exec_command: python3 scripts/investigate-cop.py Lint/UnusedMethodArgument --extended --context

I confirmed extended divergence data, and both FP paths are under vendored directories. I’m now verifying RuboCop on the setup_parser(..., debug=false) shape as a second control check.

I confirmed extended divergence data, and both FP paths are under vendored directories. I’m now verifying RuboCop on the setup_parser(..., debug=false) shape as a second control check.

exec_command: cat > /tmp/test_unused_method_argument_fp1_specific.rb <<'RUBY' def setup_parser(str, debug=false) set_string str, 0 @memoizations = Hash.new { |h,k| h[k] = {} } @result = nil @failed_rule = n

exec_command: python3 scripts/verify-cop-locations.py --help

Both extracted snippets are offenses in isolation under RuboCop, which strongly suggests these two corpus mismatches are contextual (likely parse/config/disable behavior in full files). I’m checking repo-level investigation output next to identify that context before deciding whether a cop logic change is valid.

Both extracted snippets are offenses in isolation under RuboCop, which strongly suggests these two corpus mismatches are contextual (likely parse/config/disable behavior in full files). I’m checking repo-level investigation output next to identify that context before deciding whether a cop logic change is valid.

exec_command: python3 scripts/investigate-repo.py liaoziyang__stackneveroverflow__8f4dce2 --extended

exec_command: python3 scripts/investigate-repo.py pitluga__supply_drop__d64c50c --extended

exec_command: find bench -type f \( -name 'zaml.rb' -o -name 'literals_1_8.rb' \) 2>/dev/null

I’m digging into whether the mismatch is truly cop logic or corpus context noise; next I’ll sanity-check RuboCop on plain singleton-method arguments to avoid introducing a broad suppression that RuboCop still flags.

I’m digging into whether the mismatch is truly cop logic or corpus context noise; next I’ll sanity-check RuboCop on plain singleton-method arguments to avoid introducing a broad suppression that RuboCop still flags.

exec_command: cat > /tmp/test_unused_method_argument_general_singleton.rb <<'RUBY' def self.foo(a) 1 end RUBY rubocop --only Lint/UnusedMethodArgument /tmp/test_unused_method_argument_general_singleton.rb

I’ve confirmed RuboCop still flags the broader singleton-method pattern, so a suppression in cop logic would be too broad. I’m validating whether path-level exclusion (vendor/**) is the differentiator for these two corpus paths.

I’ve confirmed RuboCop still flags the broader singleton-method pattern, so a suppression in cop logic would be too broad. I’m validating whether path-level exclusion (vendor/**) is the differentiator for these two corpus paths.

exec_command: tmpdir=$(mktemp -d) mkdir -p "$tmpdir/vendor/example" cat > "$tmpdir/vendor/example/test.rb" <<'RUBY' def setup_parser(str, debug=false) set_string str, 0 end RUBY cd "$tmpdir" && rubocop --only Lin

The FP shape aligns with RuboCop’s default vendor/** exclusion behavior rather than a cop semantic gap. I’m pulling the exact file content at the reported commits to confirm whether these locations are under exclusion/disable context in the real repos.

The FP shape aligns with RuboCop’s default vendor/** exclusion behavior rather than a cop semantic gap. I’m pulling the exact file content at the reported commits to confirm whether these locations are under exclusion/disable context in the real repos.

exec_command: set -e rm -rf /tmp/nitrocop_fp_repos mkdir -p /tmp/nitrocop_fp_repos cd /tmp/nitrocop_fp_repos (git clone --depth 1 https://github.com/liaoziyang/stackneveroverflow.git >/tmp/clone1.log 2>&1 && echo c

Both repos cloned successfully. I’m checking out the exact commits from the corpus IDs and inspecting the target files for any RuboCop exclusion/disable context.

Both repos cloned successfully. I’m checking out the exact commits from the corpus IDs and inspecting the target files for any RuboCop exclusion/disable context.

exec_command: set -e cd /tmp/nitrocop_fp_repos/stackneveroverflow git checkout 8f4dce2 >/tmp/checkout1.log 2>&1 || (cat /tmp/checkout1.log && false) cd /tmp/nitrocop_fp_repos/supply_drop git checkout d64c50c >/tmp/

exec_command: sed -n '1,120p' /tmp/nitrocop_fp_repos/stackneveroverflow/vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/markdown/literals_1_8.rb

exec_command: sed -n '150,260p' /tmp/nitrocop_fp_repos/supply_drop/examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/zaml.rb

exec_command: rg -n "UnusedMethodArgument|rubocop:disable|AllCops|Exclude|vendor" /tmp/nitrocop_fp_repos/stackneveroverflow/.rubocop.yml /tmp/nitrocop_fp_repos/supply_drop/.rubocop.yml 2>/dev/null || true

exec_command: rg -n "rubocop:disable|rubocop:todo|UnusedMethodArgument" /tmp/nitrocop_fp_repos/stackneveroverflow/vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/markdown/literals_1_8.rb /tmp/nitrocop_fp_repos/su

I inspected both files directly: no inline rubocop:disable around the methods. Next I’m reproducing RuboCop behavior on those exact repo files using both explicit-file mode and project-scan mode to confirm exclusion semantics.

I inspected both files directly: no inline rubocop:disable around the methods. Next I’m reproducing RuboCop behavior on those exact repo files using both explicit-file mode and project-scan mode to confirm exclusion semantics.

exec_command: cat > /tmp/rubocop_newcops_disable.yml <<'YML' AllCops: NewCops: disable YML set -e cd /tmp/nitrocop_fp_repos/stackneveroverflow echo '--- stack explicit file ---' rubocop --config /tmp/rubocop_newc

I found the likely differentiator: both FP files contain Ruby syntax that RuboCop treats as parse errors, so RuboCop doesn’t emit UnusedMethodArgument there while nitrocop still does. I’m now adding a fixture that captures “syntax-error file should not emit this cop” and then guarding the cop accordingly.

I found the likely differentiator: both FP files contain Ruby syntax that RuboCop treats as parse errors, so RuboCop doesn’t emit UnusedMethodArgument there while nitrocop still does. I’m now adding a fixture that captures “syntax-error file should not emit this cop” and then guarding the cop accordingly.

exec_command: rg -n "macro_rules! cop_fixture_tests|cop_fixture_tests!" src tests -g '*.rs'

exec_command: rg -n "run_cop_fixture|fixture" src/testutil* src -g '*.rs' | head -n 80

exec_command: sed -n '60,240p' src/testutil.rs

exec_command: sed -n '217,440p' src/testutil.rs

exec_command: rg -n "parse_result\..*(error|errors|warning|diagnostic)|has_error|has_errors|error_count" src -g '*.rs'

exec_command: rg -n "ParseResult" src/parse src -g '*.rs' | head -n 120

exec_command: sed -n '560,700p' src/linter.rs

exec_command: rg -n "fn lint_source_once|parse_result.errors\(\)|Lint/Syntax|syntax" src/linter.rs

exec_command: sed -n '700,860p' src/linter.rs

exec_command: sed -n '1,260p' src/cop/lint/syntax.rs

exec_command: sed -n '1,180p' src/cop/layout/leading_comment_space.rs

I’m now validating whether Prism exposes these parser-incompatibility cases as errors or warnings; that determines whether a safe guard can be implemented in this cop without broad suppression.

I’m now validating whether Prism exposes these parser-incompatibility cases as errors or warnings; that determines whether a safe guard can be implemented in this cop without broad suppression.

exec_command: `cat > /tmp/nitrocop_unused_arg_parser_gap.rb <<'RUBY'
def setup_parser(str, debug=false)
set_string str, 0
end

_tmp = scan(/\A(?-mix:[0-9A-Za-z\200-\377])/)
RUBY
cargo run --quiet -- --format json `

I kicked off a direct nitrocop run on a minimal reproducer containing the old regex escape pattern; once it returns, I’ll know if this cop currently fires despite parser incompatibility.

I kicked off a direct nitrocop run on a minimal reproducer containing the old regex escape pattern; once it returns, I’ll know if this cop currently fires despite parser incompatibility.

write_stdin: ``

Task prompt (10420 tokens)

Fix Lint/UnusedMethodArgument — 2 FP, 0 FN

Cross-Corpus Risk

  • Standard corpus: 11,608 matches, 0 FP, 0 FN
  • Extended corpus: 29,967 matches, 2 FP, 0 FN
  • Extended divergence currently touches 2 repos

Risk class: extended-only edge case against a standard-perfect baseline.

Treat the extended examples as a narrow edge case, not proof that the broad pattern is safe.
Any carve-out that reduces the extended FP/FN count but regresses the standard corpus is a bad fix.
Prefer the smallest context-specific change you can justify from RuboCop behavior and the vendor spec.
This is concentrated in only a few repos, which increases the risk of overfitting to one local shape.

Instructions

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

Current state: 29,967 matches, 2 false positives, 0 false negatives.
Focus on: FP (nitrocop flags code RuboCop does not).

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 Lint/UnusedMethodArgument /tmp/test.rb
    echo '<general pattern>' > /tmp/test.rb && rubocop --only Lint/UnusedMethodArgument /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/lint/unused_method_argument/offense.rb with ^ annotation
    • FP fix: add the false-positive pattern to tests/fixtures/cops/lint/unused_method_argument/no_offense.rb
  4. Verify test fails: cargo test --lib -- cop::lint::unused_method_argument
  5. Fix src/cop/lint/unused_method_argument.rs
  6. Verify test passes: cargo test --lib -- cop::lint::unused_method_argument
  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
     ^^ Lint/UnusedMethodArgument: Trailing whitespace detected.

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

Mixed issues: some code bugs, some config issues

Pre-diagnostic shows SOME patterns are correctly detected in isolation (config issues)
and SOME are genuinely missed (code bugs). See the per-example diagnosis below.

  • For examples marked CODE BUG: follow the standard TDD workflow
  • For examples marked CONFIG/CONTEXT: investigate config resolution, not detection logic

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

Prism Notes

  • const splits into ConstantReadNode (simple Foo) and ConstantPathNode (qualified Foo::Bar). If you handle one, check if you need the other.

Start Here

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

Helpful local commands:

  • python3 scripts/investigate-cop.py Lint/UnusedMethodArgument --repos-only
  • python3 scripts/investigate-cop.py Lint/UnusedMethodArgument --context
  • python3 scripts/verify-cop-locations.py Lint/UnusedMethodArgument

Top FP repos:

  • liaoziyang__stackneveroverflow__8f4dce2 (1 FP) — example vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/markdown/literals_1_8.rb:21
  • pitluga__supply_drop__d64c50c (1 FP) — example examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/zaml.rb:208

Representative FP examples:

  • liaoziyang__stackneveroverflow__8f4dce2: vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/markdown/literals_1_8.rb:21 — Unused method argument - debug.
  • pitluga__supply_drop__d64c50c: examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/zaml.rb:208 — Unused method argument - tag.

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.

  • FP: 1 confirmed code bug(s), 1 context-dependent

FP #1: liaoziyang__stackneveroverflow__8f4dce2: vendor/bundle/ruby/2.3.0/gems/rdoc-4.3.0/lib/rdoc/markdown/literals_1_8.rb:21

NOT REPRODUCED in isolation — CONTEXT-DEPENDENT
nitrocop does not flag this in isolation. The FP is triggered
by surrounding code context or file-level state.
Investigate what full-file context causes the false detection.

Source context:

      setup_parser(str, debug)
    end



    # Prepares for parsing +str+.  If you define a custom initialize you must
    # call this method before #parse
    def setup_parser(str, debug=false)
      set_string str, 0
      @memoizations = Hash.new { |h,k| h[k] = {} }
      @result = nil
      @failed_rule = nil
      @failing_rule_offset = -1

      setup_foreign_grammar

Message: Unused method argument - debug.

FP #2: pitluga__supply_drop__d64c50c: examples/vendored-puppet/vendor/puppet-2.7.8/lib/puppet/util/zaml.rb:208

CONFIRMED false positive — CODE BUG
nitrocop incorrectly flags this pattern in isolation.
Fix the detection logic to not flag this.

Enclosing structure: if branch (line: if respond_to? :yaml_new and yaml_new(self, :tag, "message" => "blurp").message != "blurp")
The offense is inside this structure — this is likely WHY
RuboCop does not flag it. Your fix should detect this context.

Full source context (add relevant parts to no_offense.rb):

  # Monkey patch for buggy Exception restore in YAML
  #
  #     This makes it work for now but is not very future-proof; if things
  #     change we'll most likely want to remove this.  To mitigate the risks
  #     as much as possible, we test for the bug before appling the patch.
  #
  if respond_to? :yaml_new and yaml_new(self, :tag, "message" => "blurp").message != "blurp"
    def self.yaml_new( klass, tag, val )
      o = YAML.object_maker( klass, {} ).exception(val.delete( 'message'))
      val.each_pair do |k,v|
        o.instance_variable_set("@#{k}", v)
      end
      o
    end
  end

Message: Unused method argument - tag.

Current Rust Implementation

src/cop/lint/unused_method_argument.rs

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

/// Checks for unused method arguments.
///
/// ## Root causes of historical FP/FN (corpus 87.3% → 99.6% match rate):
/// - **FN: block params (`&block`)** were not collected — now handled via `params.block()`
/// - **FN: keyword rest (`**opts`)** were not collected — now handled via `params.keyword_rest()`
/// - **FN: post params** (after rest, e.g. `def foo(*a, b)`) were not collected — now handled via `params.posts()`
/// - **FN: `LocalVariableTargetNode` treated as use** — multi-assignment LHS (`a, b = 1, 2`)
///   incorrectly prevented flagging parameters that were only assigned to, never read.
///   Removed from VarReadFinder; only actual reads count.
/// - **FN: `NotImplementedExceptions` config ignored** — hardcoded `NotImplementedError` instead
///   of reading from config. Now uses the configured exception list.
/// - **FN: `LocalVariableOperatorWriteNode`/`AndWriteNode`/`OrWriteNode`** (`a += 1`, `a ||= x`)
///   implicitly read the variable but weren't detected. Now handled.
/// - **FP: `binding` with receiver** — RuboCop's VariableForce treats ANY call to a method
///   named `binding` (regardless of receiver) as making all local variables referenced.
///   nitrocop only handled receiverless `binding`. Fixed to match RuboCop: `obj.binding`
///   now also suppresses unused argument warnings.
/// - **FN: empty methods with `IgnoreEmptyMethods: false`** — a double-return bug in the
///   `body.is_none()` branch caused empty methods to always be skipped, even when config
///   set `IgnoreEmptyMethods: false`. Fixed to properly check params when body is absent.
///
/// ## Additional fixes (corpus 99.6% → improved):
/// - **FN: block/lambda parameter shadowing** — when a block or lambda declares a parameter
///   with the same name as a method parameter (e.g., `def foo(x); items.each { |x| x }`),
///   the read inside the block refers to the block's variable, NOT the method's. VarReadFinder
///   now tracks `block_depth` and uses Prism's `depth()` field on read/write nodes to only
///   count references that reach back to the method scope (`depth >= block_depth`).
/// - **FP: `binding(&block)` incorrectly suppressed warnings** — in RuboCop's Parser AST,
///   a block-pass `&block` is a child of the send node, making it look like `binding` has
///   arguments. Prism separates block arguments from regular arguments. Fixed to also check
///   that the call's `block()` is not a `BlockArgumentNode`.
///
/// ## Additional fixes (corpus 99.7% → improved):
/// - **FP: twisted scope expressions not visited** — RuboCop's VariableForce has
///   `TWISTED_SCOPE_TYPES` which processes certain expressions belonging to the outer
///   scope before entering a new scope. nitrocop's VarReadFinder was entirely skipping
///   nested `DefNode`, `ClassNode`, `SingletonClassNode`, and `ModuleNode` — meaning
///   method arguments used as: (1) singleton method receivers (`def obj.method_name`),
///   (2) singleton class expressions (`class << obj`), (3) superclass expressions
///   (`class Foo < base`) were not detected as used, producing false positives.
///   Fixed to visit these "twisted" expressions while still skipping the body.
pub struct UnusedMethodArgument;

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

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

    fn interested_node_types(&self) -> &'static [u8] {
        &[DEF_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 def_node = match node.as_def_node() {
            Some(d) => d,
            None => return,
        };

        let ignore_empty = config.get_bool("IgnoreEmptyMethods", true);
        let ignore_not_implemented = config.get_bool("IgnoreNotImplementedMethods", true);
        let allow_unused_keyword = config.get_bool("AllowUnusedKeywordArguments", false);
        let not_implemented_exceptions = config.get_string_array("NotImplementedExceptions");

        let body = def_node.body();

        // Check for empty methods
        if body.is_none() && ignore_empty {
            return;
        }

        // Check for not-implemented methods
        if let Some(ref b) = body {
            if ignore_not_implemented
                && is_not_implemented(b, not_implemented_exceptions.as_deref())
            {
                return;
            }
        }

        let params = match def_node.parameters() {
            Some(p) => p,
            None => return,
        };

        // Collect parameter info: (name_bytes, offset, is_keyword)
        let mut param_info: Vec<(Vec<u8>, usize, bool)> = Vec::new();

        for req in params.requireds().iter() {
            if let Some(rp) = req.as_required_parameter_node() {
                param_info.push((
                    rp.name().as_slice().to_vec(),
                    rp.location().start_offset(),
                    false,
                ));
            }
        }

        for opt in params.optionals().iter() {
            if let Some(op) = opt.as_optional_parameter_node() {
                param_info.push((
                    op.name().as_slice().to_vec(),
                    op.location().start_offset(),
                    false,
                ));
            }
        }

        // Rest parameter (*args)
        if let Some(rest) = params.rest() {
            if let Some(rp) = rest.as_rest_parameter_node() {
                if let Some(name_loc) = rp.name_loc() {
                    param_info.push((
                        rp.name().map(|n| n.as_slice().to_vec()).unwrap_or_default(),
                        name_loc.start_offset(),
                        false,
                    ));
                }
            }
        }

        // Post parameters (required params after rest, e.g. `def foo(*args, last)`)
        for post in params.posts().iter() {
            if let Some(rp) = post.as_required_parameter_node() {
                param_info.push((
                    rp.name().as_slice().to_vec(),
                    rp.location().start_offset(),
                    false,
                ));
            }
        }

        if !allow_unused_keyword {
            for kw in params.keywords().iter() {
                if let Some(kp) = kw.as_required_keyword_parameter_node() {
                    param_info.push((
                        kp.name().as_slice().to_vec(),
                        kp.location().start_offset(),
                        true,
                    ));
                } else if let Some(kp) = kw.as_optional_keyword_parameter_node() {
                    param_info.push((
                        kp.name().as_slice().to_vec(),
                        kp.location().start_offset(),
                        true,
                    ));
                }
            }
        }

        // Keyword rest parameter (**opts)
        if let Some(kwrest) = params.keyword_rest() {
            if let Some(kp) = kwrest.as_keyword_rest_parameter_node() {
                if let Some(name_loc) = kp.name_loc() {
                    let is_keyword = false; // **opts is not a keyword arg for display purposes
                    param_info.push((
                        kp.name().map(|n| n.as_slice().to_vec()).unwrap_or_default(),
                        name_loc.start_offset(),
                        is_keyword,
                    ));
                }
            }
        }

        // Block parameter (&block)
        if let Some(block) = params.block() {
            if let Some(name_loc) = block.name_loc() {
                param_info.push((
                    block
                        .name()
                        .map(|n| n.as_slice().to_vec())
                        .unwrap_or_default(),
                    name_loc.start_offset(),
                    false,
                ));
            }
        }

        if param_info.is_empty() {
            return;
        }

        // Find all local variable reads in the body AND in parameter defaults.
        // A parameter used as a default value for another parameter counts as used
        // (e.g., `def foo(node, start = node)` — `node` is used in default of `start`).
        let mut finder = VarReadFinder {
            names: Vec::new(),
            has_forwarding_super: false,
            has_binding_call: false,
            block_depth: 0,
        };
        if let Some(ref b) = body {
            finder.visit(b);
        }

        // Also scan parameter default values for variable reads
        for opt in params.optionals().iter() {
            if let Some(op) = opt.as_optional_parameter_node() {
                finder.visit(&op.value());
            }
        }
        for kw in params.keywords().iter() {
            if let Some(kp) = kw.as_optional_keyword_parameter_node() {
                finder.visit(&kp.value());
            }
        }

        // If the body contains bare `super` (ForwardingSuperNode), all args are
        // implicitly forwarded and therefore "used".
        if finder.has_forwarding_super {
            return;
        }

        // If the body calls `binding`, all local variables are accessible via
        // `binding.local_variable_get`, so consider all args as used.
        if finder.has_binding_call {
            return;
        }

        for (name, offset, is_keyword) in &param_info {
            // Skip arguments prefixed with _
            if name.starts_with(b"_") {
                continue;
            }

            // Check if the variable is referenced in the body
            if !finder.names.iter().any(|n| n == name) {
                let (line, column) = source.offset_to_line_col(*offset);
                // For keyword args, strip trailing ':'
                let display_name = if *is_keyword {
                    let s = String::from_utf8_lossy(name);
                    s.trim_end_matches(':').to_string()
                } else {
                    String::from_utf8_lossy(name).to_string()
                };
                diagnostics.push(self.diagnostic(
                    source,
                    line,
                    column,
                    format!("Unused method argument - `{display_name}`."),
                ));
            }
        }
    }
}

fn is_not_implemented(body: &ruby_prism::Node<'_>, exceptions: Option<&[String]>) -> bool {
    // Check if body is a single `raise NotImplementedError` or `fail "..."` statement
    let stmts = match body.as_statements_node() {
        Some(s) => s,
        None => {
            // Could be a direct call node
            return check_not_implemented_call(body, exceptions);
        }
    };

    let body_nodes: Vec<_> = stmts.body().iter().collect();
    if body_nodes.len() != 1 {
        return false;
    }

    check_not_implemented_call(&body_nodes[0], exceptions)
}

fn check_not_implemented_call(node: &ruby_prism::Node<'_>, exceptions: Option<&[String]>) -> bool {
    let call = match node.as_call_node() {
        Some(c) => c,
        None => return false,
    };

    let method_name = call.name().as_slice();
    if call.receiver().is_some() {
        return false;
    }

    if method_name == b"raise" {
        if let Some(args) = call.arguments() {
            let arg_list: Vec<_> = args.arguments().iter().collect();
            if !arg_list.is_empty() {
                return is_allowed_exception(&arg_list[0], exceptions);
            }
        }
        // `raise` with no arguments is also a "not implemented" pattern
        false
    } else {
        method_name == b"fail"
    }
}

/// Check if a node is an allowed exception class for NotImplementedExceptions config.
/// Default allowed classes: ["NotImplementedError"].
fn is_allowed_exception(node: &ruby_prism::Node<'_>, exceptions: Option<&[String]>) -> bool {
    let const_name = if let Some(c) = node.as_constant_read_node() {
        String::from_utf8_lossy(c.name().as_slice()).to_string()
    } else if let Some(cp) = node.as_constant_path_node() {
        // Handle qualified constants like ::NotImplementedError or Library::AbstractMethodError
        // Reconstruct the full constant path name
        extract_constant_path_name(&cp)
    } else {
        return false;
    };

    match exceptions {
        Some(allowed) => {
            if allowed.is_empty() {
                // Empty config: only default NotImplementedError
                const_name == "NotImplementedError" || const_name == "::NotImplementedError"
            } else {
                // Check against configured exceptions, allowing :: prefix
                allowed.iter().any(|exc| {
                    const_name == *exc
                        || const_name == format!("::{exc}")
                        || format!("::{const_name}") == *exc
                })
            }
        }
        None => {
            // No config: default to NotImplementedError
            const_name == "NotImplementedError" || const_name == "::NotImplementedError"
        }
    }
}

/// Extract the full constant path name, e.g., "Foo::Bar" or "::Foo::Bar"
fn extract_constant_path_name(cp: &ruby_prism::ConstantPathNode<'_>) -> String {
    let mut parts = Vec::new();
    let mut has_root = false;

    // Get the child name
    if let Some(name) = cp.name() {
        parts.push(String::from_utf8_lossy(name.as_slice()).to_string());
    }

    // Walk up the parent chain
    if let Some(parent) = cp.parent() {
        if let Some(parent_cp) = parent.as_constant_path_node() {
            let parent_name = extract_constant_path_name(&parent_cp);
            return format!("{parent_name}::{}", parts.first().unwrap_or(&String::new()));
        } else if let Some(cr) = parent.as_constant_read_node() {
            parts.insert(0, String::from_utf8_lossy(cr.name().as_slice()).to_string());
        }
    } else {
        // No parent means root-level (::Foo)
        has_root = true;
    }

    let path = parts.join("::");
    if has_root { format!("::{path}") } else { path }
}

struct VarReadFinder {
    names: Vec<Vec<u8>>,
    has_forwarding_super: bool,
    has_binding_call: bool,
    /// Number of block/lambda scopes we've entered. Used to correctly scope
    /// variable reads: only reads at depth >= block_depth reference the
    /// method's parameters. Reads at depth < block_depth reference a
    /// block/lambda's own parameter (which may shadow the method param).
    block_depth: u32,
}

impl<'pr> Visit<'pr> for VarReadFinder {
    fn visit_local_variable_read_node(&mut self, node: &ruby_prism::LocalVariableReadNode<'pr>) {
        // Only count this read as a method-param reference if its depth
        // reaches back to the method scope. `depth` is how many scopes
        // up from the innermost enclosing scope the variable resolves to.
        // A read inside a block with depth 0 refers to the block's own
        // variable (which may shadow a method param with the same name).
        if node.depth() >= self.block_depth {
            self.names.push(node.name().as_slice().to_vec());
        }
    }

    // Compound assignment operators (+=, -=, etc.) implicitly read the variable
    fn visit_local_variable_operator_write_node(
        &mut self,
        node: &ruby_prism::LocalVariableOperatorWriteNode<'pr>,
    ) {
        if node.depth() >= self.block_depth {
            self.names.push(node.name().as_slice().to_vec());
        }
        ruby_prism::visit_local_variable_operator_write_node(self, node);
    }

    // `a &&= b` implicitly reads `a`
    fn visit_local_variable_and_write_node(
        &mut self,
        node: &ruby_prism::LocalVariableAndWriteNode<'pr>,
    ) {
        if node.depth() >= self.block_depth {
            self.names.push(node.name().as_slice().to_vec());
        }
        ruby_prism::visit_local_variable_and_write_node(self, node);
    }

    // `a ||= b` implicitly reads `a`
    fn visit_local_variable_or_write_node(
        &mut self,
        node: &ruby_prism::LocalVariableOrWriteNode<'pr>,
    ) {
        if node.depth() >= self.block_depth {
            self.names.push(node.name().as_slice().to_vec());
        }
        ruby_prism::visit_local_variable_or_write_node(self, node);
    }

    // Bare `super` (no args, no parens) implicitly forwards all method arguments
    fn visit_forwarding_super_node(&mut self, _node: &ruby_prism::ForwardingSuperNode<'pr>) {
        self.has_forwarding_super = true;
    }

    // Detect `binding` calls — accessing binding exposes all local variables.
    // RuboCop's VariableForce treats `binding` with ANY receiver (including
    // `obj.binding`) as making all variables referenced, so we match that
    // behavior. Only `binding` with arguments (e.g. `binding(:something)`)
    // is excluded — that's not Kernel#binding.
    // Also exclude `binding(&block)` — in RuboCop's Parser AST, a block_pass
    // is an argument that makes `args.children.empty?` false, so RuboCop
    // does NOT treat `binding(&block)` as Kernel#binding.
    fn visit_call_node(&mut self, node: &ruby_prism::CallNode<'pr>) {
        if node.name().as_slice() == b"binding"
            && node.arguments().is_none()
            && node
                .block()
                .is_none_or(|b| b.as_block_argument_node().is_none())
        {
            self.has_binding_call = true;
        }
        ruby_prism::visit_call_node(self, node);
    }

    // Block/lambda scopes increment block_depth so that variable reads
    // inside them are correctly scoped. Only reads with depth >= block_depth
    // reference the method's parameters.
    fn visit_block_node(&mut self, node: &ruby_prism::BlockNode<'pr>) {
        self.block_depth += 1;
        ruby_prism::visit_block_node(self, node);
        self.block_depth -= 1;
    }

    fn visit_lambda_node(&mut self, node: &ruby_prism::LambdaNode<'pr>) {
        self.block_depth += 1;
        ruby_prism::visit_lambda_node(self, node);
        self.block_depth -= 1;
    }

    // Don't recurse into the body of nested def/class/module/sclass (they
    // have their own scope), BUT do visit their "twisted" expressions that
    // belong to the outer scope:
    // - DefNode: receiver (e.g., `def obj.method_name`)
    // - ClassNode: superclass (e.g., `class Foo < base`)
    // - SingletonClassNode: expression (e.g., `class << obj`)
    // - ModuleNode: constant_path only (unlikely to contain local vars)
    // This matches RuboCop's VariableForce TWISTED_SCOPE_TYPES handling.
    fn visit_def_node(&mut self, node: &ruby_prism::DefNode<'pr>) {
        if let Some(receiver) = node.receiver() {
            self.visit(&receiver);
        }
    }
    fn visit_class_node(&mut self, node: &ruby_prism::ClassNode<'pr>) {
        if let Some(superclass) = node.superclass() {
            self.visit(&superclass);
        }
    }
    fn visit_module_node(&mut self, _node: &ruby_prism::ModuleNode<'pr>) {}
    fn visit_singleton_class_node(&mut self, node: &ruby_prism::SingletonClassNode<'pr>) {
        self.visit(&node.expression());
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    crate::cop_fixture_tests!(UnusedMethodArgument, "cops/lint/unused_method_argument");

    #[test]
    fn test_block_param_unused() {
        // &block parameter that is unused should be flagged
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(a, &block)\n  puts a\nend\n",
        );
        let names: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
        assert!(
            names.iter().any(|m| m.contains("block")),
            "Expected offense for unused &block, got: {:?}",
            names
        );
    }

    #[test]
    fn test_kwrest_param_unused() {
        // **opts parameter that is unused should be flagged
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(a, **opts)\n  puts a\nend\n",
        );
        let names: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
        assert!(
            names.iter().any(|m| m.contains("opts")),
            "Expected offense for unused **opts, got: {:?}",
            names
        );
    }

    #[test]
    fn test_post_param_unused() {
        // post parameter (after rest) that is unused should be flagged
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(*args, last)\n  args.first\nend\n",
        );
        let names: Vec<&str> = diags.iter().map(|d| d.message.as_str()).collect();
        assert!(
            names.iter().any(|m| m.contains("last")),
            "Expected offense for unused post param 'last', got: {:?}",
            names
        );
    }

    #[test]
    fn test_keyword_arg_used_no_offense() {
        // keyword arg that IS used should NOT be flagged
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(bar:)\n  puts bar\nend\n",
        );
        assert!(
            diags.is_empty(),
            "Expected no offense for used keyword arg, got: {:?}",
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_binding_with_receiver_no_offense() {
        // obj.binding should suppress unused arg warnings (matches RuboCop)
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(bar)\n  some_object.binding\nend\n",
        );
        assert!(
            diags.is_empty(),
            "Expected no offense when obj.binding is called, got: {:?}",
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_empty_method_ignore_false() {
        // When IgnoreEmptyMethods is false, empty methods should still flag unused args
        let mut config = CopConfig::default();
        config.options.insert(
            "IgnoreEmptyMethods".to_string(),
            serde_yml::Value::Bool(false),
        );
        let diags = crate::testutil::run_cop_full_with_config(
            &UnusedMethodArgument,
            b"def foo(bar)\nend\n",
            config,
        );
        assert!(
            !diags.is_empty(),
            "Expected offense for unused arg in empty method when IgnoreEmptyMethods=false"
        );
    }

    #[test]
    fn test_block_param_shadows_method_param_fn() {
        // When a block parameter shadows a method parameter, the method param
        // is unused even though a variable with the same name is read inside
        // the block. RuboCop's VariableForce correctly scopes this.
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(x)\n  items.each { |x| puts x }\nend\n",
        );
        assert!(
            diags.iter().any(|d| d.message.contains("x")),
            "Expected offense for method param 'x' shadowed by block param, got: {:?}",
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_lambda_param_shadows_method_param_fn() {
        // Lambda parameter shadows method parameter
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(x)\n  ->(x) { puts x }\nend\n",
        );
        assert!(
            diags.iter().any(|d| d.message.contains("x")),
            "Expected offense for method param 'x' shadowed by lambda param, got: {:?}",
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_binding_with_block_pass_still_flags() {
        // binding(&block) is NOT Kernel#binding — should still flag unused args
        // RuboCop's Parser treats &block as an argument, so VariableForce
        // does not suppress warnings.
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(bar, &blk)\n  binding(&blk)\nend\n",
        );
        assert!(
            diags.iter().any(|d| d.message.contains("bar")),
            "Expected offense for unused 'bar' when binding(&blk), got: {:?}",
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_multi_assign_target_not_used() {
        // Multi-assignment target should NOT count as a use of the parameter
        let diags = crate::testutil::run_cop_full(
            &UnusedMethodArgument,
            b"def foo(a, b)\n  a, b = 1, 2\nend\n",
        );
        assert!(
            diags.len() >= 2,
            "Expected 2 offenses for multi-assign only, got: {} ({:?})",
            diags.len(),
            diags.iter().map(|d| &d.message).collect::<Vec<_>>()
        );
    }
}

RuboCop Ruby Implementation (ground truth)

vendor/rubocop/lib/rubocop/cop/lint/unused_method_argument.rb

# frozen_string_literal: true

module RuboCop
  module Cop
    module Lint
      # Checks for unused method arguments.
      #
      # @example
      #   # bad
      #   def some_method(used, unused, _unused_but_allowed)
      #     puts used
      #   end
      #
      #   # good
      #   def some_method(used, _unused, _unused_but_allowed)
      #     puts used
      #   end
      #
      # @example AllowUnusedKeywordArguments: false (default)
      #   # bad
      #   def do_something(used, unused: 42)
      #     used
      #   end
      #
      # @example AllowUnusedKeywordArguments: true
      #   # good
      #   def do_something(used, unused: 42)
      #     used
      #   end
      #
      # @example IgnoreEmptyMethods: true (default)
      #   # good
      #   def do_something(unused)
      #   end
      #
      # @example IgnoreEmptyMethods: false
      #   # bad
      #   def do_something(unused)
      #   end
      #
      # @example IgnoreNotImplementedMethods: true (default)
      #   # with default value of `NotImplementedExceptions: ['NotImplementedError']`
      #
      #   # good
      #   def do_something(unused)
      #     raise NotImplementedError
      #   end
      #
      #   def do_something_else(unused)
      #     fail "TODO"
      #   end
      #
      # @example IgnoreNotImplementedMethods: true
      #   # with `NotImplementedExceptions: ['AbstractMethodError']`
      #
      #   # good
      #   def do_something(unused)
      #     raise AbstractMethodError
      #   end
      #
      # @example IgnoreNotImplementedMethods: false
      #   # bad
      #   def do_something(unused)
      #     raise NotImplementedError
      #   end
      #
      #   def do_something_else(unused)
      #     fail "TODO"
      #   end
      class UnusedMethodArgument < Base
        include UnusedArgument
        extend AutoCorrector

        # @!method not_implemented?(node)
        def_node_matcher :not_implemented?, <<~PATTERN
          {(send nil? :raise #allowed_exception_class? ...)
           (send nil? :fail ...)}
        PATTERN

        def self.autocorrect_incompatible_with
          [Style::ExplicitBlockArgument]
        end

        def self.joining_forces
          VariableForce
        end

        private

        def autocorrect(corrector, node)
          UnusedArgCorrector.correct(corrector, processed_source, node)
        end

        def check_argument(variable)
          return unless variable.method_argument?
          return if variable.keyword_argument? && cop_config['AllowUnusedKeywordArguments']
          return if ignored_method?(variable.scope.node.body)

          super
        end

        def ignored_method?(body)
          (cop_config['IgnoreEmptyMethods'] && body.nil?) ||
            (cop_config['IgnoreNotImplementedMethods'] && not_implemented?(body))
        end

        def message(variable)
          message = +"Unused method argument - `#{variable.name}`."

          unless variable.keyword_argument?
            message << " If it's necessary, use `_` or `_#{variable.name}` " \
                       "as an argument name to indicate that it won't be used. " \
                       "If it's unnecessary, remove it."
          end

          scope = variable.scope
          all_arguments = scope.variables.each_value.select(&:method_argument?)

          if all_arguments.none?(&:referenced?)
            message << " You can also write as `#{scope.name}(*)` " \
                       'if you want the method to accept any arguments ' \
                       "but don't care about them."
          end

          message
        end

        def allowed_exception_class?(node)
          return false unless node.const_type?

          allowed_class_names = Array(cop_config.fetch('NotImplementedExceptions', []))
          allowed_class_names.include?(node.const_name)
        end
      end
    end
  end
end

RuboCop Test Excerpts

vendor/rubocop/spec/rubocop/cop/lint/unused_method_argument_spec.rb

  describe 'inspection' do

    context 'when a method takes multiple arguments' do

      context 'and an argument is unused' do

        it 'registers an offense and adds underscore-prefix' do

          expect_offense(<<~RUBY)
            def some_method(foo, bar)
                            ^^^ #{message}
              puts bar
            end
          RUBY

        context 'and there is some whitespace around the unused argument' do

          it 'registers an offense and preserves whitespace' do

            expect_offense(<<~RUBY)
              def some_method(foo,
                  bar)
                  ^^^ #{message}
                puts foo
              end
            RUBY

        context 'and arguments are swap-assigned' do

          it 'accepts' do

            expect_no_offenses(<<~RUBY)
              def foo(a, b)
                a, b = b, a
              end
            RUBY

        context "and one argument is assigned to another, whilst other's value is not used" do

          it 'registers an offense' do

            expect_offense(<<~RUBY)
              def foo(a, b)
                      ^ #{message}
                a, b = b, 42
              end
            RUBY

      context 'and all the arguments are unused' do

        it 'registers offenses and suggests the use of `*` and ' \

          expect_offense(<<~RUBY)
            def some_method(foo, bar)
                                 ^^^ #{bar_message}
                            ^^^ #{foo_message}
            end
          RUBY

    context 'when a splat argument is unused' do

      it 'registers an offense and preserves the splat' do

        expect_offense(<<~RUBY)
          def some_method(foo, *bar)
                                ^^^ #{message}
            puts foo
          end
        RUBY

Current Fixture: offense.rb

tests/fixtures/cops/lint/unused_method_argument/offense.rb

def some_method(used, unused)
                      ^^^^^^ Lint/UnusedMethodArgument: Unused method argument - `unused`.
  puts used
end

def foo(bar, baz)
             ^^^ Lint/UnusedMethodArgument: Unused method argument - `baz`.
  bar
end

def calculate(x, y, z)
                    ^ Lint/UnusedMethodArgument: Unused method argument - `z`.
  x + y
end

def protect(*args)
             ^^^^ Lint/UnusedMethodArgument: Unused method argument - `args`.
  do_something
end

# block parameter unused
def with_block(a, &block)
                   ^^^^^ Lint/UnusedMethodArgument: Unused method argument - `block`.
  puts a
end

# keyword rest parameter unused
def with_kwrest(a, **opts)
                     ^^^^ Lint/UnusedMethodArgument: Unused method argument - `opts`.
  puts a
end

# post parameter unused (after rest)
def with_post(*args, last)
                     ^^^^ Lint/UnusedMethodArgument: Unused method argument - `last`.
  args.first
end

# multi-assign target only (not a read)
def multi_target(a, b)
                 ^ Lint/UnusedMethodArgument: Unused method argument - `a`.
                    ^ Lint/UnusedMethodArgument: Unused method argument - `b`.
  a, b = 1, 2
end

# block parameter shadows method parameter — method param is unused
def shadowed_by_block(x)
                      ^ Lint/UnusedMethodArgument: Unused method argument - `x`.
  items.each { |x| puts x }
end

# lambda parameter shadows method parameter — method param is unused
def shadowed_by_lambda(x)
                       ^ Lint/UnusedMethodArgument: Unused method argument - `x`.
  transform = ->(x) { x * 2 }
  transform.call(42)
end

# binding(&block) is NOT Kernel#binding — does not suppress unused arg warning
def with_binding_block_pass(bar, &blk)
                            ^^^ Lint/UnusedMethodArgument: Unused method argument - `bar`.
  binding(&blk)
end

Current Fixture: no_offense.rb

tests/fixtures/cops/lint/unused_method_argument/no_offense.rb

def some_method(used, _unused)
  puts used
end

def no_args
  puts "hello"
end

def empty_method(unused)
end

def not_implemented(unused)
  raise NotImplementedError
end

def not_implemented2(unused)
  fail "TODO"
end

def all_used(a, b)
  a + b
end

# bare super implicitly forwards all arguments, so they are "used"
def with_super(name, value)
  super
end

def initialize(x, y, z)
  super
  @extra = true
end

# used inside a block (blocks share scope with enclosing method)
def used_in_block(items, transform)
  items.map { |item| transform.call(item) }
end

# parameter used as default value for another parameter counts as used
def check_children_line_break(node, start = node)
  puts start
end

# binding call exposes all locals — args accessed via binding.local_variable_get
def render_icon(name, class: nil, **options)
  binding.local_variable_get(:class)
end

# block parameter used
def with_block(a, &block)
  block.call(a)
end

# keyword rest parameter used
def with_kwrest(a, **opts)
  do_something(a, opts)
end

# post parameter used
def with_post(*args, last)
  args.push(last)
end

# swap-assigned (both variables read)
def swap(a, b)
  a, b = b, a
end

# compound assignment reads the variable (a += 1 reads a)
def compound_assign(count)
  count += 1
  count
end

# or-assign reads the variable
def or_assign(value)
  value ||= "default"
  value
end

# and-assign reads the variable
def and_assign(flag)
  flag &&= validate(flag)
  flag
end

# underscore-prefixed block param is fine
def with_underscore_block(_a, &_block)
  42
end

# anonymous rest/block (no name) should not flag
def anonymous_rest(*)
  42
end

# raise NotImplementedError with message (still not-implemented)
def not_impl_with_msg(arg)
  raise NotImplementedError, "not yet"
end

# fail without message (still not-implemented)
def fail_bare(arg)
  fail
end

# binding called with a receiver still suppresses warnings (matches RuboCop)
def with_receiver_binding(name, value)
  some_object.binding
end

# method param captured by block (not shadowed) — still used
def captured_in_nested_block(x)
  items.each do |item|
    nested.each { |n| process(x, item, n) }
  end
end

# method argument used as receiver of a singleton method definition
def define_singleton(obj)
  def obj.to_s
    "custom"
  end
end

# method argument used in singleton class expression
def extend_object(obj)
  class << obj
    def custom_method
      42
    end
  end
end

# method argument used as superclass in class definition (twisted scope)
def make_subclass(base)
  class Custom < base
    def greet
      "hello"
    end
  end
end

@6 6 bot force-pushed the fix/lint-unused_method_argument-23474452426 branch from 61bd1b6 to 770258c Compare March 24, 2026 05:55
@6 6 bot marked this pull request as ready for review March 24, 2026 05:55
@6 6 bot enabled auto-merge (squash) March 24, 2026 05:55
@6
Copy link
Copy Markdown
Owner

6 commented Mar 24, 2026

Closing — the per-cop byte scanner approach is too fragile and doesn't scale.

The root cause (Prism parses files that RuboCop's Parser gem rejects as syntax errors) affects all cops equally. PR #153 for Lint/HashCompareByIdentity hit the same issue and correctly documented it rather than adding a per-cop workaround.

The right fix is either:

  • Document these as known parser-level FPs (like [bot] Fix Lint/HashCompareByIdentity #153)
  • Fix at the engine level: skip non-syntax cops on files where Prism reports diagnostics that would be fatal in Parser (one check in the linter, covers all cops)

The 70-line byte scanner for specific regex escape patterns would also false-positive on those patterns appearing in strings or comments.

@6 6 closed this Mar 24, 2026
auto-merge was automatically disabled March 24, 2026 05:57

Pull request was closed

@6 6 deleted the fix/lint-unused_method_argument-23474452426 branch March 24, 2026 05:57
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.

1 participant