Class: RuboCop::Cop::Gusto::UnreferencedLet

Inherits:
RSpec::Base
  • Object
show all
Extended by:
AutoCorrector
Includes:
RangeHelp
Defined in:
lib/rubocop/cop/gusto/unreferenced_let.rb

Overview

Flags lazy ‘let` declarations whose name is never referenced. A lazy `let(:name) { … }` is only evaluated when `name` is called, so an unreferenced one is dead code – its block never runs – and is deleted.

Eager ‘let!` is intentionally out of scope: it runs its block before every example for its side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is handled here.

Detection is file-scoped: a ‘let` referenced only from another file (through a shared example or an included test harness) cannot be seen, so the cop stays conservative and prefers false negatives over false positives:

  • a name defined more than once in the file by ‘let`/`let!`/`subject` (an override / `super` chain, including a `subject` that overrides a `let` of the same name) is never flagged;

  • a ‘let` declared lexically inside a `shared_examples` / `shared_examples_for` / `shared_context` block is skipped (its consumers live in other files);

  • every ‘let` in a file that uses `it_behaves_like` / `it_should_behave_like` / `include_examples` / `include_context` is skipped, because an included shared block may reference the binding by a name we cannot follow statically;

  • any ‘let` whose name is also defined as a `let`/`subject` in a `spec/support/**` helper is skipped, because it is almost certainly overriding a contract an included harness consumes;

  • ‘let(:cop_config)` is skipped: it is a rubocop-rspec contract consumed by the `:config` shared context, not by a reference in the spec file; and

  • every ‘let` in a file that reflectively dispatches through a name we cannot resolve statically (e.g. `send(“expected_#type”)`) is skipped, since any `let` could be the target.

A name counts as referenced if it is called bare (‘foo`), appears as a symbol (`:foo`) anywhere but the let’s own name argument, or appears as an identifier-shaped token inside any string/heredoc literal – covering dynamic dispatch, ‘:foo` entries in data tables the spec later dispatches on, and bindings named only inside raw SQL/GraphQL text.

Because a bare ‘:foo` symbol anywhere counts as a reference, commonly-named lets (`let(:user)`, `let(:company)`, `let(:id)`) are essentially never flagged – `create(:user)`, `:name` hash keys, and the like saturate the file. This conservative bias means the cop realistically only deletes distinctively-named dead lets; it is not a complete dead-`let` finder.

Examples:

# bad (name never referenced -- deleted, the block never runs)
let(:unused) { create(:thing) }

# good
let(:thing) { create(:thing) }
it { expect(thing).to be_present }

Constant Summary collapse

DEFINITION_METHODS =
%i(let let! subject).freeze
FRAMEWORK_RESERVED_NAMES =

‘let`s consumed by a test framework rather than by a reference in the spec file. The rubocop-rspec `:config` shared context reads `cop_config`, so it is live even though the spec never names it.

%i(cop_config).freeze
DYNAMIC_DISPATCH_METHODS =

Reflective dispatch methods whose target is the first argument. When that argument is not a statically-resolvable name (a ‘sym` or plain `str`) – e.g. `send(“expected_#type”)` – the called name cannot be known, so the whole file is left untouched.

%i(send public_send __send__ try try! method public_method respond_to?).freeze
FRAMEWORK_LET_PATTERN =
/\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
IDENTIFIER_IN_STRING =

Identifier-shaped tokens inside a string/heredoc literal. A ‘let` whose name appears only inside string text – e.g. a binding or column referenced in raw SQL/GraphQL the spec later executes – counts as referenced, so it is not deleted.

/[A-Za-z_]\w*[!?]?/
MSG =
"Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
RESTRICT_ON_SEND =
%i(let).freeze
SUPPORT_FILES_GLOB =

The glob and the pathspec encode the SAME set of files two ways: ‘Dir.glob` (fallback) and a regexp filter over `git ls-files` output. Keep them in sync if either changes.

"**/spec/support/**/*.rb"
SUPPORT_FILES_PATHSPEC =
%r{(?:\A|/)spec/support/.+\.rb\z}

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.extract_let_names(source, names) ⇒ Object



120
121
122
123
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 120

def extract_let_names(source, names)
  source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym }
  names
end

.framework_let_namesObject

Names defined as ‘let`/`subject` anywhere under `spec/support/**`. Computed once per process (lazily, after boot) and shared across every file the cop inspects.



89
90
91
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 89

def framework_let_names
  @framework_let_names ||= scan_framework_let_names(support_file_paths)
end

.git_tracked_support_filesObject



105
106
107
108
109
110
111
112
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 105

def git_tracked_support_files
  output, status = ::Open3.capture2("git", "ls-files", "-z")
  return nil unless status.success?

  output.split("\x0").grep(SUPPORT_FILES_PATHSPEC)
rescue ::SystemCallError
  nil
end

.read_source(path) ⇒ Object



125
126
127
128
129
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 125

def read_source(path)
  return "" unless ::File.file?(path)

  ::File.read(path)
end

.scan_framework_let_names(paths) ⇒ Object



114
115
116
117
118
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 114

def scan_framework_let_names(paths)
  paths.each_with_object(Set.new) do |path, names|
    extract_let_names(read_source(path), names)
  end
end

.support_file_pathsObject

Enumerate ‘spec/support/*/.rb`. Prefer `git ls-files` (reads the git index, skipping untracked trees like `node_modules`): a leading-`**` `Dir.glob` walks the entire repository and costs seconds, while reading the index costs tens of milliseconds. Fall back to `Dir.glob` when not in a git work tree or `git` is unavailable.

Tradeoff: an untracked (brand-new, uncommitted) ‘spec/support/*.rb` override is invisible to `git ls-files`. In that narrow window its contract names are not exempted; once committed it is seen like any other support file.



101
102
103
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 101

def support_file_paths
  git_tracked_support_files || ::Dir.glob(SUPPORT_FILES_GLOB)
end

Instance Method Details

#definition_name(node) ⇒ Object

The name symbol of any definition (‘let`/`let!`/`subject`) in any block form – used to count how many times a name is defined, so override / `super` chains (including a `subject` that overrides a `let` of the same name) are never flagged.



82
83
84
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 82

def_node_matcher :definition_name, <<~PATTERN
  (any_block (send nil? {#{DEFINITION_METHODS.map { ":#{it}" }.join(' ')}} (sym $_) ...) ...)
PATTERN

#on_send(node) ⇒ Object



132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
# File 'lib/rubocop/cop/gusto/unreferenced_let.rb', line 132

def on_send(node)
  return unless node.receiver.nil?

  name_argument = node.first_argument
  return unless name_argument&.sym_type?

  block = node.block_node
  return unless block

  name = name_argument.value
  return if exempt_from_deletion?(name, block)

  add_offense(node.loc.selector, message: format(MSG, name:)) do |corrector|
    corrector.remove(removal_range(block))
  end
end