Skip to content

[bot] Fix RSpec/LetSetup#149

Merged
6 merged 2 commits intomainfrom
fix/rspec-let_setup-23472126892
Mar 24, 2026
Merged

[bot] Fix RSpec/LetSetup#149
6 merged 2 commits intomainfrom
fix/rspec-let_setup-23472126892

Conversation

@6
Copy link
Copy Markdown
Contributor

@6 6 bot commented Mar 24, 2026

Automated fix fix for RSpec/LetSetup corpus conformance.

Details

Cop RSpec/LetSetup
Backend codex / hard
Model gpt-5.4 (xhigh)
Mode fix
Turns 1
Run #69

Result

Updated [let_setup.rs](/home/runner/work/nitrocop/nitrocop/src/cop/rspec/let_setup.rs#L163) so the in-scope `let!` collector descends into Prism `ElseNode` bodies. That was the miss: `if_node.subsequent()` reached the `else`, but the walker treated the `ElseNode` wrapper as opaque, so unused `let!` inside `else` branches were never collected. I also documented the finding on the cop struct in [let_setup.rs](/home/runner/work/nitrocop/nitrocop/src/cop/rspec/let_setup.rs#L29).

I replaced the malformed top-level fixture tail with a representative `if/else` example-group case in [offense.rb](/home/runner/work/nitrocop/nitrocop/tests/fixtures/cops/rspec/let_setup/offense.rb#L114), where the first two `let!` helpers are referenced and only `work_without_embargo` is unused.

Verification: `cargo test --lib -- cop::rspec::let_setup` passed. Commit: `d9c3752d` (`Fix RSpec LetSetup else branch detection`).

Changes

  2 files changed
Agent conversation excerpt

Reading docs/agent-ci.md first, then I’ll inspect the existing RSpec/LetSetup implementation and fixtures to reproduce the single extended FN before editing anything.

Reading docs/agent-ci.md first, then I’ll inspect the existing RSpec/LetSetup implementation and fixtures to reproduce the single extended FN before editing anything.

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

exec_command: sed -n '1,260p' src/cop/rspec/let_setup.rs

exec_command: sed -n '1,240p' tests/fixtures/cops/rspec/let_setup/offense.rb

exec_command: sed -n '1,240p' tests/fixtures/cops/rspec/let_setup/no_offense.rb

The current fixture already has the reported FN snippet appended, but it’s isolated at top level rather than under the else/group context from the corpus example. I’m pulling the corpus context and running the focused cop test next to see whether this is a pure detection gap or a malformed fixture artifact.

The current fixture already has the reported FN snippet appended, but it’s isolated at top level rather than under the else/group context from the corpus example. I’m pulling the corpus context and running the focused cop test next to see whether this is a pure detection gap or a malformed fixture artifact.

exec_command: python3 scripts/investigate-cop.py RSpec/LetSetup --context

exec_command: cargo test --lib -- cop::rspec::let_setup

write_stdin: ``

write_stdin: ``

write_stdin: ``

write_stdin: ``

write_stdin: ``

The cached standard corpus is clean, which matches the prompt: this is extended-only. The focused test run is still compiling; once that finishes I’ll know whether the existing appended fixture already reproduces the miss or whether I need to reshape it to the actual else context.

The cached standard corpus is clean, which matches the prompt: this is extended-only. The focused test run is still compiling; once that finishes I’ll know whether the existing appended fixture already reproduces the miss or whether I need to reshape it to the actual else context.

write_stdin: ``

write_stdin: ``

The focused fixture now fails exactly the way the prompt predicts: nitrocop misses the new let! case. I’m checking the extended corpus context and then I’ll patch the visitor in the narrowest place that explains an offense under an else branch without broadening the cop.

The focused fixture now fails exactly the way the prompt predicts: nitrocop misses the new let! case. I’m checking the extended corpus context and then I’ll patch the visitor in the narrowest place that explains an offense under an else branch without broadening the cop.

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

exec_command: python3 scripts/verify-cop-locations.py RSpec/LetSetup

exec_command: python3 scripts/investigate-cop.py RSpec/LetSetup --context --extended --fn-only

write_stdin: ``

write_stdin: ``

The existing last fixture case is too broad: top-level let! outside any example group should not be the contract for this cop. I’m confirming that with RuboCop and then I’ll replace it with the real else-branch shape from the corpus so the test guards the right behavior.

The existing last fixture case is too broad: top-level let! outside any example group should not be the contract for this cop. I’m confirming that with RuboCop and then I’ll replace it with the real else-branch shape from the corpus so the test guards the right behavior.

exec_command: printf "%s\n" "let!(:work_without_embargo) { create(:generic_work) }" > /tmp/let_setup_top_level.rb && rubocop --only RSpec/LetSetup /tmp/let_setup_top_level.rb

exec_command: cat <<'RUBY' > /tmp/let_setup_else.rb describe Hyrax do if condition let!(:used_record) { create(:record) } else let!(:work_with_released_embargo) { create(:embargoed_work) } let!(:wor

write_stdin: ``

write_stdin: ``

write_stdin: ``

exec_command: rg -n "as_else_node|as_elsif_node|ElseNode|ElsifNode" src tests -g '*.rs'

exec_command: rg -n "if condition|else" tests/fixtures/cops/rspec -g '*.rb'

exec_command: sed -n '150,230p' src/cop/rspec/expect_in_let.rs

exec_command: sed -n '150,235p' src/cop/rspec/expect_in_hook.rs

exec_command: sed -n '1,120p' tests/fixtures/cops/rspec/expect_in_let/offense.rb

I have the root cause scoped: collect_let_bangs_in_scope unwraps IfNode and UnlessNode, but it never unwraps Prism’s ElseNode, so let! declarations in an else body are invisible while nested groups in that body still get visited normally. I’m updating the fixture to the real if/else example, then I’ll add ElseNode traversal in the collector, rerun the focused test, and stop there.

I have the root cause scoped: collect_let_bangs_in_scope unwraps IfNode and UnlessNode, but it never unwraps Prism’s ElseNode, so let! declarations in an else body are invisible while nested groups in that body still get visited normally. I’m updating the fixture to the real if/else example, then I’ll add ElseNode traversal in the collector, rerun the focused test, and stop there.

update_plan

exec_command: nl -ba tests/fixtures/cops/rspec/let_setup/offense.rb | tail -n 30

I’m editing two files now: the fixture will become a representative if/else example inside an RSpec group, and the cop will gain a single ElseNode descent in the in-scope let! collector plus a short investigation note on the struct.

I’m editing two files now: the fixture will become a representative if/else example inside an RSpec group, and the cop will gain a single ElseNode descent in the in-scope let! collector plus a short investigation note on the struct.

apply_patch

exec_command: cargo test --lib -- cop::rspec::let_setup

write_stdin: ``

write_stdin: ``

The corrected fixture still fails with the same single missing offense, now at the real else-branch location. I’m patching only the in-scope declaration walk so it unwraps Prism ElseNode bodies instead of treating the else wrapper as opaque.

The corrected fixture still fails with the same single missing offense, now at the real else-branch location. I’m patching only the in-scope declaration walk so it unwraps Prism ElseNode bodies instead of treating the else wrapper as opaque.

apply_patch

exec_command: cargo fmt -- src/cop/rspec/let_setup.rs

write_stdin: ``

exec_command: cargo test --lib -- cop::rspec::let_setup

write_stdin: ``

The collector change is in place and formatted. I’m waiting on the focused test run now; if it comes back clean, I’ll do a quick diff check limited to the cop files and report the exact outcome.

The collector change is in place and formatted. I’m waiting on the focused test run now; if it comes back clean, I’ll do a quick diff check limited to the cop files and report the exact outcome.

write_stdin: ``

write_stdin: ``

write_stdin: ``

Task prompt (7626 tokens)

Fix RSpec/LetSetup — 0 FP, 1 FN

Cross-Corpus Risk

  • Standard corpus: 6,430 matches, 0 FP, 0 FN
  • Extended corpus: 10,680 matches, 0 FP, 1 FN
  • Extended divergence currently touches 1 repo

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: 10,680 matches, 0 false positives, 1 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 RSpec/LetSetup /tmp/test.rb
    echo '<general pattern>' > /tmp/test.rb && rubocop --only RSpec/LetSetup /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/rspec/let_setup/offense.rb with ^ annotation
    • FP fix: add the false-positive pattern to tests/fixtures/cops/rspec/let_setup/no_offense.rb
  4. Verify test fails: cargo test --lib -- cop::rspec::let_setup
  5. Fix src/cop/rspec/let_setup.rs
  6. Verify test passes: cargo test --lib -- cop::rspec::let_setup
  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
     ^^ RSpec/LetSetup: Trailing whitespace detected.

The ^ characters must align with the offending columns. The message format is RSpec/LetSetup: <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 RSpec/LetSetup /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/rspec/let_setup.rs and tests/fixtures/cops/rspec/let_setup/
  • Run cargo test --lib -- cop::rspec::let_setup 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 RSpec/LetSetup --repos-only
  • python3 scripts/investigate-cop.py RSpec/LetSetup --context
  • python3 scripts/verify-cop-locations.py RSpec/LetSetup

Top FN repos:

  • samvera__hyrax__87877a3 (1 FN) — example spec/services/hyrax/embargo_service_spec.rb:91

Representative FN examples:

  • samvera__hyrax__87877a3: spec/services/hyrax/embargo_service_spec.rb:91 — Do not use let! to setup objects not referenced in tests.

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

FN #1: samvera__hyrax__87877a3: spec/services/hyrax/embargo_service_spec.rb:91

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

Enclosing structure: enclosing line: else
The offense is inside this structure — the cop may need
to handle this context to detect the pattern.

Message: Do not use let! to setup objects not referenced in tests.

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

    let!(:work_without_embargo) { create(:generic_work) }
^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

Full source context:

    end
  else
    # NOTE: This test suite is the original Dassie sequence. Notice the combination of Valkyrie and ActiveFedora objects.
    #   An attempt was made to use only Valkyrie objects across both Dassie and Koppie, but each environment returns different results.
    #   This is very similar to the test suite at spec/services/hyrax/lease_service_spec.rb.
    let!(:work_with_released_embargo) { create(:embargoed_work, with_embargo_attributes: { embargo_date: past_date.to_s }) }
    let!(:work_with_embargo_in_effect) { create(:embargoed_work, with_embargo_attributes: { embargo_date: future_date.to_s }) }
    let!(:work_without_embargo) { create(:generic_work) }

    include_examples 'tests #assets_with_expired_embargoes'
    include_examples 'tests #assets_with_enforced_embargoes'

    describe '#assets_with_deactivated_embargoes' do
      include_context 'with a fake Solr hash with embargo_history_ssim populated'

Current Rust Implementation

src/cop/rspec/let_setup.rs

use crate::cop::util::RSPEC_DEFAULT_INCLUDE;
use crate::cop::{Cop, CopConfig};
use crate::diagnostic::{Diagnostic, Severity};
use crate::parse::source::SourceFile;
use ruby_prism::Visit;
use std::collections::HashSet;

/// RSpec/LetSetup: Flag `let!` that is not referenced in tests (only used for side effects).
///
/// Investigation findings:
/// - The dominant false-positive pattern was inner `let!` overriding an outer `let!` with the
///   same name (e.g., `let!(:record) { nil }` inside a nested context that overrides a parent
///   `let!(:record) { create(...) }`). RuboCop skips these via `overrides_outer_let_bang?`.
/// - Implemented a recursive visitor that maintains a stack of ancestor `let!` names, so inner
///   overrides are correctly suppressed without needing parent node references.
/// - FN fix: Added `include_examples` and `include_context` to recognized group names.
///   RuboCop's `example_or_shared_group_or_including?` matches `Includes.all` which includes
///   `include_examples`, `include_context`, `it_behaves_like`, `it_should_behave_like`.
///   nitrocop was missing `include_examples` and `include_context`, causing 103 FNs.
/// - FN fix: Removed `LocalVariableReadNode` from `IdentifierCollector`. RuboCop's
///   `method_called?` uses `(send nil? %)` which only matches method sends, not local
///   variable reads. Multi-line `let!` bodies that assign to a local variable with the
///   same name as the `let!` (e.g., `let!(:order) do order = create(...); order end`)
///   had the local variable read falsely marking the name as "used".
/// - FN fix: `let!` declaration search now recurses through non-scope-change blocks
///   (e.g., `[].each do ... end`) to find `let!` calls, matching RuboCop's
///   `ExampleGroup#find_all_in_scope` behavior which stops only at scope changes
///   (other example groups) and examples.
pub struct LetSetup;

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

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

    fn default_include(&self) -> &'static [&'static str] {
        RSPEC_DEFAULT_INCLUDE
    }

    fn check_source(
        &self,
        source: &SourceFile,
        parse_result: &ruby_prism::ParseResult<'_>,
        _code_map: &crate::parse::codemap::CodeMap,
        _config: &CopConfig,
        diagnostics: &mut Vec<Diagnostic>,
        _corrections: Option<&mut Vec<crate::correction::Correction>>,
    ) {
        let mut visitor = LetSetupVisitor {
            cop: self,
            source,
            diagnostics,
            ancestor_let_bang_names: Vec::new(),
        };
        visitor.visit(&parse_result.node());
    }
}

struct LetSetupVisitor<'a> {
    cop: &'a LetSetup,
    source: &'a SourceFile,
    diagnostics: &'a mut Vec<Diagnostic>,
    /// Stack of sets: each set contains the `let!` names defined at that ancestor scope level.
    ancestor_let_bang_names: Vec<HashSet<Vec<u8>>>,
}

impl<'pr> LetSetupVisitor<'_> {
    fn process_example_group(&mut self, block_node: &ruby_prism::BlockNode<'pr>) {
        let body = match block_node.body() {
            Some(b) => b,
            None => return,
        };
        let stmts = match body.as_statements_node() {
            Some(s) => s,
            None => return,
        };

        // Collect let! names (recursing through non-scope-change blocks)
        // and all method-call identifiers used in the same scope.
        let mut let_bang_decls: Vec<(Vec<u8>, usize, usize)> = Vec::new();
        let mut used_names: HashSet<Vec<u8>> = HashSet::new();
        let mut this_scope_let_bang_names: HashSet<Vec<u8>> = HashSet::new();

        for stmt in stmts.body().iter() {
            // Recursively find let! declarations through non-scope-change blocks,
            // matching RuboCop's ExampleGroup#find_all_in_scope behavior.
            self.collect_let_bangs_in_scope(
                &stmt,
                &mut let_bang_decls,
                &mut this_scope_let_bang_names,
            );
            // Walk ALL siblings (including let! bodies) for identifier
            // collection. This matches RuboCop behavior where method_called?
            // searches the entire example group block, so a let! name used
            // inside a sibling let! body is not flagged.
            let mut collector = IdentifierCollector {
                names: &mut used_names,
            };
            collector.visit(&stmt);
        }

        for (let_name, line, col) in &let_bang_decls {
            // Skip if this let! overrides an outer let! with the same name
            if self.overrides_outer_let_bang(let_name) {
                continue;
            }
            if !used_names.contains(let_name) {
                self.diagnostics.push(self.cop.diagnostic(
                    self.source,
                    *line,
                    *col,
                    "Do not use `let!` to setup objects not referenced in tests.".to_string(),
                ));
            }
        }

        // Push this scope's let! names onto the ancestor stack, then recurse into children
        self.ancestor_let_bang_names.push(this_scope_let_bang_names);
        for stmt in stmts.body().iter() {
            self.visit(&stmt);
        }
        self.ancestor_let_bang_names.pop();
    }

    /// Recursively search for `let!` calls within the current scope, stopping at
    /// scope changes (example groups, includes) and examples. This mirrors RuboCop's
    /// `ExampleGroup#find_all_in_scope` which recurses through non-scope-change blocks
    /// like iterators (`[].each do ... end`).
    fn collect_let_bangs_in_scope(
        &self,
        node: &ruby_prism::Node<'pr>,
        decls: &mut Vec<(Vec<u8>, usize, usize)>,
        scope_names: &mut HashSet<Vec<u8>>,
    ) {
        // Unwrap trailing if/unless modifiers: `let!(:foo) { } if cond` parses as
        // IfNode/UnlessNode wrapping the CallNode. Recurse into the body statement(s).
        if let Some(if_node) = node.as_if_node() {
            if let Some(stmts) = if_node.statements() {
                for stmt in stmts.body().iter() {
                    self.collect_let_bangs_in_scope(&stmt, decls, scope_names);
                }
            }
            if let Some(subsequent) = if_node.subsequent() {
                self.collect_let_bangs_in_scope(&subsequent, decls, scope_names);
            }
            return;
        }
        if let Some(unless_node) = node.as_unless_node() {
            if let Some(stmts) = unless_node.statements() {
                for stmt in stmts.body().iter() {
                    self.collect_let_bangs_in_scope(&stmt, decls, scope_names);
                }
            }
            return;
        }
        if let Some(c) = node.as_call_node() {
            let m = c.name().as_slice();
            // If it's a let! call, record it
            if m == b"let!" && c.receiver().is_none() {
                if let Some(let_name) = extract_let_name(&c) {
                    let loc = c.location();
                    let (line, col) = self.source.offset_to_line_col(loc.start_offset());
                    scope_names.insert(let_name.clone());
                    decls.push((let_name, line, col));
                }
                return;
            }
            // If it's a scope change (example group or include) or an example,
            // stop recursing — let! declarations inside belong to that inner scope
            if is_example_group_or_include(m) || is_example(m) {
                return;
            }
            // For other calls with blocks (e.g., `[].each do ... end`),
            // recurse into the block body
            if let Some(block) = c.block() {
                if let Some(block_node) = block.as_block_node() {
                    if let Some(body) = block_node.body() {
                        if let Some(stmts) = body.as_statements_node() {
                            for stmt in stmts.body().iter() {
                                self.collect_let_bangs_in_scope(&stmt, decls, scope_names);
                            }
                        }
                    }
                }
            }
        }
    }

    fn overrides_outer_let_bang(&self, name: &[u8]) -> bool {
        self.ancestor_let_bang_names
            .iter()
            .any(|scope| scope.contains(name))
    }
}

impl<'pr> Visit<'pr> for LetSetupVisitor<'_> {
    fn visit_call_node(&mut self, node: &ruby_prism::CallNode<'pr>) {
        let name = node.name().as_slice();
        if !is_example_group_or_include(name) {
            // Not an example group — continue default traversal
            ruby_prism::visit_call_node(self, node);
            return;
        }

        let block = match node.block() {
            Some(b) => b,
            None => return,
        };
        let block_node = match block.as_block_node() {
            Some(b) => b,
            None => return,
        };

        // Process this example group (handles let! detection + nested recursion)
        self.process_example_group(&block_node);
        // Don't call visit_call_node default — we already recursed into children
    }
}

fn extract_let_name(call: &ruby_prism::CallNode<'_>) -> Option<Vec<u8>> {
    let args = call.arguments()?;
    let first = args.arguments().iter().next()?;
    if let Some(sym) = first.as_symbol_node() {
        return Some(sym.unescaped().to_vec());
    }
    if let Some(s) = first.as_string_node() {
        return Some(s.unescaped().to_vec());
    }
    None
}

/// Walks the entire AST subtree, collecting all receiverless call names.
/// This matches RuboCop's `method_called?` which uses `(send nil? %)` —
/// only method sends without a receiver, NOT local variable reads.
struct IdentifierCollector<'a> {
    names: &'a mut HashSet<Vec<u8>>,
}

impl<'pr> Visit<'pr> for IdentifierCollector<'_> {
    fn visit_call_node(&mut self, node: &ruby_prism::CallNode<'pr>) {
        if node.receiver().is_none() {
            self.names.insert(node.name().as_slice().to_vec());
        }
        ruby_prism::visit_call_node(self, node);
    }
}

/// Returns true for RSpec example methods (it, specify, example, etc.)
/// which define a new scope where let! declarations inside belong to
/// the enclosing example group, not nested further.
fn is_example(name: &[u8]) -> bool {
    matches!(
        name,
        b"it"
            | b"specify"
            | b"example"
            | b"its"
            | b"xit"
            | b"xspecify"
            | b"xexample"
            | b"fit"
            | b"fspecify"
            | b"fexample"
            | b"skip"
            | b"pending"
    )
}

fn is_example_group_or_include(name: &[u8]) -> bool {
    matches!(
        name,
        // ExampleGroups (regular, focused, skipped)
        b"describe"
            | b"context"
            | b"feature"
            | b"example_group"
            | b"xdescribe"
            | b"xcontext"
            | b"xfeature"
            | b"fdescribe"
            | b"fcontext"
            | b"ffeature"
            // SharedGroups
            | b"shared_context"
            | b"shared_examples"
            | b"shared_examples_for"
            // Includes (Examples + Context)
            | b"it_behaves_like"
            | b"it_should_behave_like"
            | b"include_examples"
            | b"include_context"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    crate::cop_fixture_tests!(LetSetup, "cops/rspec/let_setup");
}

RuboCop Ruby Implementation (ground truth)

vendor/rubocop-rspec/lib/rubocop/cop/rspec/let_setup.rb

# frozen_string_literal: true

module RuboCop
  module Cop
    module RSpec
      # Checks unreferenced `let!` calls being used for test setup.
      #
      # @example
      #   # bad
      #   let!(:my_widget) { create(:widget) }
      #
      #   it 'counts widgets' do
      #     expect(Widget.count).to eq(1)
      #   end
      #
      #   # good
      #   it 'counts widgets' do
      #     create(:widget)
      #     expect(Widget.count).to eq(1)
      #   end
      #
      #   # good
      #   before { create(:widget) }
      #
      #   it 'counts widgets' do
      #     expect(Widget.count).to eq(1)
      #   end
      #
      #   # good
      #   describe 'a widget' do
      #     let!(:my_widget) { create(:widget) }
      #     context 'when visiting its page' do
      #       let!(:my_widget) { create(:widget, name: 'Special') }
      #       it 'counts widgets' do
      #         expect(Widget.count).to eq(1)
      #       end
      #     end
      #   end
      #
      class LetSetup < Base
        MSG = 'Do not use `let!` to setup objects not referenced in tests.'

        # @!method example_or_shared_group_or_including?(node)
        def_node_matcher :example_or_shared_group_or_including?, <<~PATTERN
          (block {
            (send #rspec? {#SharedGroups.all #ExampleGroups.all} ...)
            (send nil? #Includes.all ...)
          } ...)
        PATTERN

        # @!method let_bang(node)
        def_node_matcher :let_bang, <<~PATTERN
          {
            (block $(send nil? :let! {(sym $_) (str $_)}) ...)
            $(send nil? :let! {(sym $_) (str $_)} block_pass)
          }
        PATTERN

        # @!method method_called?(node)
        def_node_search :method_called?, '(send nil? %)'

        def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
          return unless example_or_shared_group_or_including?(node)

          unused_let_bang(node) do |let|
            add_offense(let)
          end
        end

        private

        def unused_let_bang(node)
          child_let_bang(node) do |method_send, method_name|
            next if overrides_outer_let_bang?(node, method_name)

            yield(method_send) unless method_called?(node, method_name.to_sym)
          end
        end

        def child_let_bang(node, &block)
          RuboCop::RSpec::ExampleGroup.new(node).lets.each do |let|
            let_bang(let, &block)
          end
        end

        def overrides_outer_let_bang?(node, method_name)
          node.each_ancestor(:block).any? do |ancestor|
            next unless example_or_shared_group_or_including?(ancestor)

            outer_let_bang?(ancestor, method_name)
          end
        end

        def outer_let_bang?(ancestor_node, method_name)
          RuboCop::RSpec::ExampleGroup.new(ancestor_node).lets.any? do |let|
            let_bang(let) do |_send, name|
              name == method_name
            end
          end
        end
      end
    end
  end
end

RuboCop Test Excerpts

vendor/rubocop-rspec/spec/rubocop/cop/rspec/let_setup_spec.rb

  it 'complains when let! is used and not referenced' do

    expect_offense(<<~RUBY)
      describe Foo do
        let!(:foo) { bar }
        ^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.

        it 'does not use foo' do
          expect(baz).to eq(qux)
        end
      end
    RUBY

  it 'ignores let! when used in `before`' do

    expect_no_offenses(<<~RUBY)
      describe Foo do
        let!(:foo) { bar }

        before do
          foo
        end

        it 'does not use foo' do
          expect(baz).to eq(qux)
        end
      end
    RUBY

  it 'ignores let! when used in example' do

    expect_no_offenses(<<~RUBY)
      describe Foo do
        let!(:foo) { bar }

        it 'uses foo' do
          foo
          expect(baz).to eq(qux)
        end
      end
    RUBY

  it 'complains when let! is used and not referenced within nested group' do

    expect_offense(<<~RUBY)
      describe Foo do
        context 'when something special happens' do
          let!(:foo) { bar }
          ^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.

          it 'does not use foo' do
            expect(baz).to eq(qux)
          end
        end

        it 'references some other foo' do
          foo
        end
      end
    RUBY

  it 'complains when let! is used and not referenced in shared example group' do

    expect_offense(<<~RUBY)
      shared_context 'foo' do
        let!(:bar) { baz }
        ^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.

        it 'does not use bar' do
          expect(baz).to eq(qux)
        end
      end
    RUBY

  it 'complains when let! used in shared example including' do

    expect_offense(<<~RUBY)
      describe Foo do
        it_behaves_like 'bar' do
          let!(:baz) { foobar }
          ^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.
          let(:a) { b }
        end
      end
    RUBY

  it 'complains when there is only one nested node into example group' do

    expect_offense(<<~RUBY)
      describe Foo do
        let!(:bar) { baz }
        ^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.
      end
    RUBY

  it 'flags unused helpers defined as strings' do

    expect_offense(<<~RUBY)
      describe Foo do
        let!('bar') { baz }
        ^^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.
      end
    RUBY

  it 'ignores used helpers defined as strings' do

    expect_no_offenses(<<~RUBY)
      describe Foo do
        let!('bar') { baz }
        it { expect(bar).to be_near }
      end
    RUBY

  it 'flags blockpass' do

    expect_offense(<<~RUBY)
      shared_context Foo do |&block|
        let!(:bar, &block)
        ^^^^^^^^^^^^^^^^^^ Do not use `let!` to setup objects not referenced in tests.
      end
    RUBY

Current Fixture: offense.rb

tests/fixtures/cops/rspec/let_setup/offense.rb

describe Foo do
  let!(:foo) { bar }
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

  it 'does not use foo' do
    expect(baz).to eq(qux)
  end
end

describe Foo do
  context 'when something special happens' do
    let!(:foo) { bar }
    ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

    it 'does not use foo' do
      expect(baz).to eq(qux)
    end
  end

  it 'references some other foo' do
    foo
  end
end

describe Foo do
  let!(:bar) { baz }
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.
end

# include_examples block with unused let!
describe Widget do
  include_examples 'shared behavior' do
    let!(:item) { create(:item) }
    ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

    it 'works' do
      expect(true).to be true
    end
  end
end

# include_context block with unused let!
describe Widget do
  include_context 'with setup' do
    let!(:record) { create(:record) }
    ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

    it 'works' do
      expect(true).to be true
    end
  end
end

# RSpec.describe with unused let!
RSpec.describe Widget do
  let!(:item) { create(:item) }
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

  it 'does not use item' do
    expect(true).to be true
  end
end

# Multi-line let! with local variable shadowing the let! name
# The let! body assigns to a local variable with the same name,
# but that internal reference should not count as "used"
describe Widget do
  let!(:order) do
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.
    order = create(:order, user: user)
    order.items << items
    order.save!
    order
  end

  it 'checks count' do
    expect(Order.count).to eq(1)
  end
end

# let! inside a non-example-group block (e.g., iterator)
# RuboCop's ExampleGroup#lets recurses through non-scope-change blocks
describe Widget do
  [1, 2].each do |i|
    let!(:record) { create(:record, position: i) }
    ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

    it 'does not use record' do
      expect(Record.count).to eq(2)
    end
  end
end

# let! with trailing if modifier — should still be detected
describe Widget do
  let!(:projects) { create_list(:project, 3) } if config[:projects]
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

  it 'does something' do
    expect(true).to be true
  end
end

# let! with trailing unless modifier — should still be detected
describe Widget do
  let!(:record) { create(:record) } unless skip_setup
  ^^^^^^^^^^ RSpec/LetSetup: Do not use `let!` to setup objects not referenced in tests.

  it 'does something' do
    expect(true).to be true
  end
end

Current Fixture: no_offense.rb

tests/fixtures/cops/rspec/let_setup/no_offense.rb

describe Foo do
  let!(:foo) { bar }

  before do
    foo
  end

  it 'does not use foo' do
    expect(baz).to eq(qux)
  end
end

describe Foo do
  let!(:foo) { bar }

  it 'uses foo' do
    foo
    expect(baz).to eq(qux)
  end
end

# let! name referenced in a sibling let! body — should not be flagged
describe Widget do
  let!(:user) { create(:user) }
  let!(:post) { create(:post, author: user) }

  it 'creates a post' do
    expect(post).to be_valid
  end
end

# let! that overrides an outer let! — should not be flagged
describe Widget do
  let!(:record) { create(:widget) }

  it 'uses record' do
    expect(record).to be_valid
  end

  context 'when record is nil' do
    let!(:record) { nil }

    it 'handles nil' do
      expect(true).to be true
    end
  end

  context 'when record is special' do
    let!(:record) { create(:widget, special: true) }

    it 'handles special' do
      expect(true).to be true
    end
  end
end

# let! overriding outer let! in deeply nested context
describe Service do
  let!(:user) { create(:user) }

  it 'allows access' do
    expect(user).to be_valid
  end

  context 'when user is admin' do
    context 'and user is blocked' do
      let!(:user) { create(:user, :blocked) }

      it 'denies access' do
        expect(true).to be true
      end
    end
  end
end

# include_examples block with let! that IS referenced
describe Widget do
  include_examples 'shared behavior' do
    let!(:item) { create(:item) }

    it 'uses item' do
      expect(item).to be_valid
    end
  end
end

# include_context block with let! that IS referenced
describe Widget do
  include_context 'with setup' do
    let!(:record) { create(:record) }

    it 'uses record' do
      expect(record).to be_valid
    end
  end
end

# RSpec.describe with let! that IS referenced
RSpec.describe Widget do
  let!(:item) { create(:item) }

  it 'uses item' do
    expect(item).to be_valid
  end
end

# Multi-line let! with local variable shadowing — still referenced externally
describe Widget do
  let!(:order) do
    order = create(:order, user: user)
    order.items << items
    order.save!
    order
  end

  it 'uses order' do
    expect(order).to be_valid
  end
end

# let! with trailing if modifier — referenced, so no offense
describe Widget do
  let!(:projects) { create_list(:project, 3) } if config[:projects]

  it 'uses projects' do
    expect(projects).not_to be_empty
  end
end

@6 6 bot force-pushed the fix/rspec-let_setup-23472126892 branch from d9c3752 to 1fce375 Compare March 24, 2026 04:01
@6 6 bot marked this pull request as ready for review March 24, 2026 04:04
@6 6 bot enabled auto-merge (squash) March 24, 2026 04:04
@6
Copy link
Copy Markdown
Contributor Author

6 bot commented Mar 24, 2026

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

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

Pull request was closed

@6 6 bot deleted the fix/rspec-let_setup-23472126892 branch March 24, 2026 04:04
@6 6 restored the fix/rspec-let_setup-23472126892 branch March 24, 2026 04:09
@6 6 reopened this Mar 24, 2026
@6 6 enabled auto-merge (squash) March 24, 2026 04:10
@6 6 merged commit 5e0a83f into main Mar 24, 2026
7 of 8 checks passed
@6 6 deleted the fix/rspec-let_setup-23472126892 branch March 24, 2026 04:15
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