rubocop-gusto 10.9.4 → 11.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e23aac56c0e7b184c9cc631ba70e8ac0c654aa47ec6e368737c089014d28c6ed
4
- data.tar.gz: 48ef7a1dd5fa1145b5617b42abde18ae11480ec1609e9f9199daebbdde952d66
3
+ metadata.gz: b642214b2077302ad9ab6e076c66534fe49cfcda33a411b87b9c4b34df8f8b43
4
+ data.tar.gz: d9a653b4e27f33467921f5a2ffb520e2cc8b3a65f58e7cb4bb888611cea3e69d
5
5
  SHA512:
6
- metadata.gz: 5c7059ad1293db2d96cba104f20009a3f4cdd7c487c34808264673ac9e65abdb9a60a6859e83d817843674a854ae5423328b719b1146edb9b60aca3296e4dc05
7
- data.tar.gz: f9734a51d5eb38959f52c78e1c22aa028f65789e566c6067f0f38bbef4e76938d4deb5dd727709f1d2586615b93f29bdea58e39c692d38d54a69b8c328cece2f
6
+ metadata.gz: 876ba0b9f58929c28d77c71a370833670b27e0d55ecf8ab937f2882824db8b0da4ba2294a0649f2e54fdf7687b393d6c79be86da874a38d555b4190291c8a36b
7
+ data.tar.gz: fe18184e9f77db188cbbdc77a5274fbe624d664e73ec97a2b5eec130b3753dd6323e5192e98441fe7832d166e2c8980e0d6231494f4d54b0317a9cb18aae9ee5
data/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
3
3
  - Remove redundant `Rails: Enabled: true` from `config/rails.yml` (already set by rubocop-rails' own defaults)
4
4
  - Enable `Rails/DefaultScope` cop (disabled by default in rubocop-rails)
5
5
 
6
+ ## [11.0.0](https://github.com/Gusto/rubocop-gusto/compare/v10.10.0...v11.0.0) (2026-06-08)
7
+
8
+
9
+ ### ⚠ BREAKING CHANGES
10
+
11
+ * rubocop-gusto now requires Ruby >= 3.4.
12
+
13
+ ### Features
14
+
15
+ * add Gusto/DescribedClassConstantReference cop ([#127](https://github.com/Gusto/rubocop-gusto/issues/127)) ([f7cf636](https://github.com/Gusto/rubocop-gusto/commit/f7cf6362f3f522f322251da0fdf5c8affa35bc0f))
16
+ * add Gusto/UnreferencedLet cop (requires Ruby >= 3.4) ([#128](https://github.com/Gusto/rubocop-gusto/issues/128)) ([99a2df7](https://github.com/Gusto/rubocop-gusto/commit/99a2df761b52ce11f4f6bf65a5c8e414153efa53))
17
+
18
+ ## [10.10.0](https://github.com/Gusto/rubocop-gusto/compare/v10.9.4...v10.10.0) (2026-06-01)
19
+
20
+
21
+ ### Features
22
+
23
+ * Set EnforcedShorthandSyntax to 'always' ([#116](https://github.com/Gusto/rubocop-gusto/issues/116)) ([650a8aa](https://github.com/Gusto/rubocop-gusto/commit/650a8aa26ee5af6d7558976dce6df18f053e1925))
24
+
6
25
  ## [10.9.4](https://github.com/Gusto/rubocop-gusto/compare/v10.9.3...v10.9.4) (2026-06-01)
7
26
 
8
27
 
data/config/default.yml CHANGED
@@ -42,6 +42,13 @@ Gusto/DatadogConstant:
42
42
  - '**/spec/**/*'
43
43
  Description: 'Do not call Datadog directly, use an appropriate wrapper library.'
44
44
 
45
+ Gusto/DescribedClassConstantReference:
46
+ Description: 'Flags constants scoped through `described_class` (e.g. `described_class::Foo`), which Sorbet cannot resolve statically.'
47
+ Enabled: true
48
+ SafeAutoCorrect: false
49
+ Include:
50
+ - '**/spec/**/*'
51
+
45
52
  Gusto/DiscouragedGem:
46
53
  Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
47
54
  Enabled: false
@@ -136,6 +143,15 @@ Gusto/ToplevelConstants:
136
143
  - '**/*/spec_helper.rb'
137
144
  - 'spec/support/**/*.rb'
138
145
 
146
+ Gusto/UnreferencedLet:
147
+ Description: 'Removes a lazy let whose name is never referenced (its block never runs).'
148
+ Enabled: true
149
+ Include:
150
+ - '**/spec/**/*_spec.rb'
151
+ # Deletion is unsafe (explicit -A required, never applied on a plain run): the cop is heuristic
152
+ # and cannot see references made across files via shared examples or included harnesses.
153
+ SafeAutoCorrect: false
154
+
139
155
  Gusto/UsePaintNotColorize:
140
156
  Description: 'Use Paint instead of colorize for terminal colors.'
141
157
  SafeAutoCorrect: false
@@ -462,6 +478,7 @@ Style/GuardClause:
462
478
 
463
479
  Style/HashSyntax:
464
480
  EnforcedStyle: ruby19_no_mixed_keys
481
+ EnforcedShorthandSyntax: always
465
482
 
466
483
  Style/HashTransformKeys:
467
484
  Enabled: false
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ # Flags constants that are scoped through `described_class`, e.g.
7
+ # `described_class::Worker`.
8
+ #
9
+ # `described_class` is an RSpec helper method resolved at runtime, so
10
+ # Sorbet's static analysis treats `described_class::Worker` as a dynamic
11
+ # constant reference and cannot resolve it (`Dynamic constant references
12
+ # are unsupported`, https://srb.help/5001). Reference the constant by its
13
+ # fully-qualified name instead. A bare `described_class` (with no `::`
14
+ # constant lookup) is an ordinary method call and is left alone.
15
+ #
16
+ # Autocorrection replaces `described_class` with the constant that the
17
+ # enclosing example group describes. It is marked unsafe
18
+ # (`SafeAutoCorrect: false`) because the rewrite relies on the described
19
+ # constant being a statically-written name; review the result before
20
+ # committing. In particular, a constant defined on an *ancestor* of the
21
+ # described class is qualified against the described class itself, which
22
+ # is correct at runtime but which Sorbet cannot resolve through the
23
+ # inheritance chain -- re-point those to the defining ancestor by hand.
24
+ #
25
+ # @example
26
+ # # bad
27
+ # RSpec.describe Payments::Processor do
28
+ # describe described_class::Worker do
29
+ # end
30
+ # end
31
+ #
32
+ # # good
33
+ # RSpec.describe Payments::Processor do
34
+ # describe Payments::Processor::Worker do
35
+ # end
36
+ # end
37
+ #
38
+ # # good - `RSpec.describe self` resolves to the enclosing namespace
39
+ # module Payments
40
+ # RSpec.describe self do
41
+ # it { expect(Payments::TIMEOUT).to eq(5) }
42
+ # end
43
+ # end
44
+ #
45
+ # # good - a bare `described_class` is not a constant reference
46
+ # RSpec.describe Payments::Processor do
47
+ # subject { described_class.new }
48
+ # end
49
+ class DescribedClassConstantReference < Base
50
+ extend AutoCorrector
51
+
52
+ MSG = "Use the fully-qualified constant name instead of scoping it through " \
53
+ "`described_class`, which Sorbet cannot resolve statically."
54
+
55
+ # A constant whose scope is a no-receiver `described_class`, e.g.
56
+ # `described_class::Worker`.
57
+ # @!method const_scoped_on_described_class?(node)
58
+ def_node_matcher :const_scoped_on_described_class?, <<~PATTERN
59
+ (const (send nil? :described_class) _)
60
+ PATTERN
61
+
62
+ # An example group, capturing its first argument: a constant
63
+ # (`RSpec.describe Foo do`, `context Foo do`), `self`
64
+ # (`RSpec.describe self do`), and so on.
65
+ # @!method example_group_described_argument(node)
66
+ def_node_matcher :example_group_described_argument, <<~PATTERN
67
+ (block
68
+ (send {(const nil? :RSpec) nil?}
69
+ {:describe :xdescribe :fdescribe :context :xcontext :fcontext :feature :example_group}
70
+ $_ ...)
71
+ ...)
72
+ PATTERN
73
+
74
+ # Whether a node routes through a no-receiver `described_class`.
75
+ # @!method scoped_through_described_class?(node)
76
+ def_node_search :scoped_through_described_class?, <<~PATTERN
77
+ (send nil? :described_class)
78
+ PATTERN
79
+
80
+ def on_const(node)
81
+ return unless const_scoped_on_described_class?(node)
82
+
83
+ scope = node.children[0]
84
+ add_offense(scope) do |corrector|
85
+ replacement = described_class_replacement(node)
86
+ corrector.replace(scope, replacement) if replacement
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # The fully-qualified name (as a String) that `described_class` resolves
93
+ # to lexically, from the nearest enclosing example group, or nil if it
94
+ # cannot be determined statically.
95
+ #
96
+ # - `describe SomeClass` resolves to that constant's written name.
97
+ # - `describe self` resolves to the enclosing module/class namespace.
98
+ # - `describe described_class::X` qualifies the describe argument itself
99
+ # against the outer group; a reference in such a group's *body* resolves
100
+ # at runtime to the scoped (statically unknown) class, so we decline to
101
+ # autocorrect it. Once the enclosing `described_class::X` is rewritten,
102
+ # a later pass resolves the body reference correctly.
103
+ # - Any other describe argument (e.g. a string) is skipped, and the
104
+ # search continues at the next enclosing example group.
105
+ def described_class_replacement(node)
106
+ node.each_ancestor(:block) do |block_node|
107
+ described_argument = example_group_described_argument(block_node)
108
+ next if described_argument.nil?
109
+
110
+ if described_argument.self_type?
111
+ namespace = enclosing_namespace(block_node)
112
+ return namespace if namespace
113
+ elsif described_argument.const_type?
114
+ return described_argument.source unless scoped_through_described_class?(described_argument)
115
+ return nil unless reference_within_described_constant?(described_argument, node)
116
+ end
117
+ end
118
+ nil
119
+ end
120
+
121
+ # The fully-qualified name of the module/class lexically enclosing the
122
+ # example group, which is what `self` refers to in `RSpec.describe self`.
123
+ def enclosing_namespace(block_node)
124
+ names = block_node.each_ancestor(:class, :module).map { |mod| mod.children.first.source }
125
+ return if names.empty?
126
+
127
+ names.reverse.join("::")
128
+ end
129
+
130
+ # Whether the offending constant is the described constant itself (the
131
+ # describe argument) rather than a reference inside the group's body.
132
+ def reference_within_described_constant?(described_constant, node)
133
+ node.equal?(described_constant) ||
134
+ described_constant.each_descendant(:const).any? { |const_node| const_node.equal?(node) }
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -34,7 +34,7 @@ module RuboCop
34
34
  end
35
35
 
36
36
  def message_for(gem)
37
- format(MSG, gem: gem, advice: advice_for(gem))
37
+ format(MSG, gem:, advice: advice_for(gem))
38
38
  end
39
39
 
40
40
  def advice_for(gem)
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Gusto
8
+ # Flags lazy `let` declarations whose name is never referenced. A lazy `let(:name) { ... }`
9
+ # is only evaluated when `name` is called, so an unreferenced one is dead code -- its block
10
+ # never runs -- and is deleted.
11
+ #
12
+ # Eager `let!` is intentionally out of scope: it runs its block before every example for its
13
+ # side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is
14
+ # handled here.
15
+ #
16
+ # Detection is file-scoped: a `let` referenced only from another file (through a shared
17
+ # example or an included test harness) cannot be seen, so the cop stays conservative and
18
+ # prefers false negatives over false positives:
19
+ # - a name defined more than once in the file by `let`/`let!`/`subject` (an override /
20
+ # `super` chain, including a `subject` that overrides a `let` of the same name) is never
21
+ # flagged;
22
+ # - a `let` declared lexically inside a `shared_examples` / `shared_examples_for` /
23
+ # `shared_context` block is skipped (its consumers live in other files);
24
+ # - every `let` in a file that uses `it_behaves_like` / `it_should_behave_like` /
25
+ # `include_examples` / `include_context` is skipped, because an included shared block may
26
+ # reference the binding by a name we cannot follow statically;
27
+ # - any `let` whose name is also defined as a `let`/`subject` in a `spec/support/**` helper is
28
+ # skipped, because it is almost certainly overriding a contract an included harness consumes;
29
+ # - `let(:cop_config)` is skipped: it is a rubocop-rspec contract consumed by the `:config`
30
+ # shared context, not by a reference in the spec file; and
31
+ # - every `let` in a file that reflectively dispatches through a name we cannot resolve
32
+ # statically (e.g. `send("expected_#{type}")`) is skipped, since any `let` could be the
33
+ # target.
34
+ # A name counts as referenced if it is called bare (`foo`), appears as a symbol (`:foo`)
35
+ # anywhere but the let's own name argument, or appears as an identifier-shaped token inside
36
+ # any string/heredoc literal -- covering dynamic dispatch, `:foo` entries in data tables the
37
+ # spec later dispatches on, and bindings named only inside raw SQL/GraphQL text.
38
+ #
39
+ # Because a bare `:foo` symbol anywhere counts as a reference, commonly-named lets
40
+ # (`let(:user)`, `let(:company)`, `let(:id)`) are essentially never flagged -- `create(:user)`,
41
+ # `:name` hash keys, and the like saturate the file. This conservative bias means the cop
42
+ # realistically only deletes distinctively-named dead lets; it is not a complete dead-`let`
43
+ # finder.
44
+ #
45
+ # @example
46
+ # # bad (name never referenced -- deleted, the block never runs)
47
+ # let(:unused) { create(:thing) }
48
+ #
49
+ # # good
50
+ # let(:thing) { create(:thing) }
51
+ # it { expect(thing).to be_present }
52
+ #
53
+ class UnreferencedLet < ::RuboCop::Cop::RSpec::Base
54
+ extend AutoCorrector
55
+ include RangeHelp
56
+
57
+ DEFINITION_METHODS = %i(let let! subject).freeze
58
+ # `let`s consumed by a test framework rather than by a reference in the spec file. The
59
+ # rubocop-rspec `:config` shared context reads `cop_config`, so it is live even though the
60
+ # spec never names it.
61
+ FRAMEWORK_RESERVED_NAMES = %i(cop_config).freeze
62
+ # Reflective dispatch methods whose target is the first argument. When that argument is not
63
+ # a statically-resolvable name (a `sym` or plain `str`) -- e.g. `send("expected_#{type}")` --
64
+ # the called name cannot be known, so the whole file is left untouched.
65
+ DYNAMIC_DISPATCH_METHODS = %i(send public_send __send__ try try! method public_method respond_to?).freeze
66
+ FRAMEWORK_LET_PATTERN = /\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
67
+ # Identifier-shaped tokens inside a string/heredoc literal. A `let` whose name appears only
68
+ # inside string text -- e.g. a binding or column referenced in raw SQL/GraphQL the spec
69
+ # later executes -- counts as referenced, so it is not deleted.
70
+ IDENTIFIER_IN_STRING = /[A-Za-z_]\w*[!?]?/
71
+ MSG = "Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
72
+ RESTRICT_ON_SEND = %i(let).freeze
73
+ # The glob and the pathspec encode the SAME set of files two ways: `Dir.glob` (fallback) and
74
+ # a regexp filter over `git ls-files` output. Keep them in sync if either changes.
75
+ SUPPORT_FILES_GLOB = "**/spec/support/**/*.rb"
76
+ SUPPORT_FILES_PATHSPEC = %r{(?:\A|/)spec/support/.+\.rb\z}
77
+
78
+ # The name symbol of any definition (`let`/`let!`/`subject`) in any block form -- used to
79
+ # count how many times a name is defined, so override / `super` chains (including a
80
+ # `subject` that overrides a `let` of the same name) are never flagged.
81
+ # @!method definition_name(node)
82
+ def_node_matcher :definition_name, <<~PATTERN
83
+ (any_block (send nil? {#{DEFINITION_METHODS.map { ":#{it}" }.join(' ')}} (sym $_) ...) ...)
84
+ PATTERN
85
+
86
+ class << self
87
+ # Names defined as `let`/`subject` anywhere under `spec/support/**`. Computed once per
88
+ # process (lazily, after boot) and shared across every file the cop inspects.
89
+ def framework_let_names
90
+ @framework_let_names ||= scan_framework_let_names(support_file_paths)
91
+ end
92
+
93
+ # Enumerate `spec/support/**/*.rb`. Prefer `git ls-files` (reads the git index, skipping
94
+ # untracked trees like `node_modules`): a leading-`**` `Dir.glob` walks the entire
95
+ # repository and costs seconds, while reading the index costs tens of milliseconds. Fall
96
+ # back to `Dir.glob` when not in a git work tree or `git` is unavailable.
97
+ #
98
+ # Tradeoff: an untracked (brand-new, uncommitted) `spec/support/*.rb` override is invisible
99
+ # to `git ls-files`. In that narrow window its contract names are not exempted; once
100
+ # committed it is seen like any other support file.
101
+ def support_file_paths
102
+ git_tracked_support_files || ::Dir.glob(SUPPORT_FILES_GLOB)
103
+ end
104
+
105
+ def git_tracked_support_files
106
+ output, status = ::Open3.capture2("git", "ls-files", "-z")
107
+ return nil unless status.success?
108
+
109
+ output.split("\x0").grep(SUPPORT_FILES_PATHSPEC)
110
+ rescue ::SystemCallError
111
+ nil
112
+ end
113
+
114
+ def scan_framework_let_names(paths)
115
+ paths.each_with_object(Set.new) do |path, names|
116
+ extract_let_names(read_source(path), names)
117
+ end
118
+ end
119
+
120
+ def extract_let_names(source, names)
121
+ source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym }
122
+ names
123
+ end
124
+
125
+ def read_source(path)
126
+ return "" unless ::File.file?(path)
127
+
128
+ ::File.read(path)
129
+ end
130
+ end
131
+
132
+ def on_send(node)
133
+ return unless node.receiver.nil?
134
+
135
+ name_argument = node.first_argument
136
+ return unless name_argument&.sym_type?
137
+
138
+ block = node.block_node
139
+ return unless block
140
+
141
+ name = name_argument.value
142
+ return if exempt_from_deletion?(name, block)
143
+
144
+ add_offense(node.loc.selector, message: format(MSG, name:)) do |corrector|
145
+ corrector.remove(removal_range(block))
146
+ end
147
+ end
148
+
149
+ private
150
+
151
+ # A lazy `let` is exempt from deletion whenever file-scoped analysis cannot prove its name
152
+ # is dead: its name is a framework-reserved contract (e.g. `cop_config`), the file
153
+ # dispatches through a name we cannot resolve statically, it consumes shared examples, the
154
+ # `let` is lexically inside a shared-example definition, its name is a `spec/support/**`
155
+ # framework contract, it is overridden by another definition of the same name, or it is
156
+ # referenced somewhere in the file.
157
+ def exempt_from_deletion?(name, block)
158
+ FRAMEWORK_RESERVED_NAMES.include?(name) ||
159
+ dynamic_dispatch? ||
160
+ consumes_shared_examples? ||
161
+ within_shared_definition?(block) ||
162
+ self.class.framework_let_names.include?(name) ||
163
+ overridden?(name) ||
164
+ referenced?(name)
165
+ end
166
+
167
+ # Delete the `let` block, plus:
168
+ # - an immediately-preceding `sig { ... }` (so a Sorbet signature is not left dangling),
169
+ # - explanatory comment lines attached directly above it (so they are not orphaned), and
170
+ # - a single trailing blank line where removal would otherwise leave a stray/duplicate
171
+ # blank -- unless the line above is a `let`/`subject`, where that blank is the required
172
+ # separator after the now-final let and must stay.
173
+ def removal_range(node)
174
+ lines = processed_source.lines
175
+ start_line = node.source_range.first_line
176
+ end_line = node.source_range.last_line
177
+
178
+ sig = preceding_sig(node)
179
+ start_line = sig.source_range.first_line if sig
180
+
181
+ start_line -= 1 while start_line > 1 && absorbable_comment?(lines[start_line - 2])
182
+
183
+ if end_line < lines.size && blank_line?(lines[end_line]) &&
184
+ !(start_line > 1 && let_or_subject_line?(lines[start_line - 2]))
185
+ end_line += 1
186
+ end
187
+
188
+ buffer = processed_source.buffer
189
+ range_by_whole_lines(buffer.line_range(start_line).join(buffer.line_range(end_line)), include_final_newline: true)
190
+ end
191
+
192
+ def absorbable_comment?(source_line)
193
+ stripped = source_line.strip
194
+ stripped.start_with?("#") && !stripped.start_with?("# rubocop:")
195
+ end
196
+
197
+ def blank_line?(source_line)
198
+ source_line.strip.empty?
199
+ end
200
+
201
+ def let_or_subject_line?(source_line)
202
+ source_line.match?(/\A\s*(?:let!?|subject)\b/)
203
+ end
204
+
205
+ def preceding_sig(node)
206
+ sibling = node.left_sibling
207
+ return unless sibling.is_a?(::RuboCop::AST::BlockNode)
208
+ return unless sibling.method?(:sig)
209
+
210
+ sibling
211
+ end
212
+
213
+ def within_shared_definition?(node)
214
+ node.each_ancestor(:any_block).any? { |ancestor| shared_group?(ancestor) }
215
+ end
216
+
217
+ def consumes_shared_examples?
218
+ return @consumes_shared_examples unless @consumes_shared_examples.nil?
219
+
220
+ @consumes_shared_examples = processed_source.ast.each_node(:call).any? { |send_node| include?(send_node) }
221
+ end
222
+
223
+ # True when the file reflectively dispatches through a name we cannot resolve statically --
224
+ # `send`/`public_send`/`method`/etc. called with anything other than a `sym` or plain `str`
225
+ # first argument (most commonly an interpolated string, `send("expected_#{type}")`). In
226
+ # that case any `let` in the file could be the dispatch target, so none are deleted.
227
+ def dynamic_dispatch?
228
+ return @dynamic_dispatch unless @dynamic_dispatch.nil?
229
+
230
+ @dynamic_dispatch = processed_source.ast.each_node(:call).any? do |send_node|
231
+ next false unless DYNAMIC_DISPATCH_METHODS.include?(send_node.method_name)
232
+
233
+ target = send_node.first_argument
234
+ target && !target.sym_type? && !target.str_type?
235
+ end
236
+ end
237
+
238
+ def overridden?(name)
239
+ definitions_by_name.fetch(name, 0) > 1
240
+ end
241
+
242
+ def definitions_by_name
243
+ @definitions_by_name ||= processed_source.ast.each_node(:any_block).each_with_object(Hash.new(0)) do |node, counts|
244
+ name = definition_name(node)
245
+ counts[name] += 1 if name
246
+ end
247
+ end
248
+
249
+ def referenced?(name)
250
+ referenced_names.include?(name)
251
+ end
252
+
253
+ # A name is "referenced" if it is called as a bare method (`foo`), appears as a symbol
254
+ # literal (`:foo`) other than the let/subject's own name argument, or appears as an
255
+ # identifier-shaped token inside any string/heredoc literal. The symbol and string cases
256
+ # cover indirect invocation -- `send(:foo)` / `send("foo")`, a `:foo`/`"foo"` listed in a
257
+ # data table the spec later dispatches on, or a binding named only inside raw SQL/GraphQL
258
+ # text the spec executes -- which file-scoped analysis cannot otherwise follow. (Tokenizing
259
+ # string bodies, rather than matching the whole string, keeps a `let` referenced only from
260
+ # inside a multi-word heredoc from being deleted.) Interpolated-string *dispatch* is handled
261
+ # separately by `dynamic_dispatch?`, which exempts the whole file.
262
+ def referenced_names
263
+ @referenced_names ||= processed_source.ast.each_node(:sym, :str, :call).each_with_object(Set.new) do |node, names|
264
+ if node.sym_type?
265
+ names << node.value unless definition_name_argument?(node)
266
+ elsif node.str_type?
267
+ # A string with invalid encoding (e.g. a deliberate bad-UTF-8 test fixture) cannot
268
+ # contain an identifier-shaped reference and would raise on `scan`, so skip it.
269
+ node.value.scan(IDENTIFIER_IN_STRING) { |token| names << token.to_sym } if node.value.valid_encoding?
270
+ elsif node.receiver.nil? && node.arguments.empty?
271
+ names << node.method_name
272
+ end
273
+ end
274
+ end
275
+
276
+ def definition_name_argument?(sym_node)
277
+ parent = sym_node.parent
278
+ parent.send_type? && parent.receiver.nil? && DEFINITION_METHODS.include?(parent.method_name)
279
+ end
280
+ end
281
+ end
282
+ end
283
+ end
@@ -122,9 +122,9 @@ module RuboCop
122
122
 
123
123
  def add_offense_for_header(node, key_value)
124
124
  downcased = key_value.downcase
125
- message = format(MSG, downcased: downcased, original: key_value)
125
+ message = format(MSG, downcased:, original: key_value)
126
126
 
127
- add_offense(node, message: message) do |corrector|
127
+ add_offense(node, message:) do |corrector|
128
128
  corrector.replace(node, "'#{downcased}'")
129
129
  end
130
130
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Gusto
5
- VERSION = "10.9.4"
5
+ VERSION = "11.0.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-gusto
3
3
  version: !ruby/object:Gem::Version
4
- version: 10.9.4
4
+ version: 11.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineering
@@ -125,6 +125,7 @@ files:
125
125
  - lib/rubocop-gusto.rb
126
126
  - lib/rubocop/cop/gusto/bootsnap_load_file.rb
127
127
  - lib/rubocop/cop/gusto/datadog_constant.rb
128
+ - lib/rubocop/cop/gusto/described_class_constant_reference.rb
128
129
  - lib/rubocop/cop/gusto/discouraged_gem.rb
129
130
  - lib/rubocop/cop/gusto/execute_migration.rb
130
131
  - lib/rubocop/cop/gusto/factory_classes_or_modules.rb
@@ -143,6 +144,7 @@ files:
143
144
  - lib/rubocop/cop/gusto/rspec_date_time_mock.rb
144
145
  - lib/rubocop/cop/gusto/sidekiq_params.rb
145
146
  - lib/rubocop/cop/gusto/toplevel_constants.rb
147
+ - lib/rubocop/cop/gusto/unreferenced_let.rb
146
148
  - lib/rubocop/cop/gusto/use_paint_not_colorize.rb
147
149
  - lib/rubocop/cop/gusto/vcr_recordings.rb
148
150
  - lib/rubocop/cop/internal_affairs/assignment_first.rb
@@ -169,7 +171,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
169
171
  requirements:
170
172
  - - ">="
171
173
  - !ruby/object:Gem::Version
172
- version: '3.2'
174
+ version: '3.4'
173
175
  required_rubygems_version: !ruby/object:Gem::Requirement
174
176
  requirements:
175
177
  - - ">="