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 =
Set[:let, :let!, :subject].freeze
FRAMEWORK_RESERVED_NAMES =

lets 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



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

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.



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

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

.git_tracked_support_filesObject



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

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



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

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

  ::File.read(path, encoding: "UTF-8")
end

.scan_framework_let_names(paths) ⇒ Object



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

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.



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

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.



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

def_node_matcher :definition_name, <<~PATTERN
  (any_block (send nil? %DEFINITION_METHODS (sym $_) ...) ...)
PATTERN

#on_send(node) ⇒ Object



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

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