rubocop-gusto 11.9.1 → 11.10.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: 5581a398e181fa5cee526273621f36357e83dba9fe912433df5a4cbe2129b2e7
4
- data.tar.gz: db3825a6506974aa565e427d2e83fa1cbb618b31136c4bdbbb13840e81dd1eb1
3
+ metadata.gz: 389e8e8a7531fb3ad32c281f658bc51fa59c0c65707599089e7c45f0b8cddf19
4
+ data.tar.gz: 3c9654b75327dc9420e107dc43fff4ac94b32586639f9a925794c98eb95d4e0d
5
5
  SHA512:
6
- metadata.gz: '089fac7ad4fea249760ba0d5138e78a42152f198796b7f920d78ee656ba5cfb4d56b66a20918a90488431534b497110b366a66f467703c22797d5d4a51a94aba'
7
- data.tar.gz: 681ef77cd2115bac46da1f0713adfa9fd05ed77204cec941c47cbe7a45d30b62d2b3128997665567923eaaa6509464f10c78867869fed7aed34d9b7bc3870f21
6
+ metadata.gz: 5d116ac3f0047391c0d9e6fc17fab5849ce6a67bcd1681ef5050c9dcf501801fe97ad6f59f3a7f31f505babe20a718e2b6ff61a7fadfd306fdc4073366ad6db5
7
+ data.tar.gz: 7ab7f1cebafc19303cadff84e8091b8f52f196edb9cac54add0c2acc8719bf6420a5a8f7a2fbe4aaeae13d95747c72a16c8a5e1e1380004328e8e05f4f3916b5
data/CHANGELOG.md CHANGED
@@ -3,9 +3,22 @@
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.10.0](https://github.com/Gusto/rubocop-gusto/compare/v11.9.1...v11.10.0) (2026-10-22)
7
+
8
+
9
+ ### Features
10
+
11
+ * add Gusto/TimeoutTimeout cop ([#195](https://github.com/Gusto/rubocop-gusto/issues/195)) ([ef28938](https://github.com/Gusto/rubocop-gusto/commit/ef28938194582f3e7f804694a6a9fa7f6b005b99))
12
+ * per-group scoping for Gusto/UnreferencedLet ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
13
+ * per-group UnreferencedLet scoping ([#187](https://github.com/Gusto/rubocop-gusto/issues/187)) ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
14
+
6
15
  ## [11.9.1](https://github.com/Gusto/rubocop-gusto/compare/v11.9.0...v11.9.1) (2026-09-28)
7
16
 
8
17
 
18
+ ### Features
19
+
20
+ * flag transitive discouraged gems via the lockfile and discourage ostruct ([#188](https://github.com/Gusto/rubocop-gusto/issues/188)) ([3fb0a2c](https://github.com/Gusto/rubocop-gusto/commit/3fb0a2c825777d768c4d2d92b8504eae44723d5a))
21
+
9
22
  ### Bug Fixes
10
23
 
11
24
  * port upstream review of Gusto/HardcodedAbsentRecordId ([#180](https://github.com/Gusto/rubocop-gusto/issues/180)) ([6198928](https://github.com/Gusto/rubocop-gusto/commit/6198928187db6920c18c882c3167a1cf8edeccf1))
@@ -198,6 +198,11 @@ Gusto/SuddenAssociations:
198
198
  Exclude:
199
199
  - '**/spec/**/*' # allow stubbing, although this is still odd
200
200
 
201
+ Gusto/TimeoutTimeout:
202
+ Description: 'Flags `Timeout.timeout`, which interrupts its block with `Thread#raise` at an arbitrary point.'
203
+ Exclude:
204
+ - '**/spec/**/*'
205
+
201
206
  Gusto/ToplevelConstants:
202
207
  Description: 'Prevents top-level constants from being defined outside of initializers.'
203
208
  Include:
@@ -220,6 +225,9 @@ Gusto/UnreferencedLet:
220
225
  Include:
221
226
  - '**/spec/**/*_spec.rb'
222
227
  SafeAutoCorrect: false
228
+ PerExampleGroup: false
229
+ Cascade: false
230
+ LocalContextIncludes: []
223
231
 
224
232
  Gusto/UsePaintNotColorize:
225
233
  Description: 'Use Paint instead of colorize for terminal colors.'
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ # Flags calls to `Timeout.timeout`.
7
+ #
8
+ # `Timeout.timeout` stops its block by calling `Thread#raise` from a
9
+ # watcher thread, so the exception can land on any line of the block,
10
+ # including inside an `ensure` clause or halfway through a database write
11
+ # or a connection checkout. That can leak locks, return a half-read socket
12
+ # to a pool, or leave records partially updated. There is no safe way to
13
+ # interrupt an arbitrary block of code; see
14
+ # https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
15
+ #
16
+ # Instead, use the timeout the waiting operation itself supports, which
17
+ # fails at a known point: `Net::HTTP#read_timeout`, a database
18
+ # `statement_timeout`, `IO#timeout=`, `Queue#pop(timeout:)`, or a deadline
19
+ # checked with `Process.clock_gettime(Process::CLOCK_MONOTONIC)`.
20
+ #
21
+ # `Timeout` is a constant, so it is never nil and safe navigation
22
+ # (`Timeout&.timeout`) is never used; the cop only handles `on_send`.
23
+ #
24
+ # @example
25
+ # # bad
26
+ # Timeout.timeout(5) { http.get(path) }
27
+ # ::Timeout.timeout(5, MyError) { do_work }
28
+ #
29
+ # # good
30
+ # http.read_timeout = 5
31
+ # http.get(path)
32
+ class TimeoutTimeout < Base
33
+ MSG = "Avoid `Timeout.timeout`; it interrupts the block with `Thread#raise` at an arbitrary point. " \
34
+ "Use the timeout option of the operation being waited on instead, " \
35
+ "such as `Net::HTTP#read_timeout` or a database `statement_timeout`."
36
+ RESTRICT_ON_SEND = %i(timeout).freeze
37
+
38
+ # @!method timeout_timeout?(node)
39
+ def_node_matcher :timeout_timeout?, <<~PATTERN
40
+ (send (const {nil? cbase} :Timeout) :timeout ...)
41
+ PATTERN
42
+
43
+ def on_send(node)
44
+ add_offense(node) if timeout_timeout?(node)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -13,8 +13,8 @@ module RuboCop
13
13
  # side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is
14
14
  # handled here.
15
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
16
+ # By default detection is file-scoped: a `let` referenced only from another file (through a
17
+ # shared example or an included test harness) cannot be seen, so the cop stays conservative and
18
18
  # prefers false negatives over false positives:
19
19
  # - a name defined more than once in the file by `let`/`let!`/`subject` (an override /
20
20
  # `super` chain, including a `subject` that overrides a `let` of the same name) is never
@@ -42,6 +42,23 @@ module RuboCop
42
42
  # realistically only deletes distinctively-named dead lets; it is not a complete dead-`let`
43
43
  # finder.
44
44
  #
45
+ # `PerExampleGroup: true` scopes detection to example groups instead. A `let` is live only where
46
+ # code running in its group could read it: a reference in its own group, an enclosing group or
47
+ # a nested one, never a sibling. A reference is a bare call or a call on the example itself
48
+ # (`self.x`, `->(example) { example.x }`); hash keys and factory, stub and lookup arguments are
49
+ # not. Each definition of an overridden name is judged on its own, and a `let!` reads its own
50
+ # name. Shared groups and helper methods defined under `spec/support/**` (and in the
51
+ # `spec/*_helper.rb` files beside it) are resolved, following the groups and helpers they use
52
+ # in turn, so an include or helper call protects only the names that code could read. Harnesses
53
+ # `config` registers by metadata (`include_context`, `include`, hooks) count where the lineage's
54
+ # metadata, the type inferred from the directory, or metadata derived for the file selects
55
+ # them. A reflective call reads the names its target could hold: a pattern for an
56
+ # interpolated name. Anything the cop cannot resolve still protects every `let` in its lineage.
57
+ # `LocalContextIncludes` names include methods that evaluate a sibling `context.rb` (or
58
+ # `<name>_context.rb`), which is then resolved the same way.
59
+ #
60
+ # `Cascade: true` also flags a `let` read only from unreferenced `let`s (including its own block).
61
+ #
45
62
  # @example
46
63
  # # bad (name never referenced -- deleted, the block never runs)
47
64
  # let(:unused) { create(:thing) }
@@ -64,13 +81,57 @@ module RuboCop
64
81
  # the called name cannot be known, so the whole file is left untouched.
65
82
  DYNAMIC_DISPATCH_METHODS = %i(send public_send __send__ try try! method public_method respond_to?).freeze
66
83
  FRAMEWORK_LET_PATTERN = /\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
84
+ HOOK_METHODS = %i(before after around prepend_before append_before prepend_after append_after).freeze
85
+ HOOK_SCOPES = %i(each example all context suite).freeze
67
86
  # Identifier-shaped tokens inside a string/heredoc literal. A `let` whose name appears only
68
87
  # inside string text -- e.g. a binding or column referenced in raw SQL/GraphQL the spec
69
88
  # later executes -- counts as referenced, so it is not deleted.
70
89
  IDENTIFIER_IN_STRING = /[A-Za-z_]\w*[!?]?/
90
+ # Methods whose literal arguments name factories, traits, stubbed methods or hash keys, never a
91
+ # `let`.
92
+ INERT_SYMBOL_METHODS = %i(
93
+ receive have_received create build build_stubbed create_list build_list attributes_for create_pair build_pair
94
+ [] []= fetch dig key? has_key? delete
95
+ ).freeze
71
96
  MSG = "Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
97
+ # Includes whose block defines a nested example group; `include_context`/`include_examples`
98
+ # evaluate their block in the including group itself.
99
+ NESTING_INCLUDES = %i(it_behaves_like it_should_behave_like).freeze
100
+ RESOLVABLE_INCLUDES = %i(it_behaves_like it_should_behave_like include_examples include_context).freeze
72
101
  RESTRICT_ON_SEND = %i(let).freeze
102
+ SHARED_GROUP_METHODS = %i(shared_examples shared_examples_for shared_context).freeze
73
103
  SUPPORT_FILES_GLOB = "**/spec/support/**/*.rb"
104
+ INCLUDED_CONTEXT = ::RuboCop::AST::NodePattern.new("(send _ :include_context ({str sym} $_) ...)")
105
+ INCLUDED_MODULE = ::RuboCop::AST::NodePattern.new("(send _ :include (const _ $_) ...)")
106
+ REGEXP_NEW = ::RuboCop::AST::NodePattern.new("(send (const {nil? cbase} :Regexp) :new (str $_))")
107
+ # Harness condition for configuration that applies to every example group, metadata or not.
108
+ UNCONDITIONAL = [:__unconditional__, nil].freeze
109
+ # The `type` rspec-rails infers from a spec's directory (`infer_spec_type_from_file_location!`).
110
+ INFERRED_TYPES = {
111
+ controller: "controllers", helper: "helpers", job: "jobs", mailer: "mailers", model: "models",
112
+ request: "(?:requests|integration|api)", routing: "routing", view: "views", feature: "features",
113
+ system: "system", mailbox: "mailboxes", channel: "channels", generator: "generator",
114
+ }.transform_values { |dir| %r(spec/#{dir}/) }.freeze
115
+
116
+ # The names some code could read: literal names, patterns for names it builds by
117
+ # interpolation (`try("#{prefix}_include")`), and whether it dispatches to names its inputs
118
+ # carry (`hash.each_key { |key| send(key) }`), which hash keys and the like can supply.
119
+ Reads = Struct.new(:names, :patterns, :inputs) do
120
+ def self.empty
121
+ new(Set.new, [], false)
122
+ end
123
+
124
+ def include?(name)
125
+ names.include?(name) || patterns.any? { |pattern| pattern.match?(name.to_s) }
126
+ end
127
+
128
+ def merge!(other)
129
+ names.merge(other.names)
130
+ patterns.concat(other.patterns)
131
+ self.inputs ||= other.inputs
132
+ self
133
+ end
134
+ end
74
135
 
75
136
  # The name symbol of any definition (`let`/`let!`/`subject`) in any block form -- used to
76
137
  # count how many times a name is defined, so override / `super` chains (including a
@@ -80,11 +141,26 @@ module RuboCop
80
141
  (any_block (send nil? %DEFINITION_METHODS (sym $_) ...) ...)
81
142
  PATTERN
82
143
 
144
+ # @!method lazy_let?(node)
145
+ def_node_matcher :lazy_let?, <<~PATTERN
146
+ (any_block (send nil? :let (sym _) ...) ...)
147
+ PATTERN
148
+
149
+ # @!method subject_name(node)
150
+ def_node_matcher :subject_name, <<~PATTERN
151
+ (any_block (send nil? {:subject :subject!} (sym $_) ...) ...)
152
+ PATTERN
153
+
154
+ # @!method eager_let_name(node)
155
+ def_node_matcher :eager_let_name, <<~PATTERN
156
+ (any_block (send nil? :let! (sym $_) ...) ...)
157
+ PATTERN
158
+
83
159
  class << self
84
160
  # Names defined as `let`/`subject` anywhere under `spec/support/**`. Computed once per
85
161
  # process (lazily, after boot) and shared across every file the cop inspects.
86
162
  def framework_let_names
87
- @framework_let_names ||= scan_framework_let_names(support_file_paths)
163
+ @framework_let_names ||= scan_framework_let_names(support_files)
88
164
  end
89
165
 
90
166
  # Enumerate `spec/support/**/*.rb`. No git dependency: some environments (e.g. a build
@@ -94,6 +170,19 @@ module RuboCop
94
170
  ::Dir.glob(SUPPORT_FILES_GLOB)
95
171
  end
96
172
 
173
+ def support_files
174
+ @support_files ||= support_file_paths
175
+ end
176
+
177
+ # The support files plus the `spec/*_helper.rb` files beside each `spec/support` directory,
178
+ # which define helpers the same way.
179
+ def indexed_files
180
+ helpers = support_files.filter_map { |path| path[%r{\A(?:.*/)?spec/(?=support/)}] }.uniq.flat_map do |spec_dir|
181
+ ::Dir.glob(::File.join(spec_dir, "*_helper.rb"))
182
+ end
183
+ support_files + helpers
184
+ end
185
+
97
186
  def scan_framework_let_names(paths)
98
187
  paths.each_with_object(Set.new) do |path, names|
99
188
  extract_let_names(read_source(path), names)
@@ -110,6 +199,331 @@ module RuboCop
110
199
 
111
200
  ::File.read(path, encoding: "UTF-8")
112
201
  end
202
+
203
+ # Every name the code behind a shared group (`kind` `:groups`), a helper method (`:defs`) or a
204
+ # module (`:modules`) defined under `spec/support/**` could read, or `nil` when that cannot be
205
+ # bounded. That code is followed through the groups it includes and the helpers it calls.
206
+ # Unbounded when a group is not defined there, or the code dispatches through a name it
207
+ # computes, or includes a group by a name we cannot resolve.
208
+ def support_reads(kind, name, processed_source)
209
+ @support_reads ||= {}
210
+ key = [kind, name]
211
+ return @support_reads[key] if @support_reads.key?(key)
212
+
213
+ index = support_index(processed_source)
214
+ @support_reads[key] = reads_from(index[kind][name], index)
215
+ end
216
+
217
+ def support_helper?(name, processed_source)
218
+ support_index(processed_source)[:defs].key?(name)
219
+ end
220
+
221
+ # Everything the harnesses whose conditions `provided` metadata meets could read: shared
222
+ # groups, modules and `config` hooks registered for that metadata. `provided` holds
223
+ # `[key, value]` pairs, a `nil` value standing for one we cannot know.
224
+ def harness_reads(provided, processed_source)
225
+ index = support_index(processed_source)
226
+ provided = provided.to_set | [UNCONDITIONAL, [:file_path, nil]]
227
+ provided |= index[:derived].filter_map { |pair, filter| pair if derived_applies?(filter, provided, processed_source.file_path.to_s) }
228
+ active = index[:harnesses].keys.select { |condition| condition_met?(condition, provided) }.to_set
229
+ @harness_reads ||= {}
230
+ return @harness_reads[active] if @harness_reads.key?(active)
231
+
232
+ @harness_reads[active] = active.flat_map { |condition| index[:harnesses][condition] }.uniq.reduce(Reads.empty) do |reads, target|
233
+ found = harness_target_reads(target, index, processed_source)
234
+ break unless found
235
+
236
+ reads.merge!(found)
237
+ end
238
+ end
239
+
240
+ # Whether a `define_derived_metadata` filter matches this file: a literal `file_path:` regexp is
241
+ # tested, a metadata filter checked against `provided`; anything else is assumed to match.
242
+ def derived_applies?(filter, provided, path)
243
+ kind, value = filter
244
+ case kind
245
+ when :path then value.match?(path)
246
+ when :metadata then value.all? { |condition| condition_met?(condition, provided) }
247
+ else true
248
+ end
249
+ end
250
+
251
+ # A condition with no value asks only for its key; a provided value we cannot know meets any.
252
+ def condition_met?(condition, provided)
253
+ key, value = condition
254
+ provided.any? { |provided_key, provided_value| provided_key == key && (value.nil? || provided_value.nil? || provided_value == value) }
255
+ end
256
+
257
+ # A module no support file defines comes from a gem, which cannot read spec `let`s.
258
+ def harness_target_reads(target, index, processed_source)
259
+ kind, name = target
260
+ case kind
261
+ when :chunk then reads_from([name], index)
262
+ when :modules then index[:modules].key?(name) ? support_reads(:modules, name, processed_source) : Reads.empty
263
+ else support_reads(kind, name, processed_source)
264
+ end
265
+ end
266
+
267
+ def local_context_reads(path, processed_source)
268
+ @local_context_reads ||= {}
269
+ return @local_context_reads[path] if @local_context_reads.key?(path)
270
+
271
+ ast = parse(read_source(path), path, processed_source) if ::File.file?(path)
272
+ @local_context_reads[path] = (reads_from([scan(ast)], support_index(processed_source)) if ast)
273
+ end
274
+
275
+ def reads_from(chunks, index)
276
+ return unless chunks
277
+
278
+ pending = chunks.dup
279
+ seen = Set.new.compare_by_identity
280
+ reads = Reads.empty
281
+ while (chunk = pending.shift)
282
+ next unless seen.add?(chunk)
283
+ return if chunk[:opaque] || chunk[:includes].any? { |included| !index[:groups].key?(included) }
284
+
285
+ reads.names.merge(chunk[:names])
286
+ reads.patterns.concat(chunk[:patterns])
287
+ reads.inputs ||= chunk[:inputs]
288
+ chunk[:includes].each { |included| pending.concat(index[:groups][included]) }
289
+ chunk[:calls].each { |call| pending.concat(index[:defs].fetch(call, [])) }
290
+ end
291
+ reads
292
+ end
293
+
294
+ def support_index(processed_source)
295
+ @support_index ||= indexed_files.each_with_object(empty_index) do |path, index|
296
+ ast = parse(read_source(path), path, processed_source)
297
+ index_definitions(ast, index) if ast
298
+ end
299
+ end
300
+
301
+ def empty_index
302
+ { groups: {}, defs: {}, modules: {}, harnesses: Hash.new { |hash, key| hash[key] = [] }, derived: Set.new }
303
+ end
304
+
305
+ def parse(source, path, processed_source)
306
+ ::RuboCop::ProcessedSource.new(source, processed_source.ruby_version, path, parser_engine: processed_source.parser_engine).ast
307
+ end
308
+
309
+ # Indexes each shared group, helper method and module by name, scanned on its own, and each
310
+ # harness `config` registers by the metadata keys that pull it in.
311
+ def index_definitions(ast, index)
312
+ ast.each_node(:any_block, :def, :module, :send) do |node|
313
+ case node.type
314
+ when :def
315
+ (index[:defs][node.method_name] ||= []) << scan(node) if helper_method?(node)
316
+ when :module
317
+ (index[:modules][node.identifier.short_name] ||= []).concat(inclusion_hooks(node))
318
+ when :send
319
+ index_registration(node, index)
320
+ else
321
+ index_block(node, index)
322
+ end
323
+ end
324
+ end
325
+
326
+ def index_block(block, index)
327
+ send_node = block.send_node
328
+ name = literal_name(send_node.first_argument)
329
+ if name && SHARED_GROUP_METHODS.include?(send_node.method_name)
330
+ (index[:groups][name] ||= []) << scan(block)
331
+ metadata_conditions(send_node.arguments.drop(1)).each { |condition| index[:harnesses][condition] << [:groups, name] } if send_node.arguments.size > 1
332
+ elsif config_call?(send_node) && HOOK_METHODS.include?(send_node.method_name)
333
+ conditions = metadata_conditions(send_node.arguments.reject { |arg| arg.sym_type? && HOOK_SCOPES.include?(arg.value) })
334
+ conditions.each { |condition| index[:harnesses][condition] << [:chunk, scan(block)] }
335
+ elsif config_call?(send_node) && send_node.method?(:define_derived_metadata)
336
+ index_derived(block, index)
337
+ end
338
+ end
339
+
340
+ # The metadata a `define_derived_metadata` block assigns (`metadata[:key] = value`), which can
341
+ # be present without the spec writing it.
342
+ def index_derived(block, index)
343
+ filter = derived_filter(block.send_node.arguments)
344
+ block.each_node(:send) do |node|
345
+ index[:derived] << [[node.first_argument.value, literal_value(node.last_argument)], filter] if node.method?(:[]=) && node.first_argument.sym_type?
346
+ end
347
+ end
348
+
349
+ def derived_filter(args)
350
+ return [:always] if args.empty?
351
+
352
+ path = args.first.pairs.find { |pair| pair.key.sym_type? && pair.key.value == :file_path } if args.one? && args.first.hash_type?
353
+ return [:path, literal_regexp(path.value)] if path && literal_regexp(path.value)
354
+ return [:metadata, metadata_conditions(args)] unless path
355
+
356
+ [:unknown]
357
+ end
358
+
359
+ def literal_regexp(node)
360
+ return node.to_regexp if node.regexp_type? && node.children.all? { |part| part.type?(:str, :regopt) }
361
+
362
+ (source = REGEXP_NEW.match(node)) && ::Regexp.new(source)
363
+ end
364
+
365
+ def index_registration(node, index)
366
+ return unless config_call?(node)
367
+
368
+ target = registration_target(node)
369
+ metadata_conditions(node.arguments.drop(1)).each { |condition| index[:harnesses][condition] << target } if target
370
+ end
371
+
372
+ def registration_target(node)
373
+ if (name = INCLUDED_CONTEXT.match(node)) then [:groups, name.to_s]
374
+ elsif (name = INCLUDED_MODULE.match(node)) then [:modules, name]
375
+ end
376
+ end
377
+
378
+ def config_call?(send_node)
379
+ !send_node.receiver.nil? && !send_node.receiver.self_type? && !send_node.receiver.const_type?
380
+ end
381
+
382
+ # `[key, value]` metadata pairs written as arguments: `:key` asks for the key, `key: value` for
383
+ # the value (`nil` when it is not a literal).
384
+ def metadata_conditions(args)
385
+ conditions = args.flat_map do |arg|
386
+ if arg.sym_type? then [[arg.value, nil]]
387
+ elsif arg.hash_type? then arg.pairs.select { |pair| pair.key.sym_type? }.map { |pair| [pair.key.value, literal_value(pair.value)] }
388
+ else []
389
+ end
390
+ end
391
+ conditions.empty? ? [UNCONDITIONAL] : conditions
392
+ end
393
+
394
+ def literal_value(node)
395
+ return node.true_type? if node.boolean_type?
396
+
397
+ node.value if node.type?(:sym, :str)
398
+ end
399
+
400
+ # What including a module runs on its own: its `included`/`extended` hooks. Its other methods
401
+ # only read what they read when called, and calls are followed as helpers.
402
+ def inclusion_hooks(module_node)
403
+ module_node.each_node(:any_block, :defs).select { |node| node.method?(:included) || node.method?(:extended) }.map { |hook| scan(hook) }
404
+ end
405
+
406
+ # What code under `root` reads: its symbols (except factory/stub ones and hash keys), bare
407
+ # calls and string tokens; the helpers it calls and groups it includes, to follow; and the
408
+ # patterns of names it builds for dynamic dispatch, or `opaque` when it builds them some
409
+ # other way.
410
+ def scan(root)
411
+ chunk = { calls: Set.new, names: Set.new, includes: [], patterns: [], opaque: false, inputs: false }
412
+ inert = Set.new
413
+ root.each_node do |node|
414
+ case node.type
415
+ when :sym then (inert_symbol?(node) ? inert : chunk[:names]) << node.value
416
+ when :str then (inert_symbol?(node) ? inert : chunk[:names]).merge(string_tokens(node))
417
+ when :send, :csend then scan_call(node, chunk)
418
+ end
419
+ end
420
+ chunk[:names].merge(inert) if chunk[:inputs]
421
+ chunk
422
+ end
423
+
424
+ def scan_call(node, chunk)
425
+ if node.receiver.nil?
426
+ chunk[:calls] << node.method_name
427
+ chunk[:names] << node.method_name if node.arguments.empty?
428
+ scan_include(node, chunk) if include_method?(node.method_name)
429
+ end
430
+ case (read = dispatch_read(node))
431
+ when Regexp then chunk[:patterns] << read
432
+ when :inputs then chunk[:inputs] = true
433
+ when :unbounded then chunk[:opaque] = true
434
+ end
435
+ end
436
+
437
+ def scan_include(node, chunk)
438
+ name = literal_name(node.first_argument)
439
+ name && RESOLVABLE_INCLUDES.include?(node.method_name) ? chunk[:includes] << name : chunk[:opaque] = true
440
+ end
441
+
442
+ # A method a spec could call bare: defined at the top level, in a block, or in a module that
443
+ # can be mixed into example groups -- not an instance method of some class.
444
+ def helper_method?(def_node)
445
+ !def_node.each_ancestor(:class, :sclass, :module).first&.type?(:class, :sclass)
446
+ end
447
+
448
+ def include_method?(method_name)
449
+ ::RuboCop::RSpec::Language.config.fetch("Includes", {}).values.flatten.include?(method_name.to_s)
450
+ end
451
+
452
+ def literal_name(node)
453
+ node.value.to_s if node&.type?(:str, :sym)
454
+ end
455
+
456
+ # A symbol or string that cannot name a `let`: a hash key, or a factory, trait, stubbed method
457
+ # or hash lookup name.
458
+ def inert_symbol?(node)
459
+ parent = node.parent
460
+ return parent.key.equal?(node) if parent.pair_type?
461
+
462
+ parent.send_type? && INERT_SYMBOL_METHODS.include?(parent.method_name) && parent.arguments.any? { |arg| arg.equal?(node) }
463
+ end
464
+
465
+ def dynamic_target?(node)
466
+ return false unless DYNAMIC_DISPATCH_METHODS.include?(node.method_name)
467
+
468
+ target = node.first_argument
469
+ !target.nil? && !target.type?(:sym, :str)
470
+ end
471
+
472
+ # What a reflective call that can reach a `let` (no receiver, or `self`) reads beyond its
473
+ # literal names: nothing for a literal target; `:inputs` when the target is a parameter, a
474
+ # variable assigned only literals, or a bare call returning one, so any literal could
475
+ # supply the name; a pattern when it interpolates around literal text; `:unbounded`
476
+ # otherwise.
477
+ def dispatch_read(node)
478
+ return unless DYNAMIC_DISPATCH_METHODS.include?(node.method_name) && (node.receiver.nil? || node.receiver.self_type?)
479
+
480
+ target = node.first_argument
481
+ return if target.nil? || target.type?(:sym, :str)
482
+ return :inputs if literal_valued?(target)
483
+
484
+ target.type?(:dstr, :dsym) ? interpolation_pattern(target) : :unbounded
485
+ end
486
+
487
+ def literal_valued?(node)
488
+ case node.type
489
+ when :lvar
490
+ node.each_ancestor.to_a.last.each_node(:lvasgn).none? do |assignment|
491
+ assignment.name == node.children.first && !assignment.expression&.type?(:sym, :str)
492
+ end
493
+ when :send then node.receiver.nil? && node.arguments.empty?
494
+ else false
495
+ end
496
+ end
497
+
498
+ def interpolation_pattern(node)
499
+ parts = node.children.map { |part| part.str_type? ? ::Regexp.escape(part.value) : (literal_alternatives(part) || "\\w*") }
500
+ return :unbounded unless node.children.any?(&:str_type?)
501
+
502
+ ::Regexp.new("\\A#{parts.join}[!?]?\\z")
503
+ end
504
+
505
+ # `(?:a|b)` for an interpolated block parameter bound by iterating a literal array or hash.
506
+ def literal_alternatives(part)
507
+ variable = part.children.first
508
+ return unless part.children.one? && variable.lvar_type?
509
+
510
+ block = variable.each_ancestor(:block).find { |ancestor| ancestor.argument_list.first&.name == variable.children.first }
511
+ values = literal_collection(block.receiver) if block
512
+ "(?:#{values.map { |value| ::Regexp.escape(value) }.join('|')})" if values
513
+ end
514
+
515
+ def literal_collection(node)
516
+ return unless node&.type?(:array, :hash)
517
+
518
+ elements = node.hash_type? ? node.keys : node.children
519
+ elements.map { |element| element.value.to_s } if elements.all? { |element| element.type?(:sym, :str) }
520
+ end
521
+
522
+ # A string with invalid encoding (e.g. a deliberate bad-UTF-8 test fixture) cannot contain
523
+ # an identifier-shaped reference and would raise on `scan`, so it yields none.
524
+ def string_tokens(node)
525
+ node.value.valid_encoding? ? node.value.scan(IDENTIFIER_IN_STRING).map(&:to_sym) : []
526
+ end
113
527
  end
114
528
 
115
529
  def on_send(node)
@@ -131,20 +545,243 @@ module RuboCop
131
545
 
132
546
  private
133
547
 
134
- # A lazy `let` is exempt from deletion whenever file-scoped analysis cannot prove its name
135
- # is dead: its name is a framework-reserved contract (e.g. `cop_config`), the file
136
- # dispatches through a name we cannot resolve statically, it consumes shared examples, the
137
- # `let` is lexically inside a shared-example definition, its name is a `spec/support/**`
138
- # framework contract, it is overridden by another definition of the same name, or it is
139
- # referenced somewhere in the file.
140
548
  def exempt_from_deletion?(name, block)
141
549
  FRAMEWORK_RESERVED_NAMES.include?(name) ||
142
- dynamic_dispatch? ||
143
- consumes_shared_examples? ||
550
+ (!per_example_group? && dynamic_dispatch?) ||
144
551
  within_shared_definition?(block) ||
145
- self.class.framework_let_names.include?(name) ||
146
- overridden?(name) ||
147
- referenced?(name)
552
+ !dead_lets.include?(block)
553
+ end
554
+
555
+ def per_example_group?
556
+ cop_config.fetch("PerExampleGroup", false)
557
+ end
558
+
559
+ def cascade?
560
+ cop_config.fetch("Cascade", false)
561
+ end
562
+
563
+ def local_context_includes
564
+ @local_context_includes ||= Array(cop_config["LocalContextIncludes"]).to_set(&:to_sym)
565
+ end
566
+
567
+ def dead_lets
568
+ @dead_lets ||= begin
569
+ dead = find_dead(identity_set)
570
+ while cascade? && (next_dead = find_dead(dead)).size > dead.size
571
+ dead = next_dead
572
+ end
573
+ dead
574
+ end
575
+ end
576
+
577
+ def find_dead(dead)
578
+ identity_set.merge(lazy_lets.reject { |let| live?(let, dead) })
579
+ end
580
+
581
+ def identity_set
582
+ Set.new.compare_by_identity
583
+ end
584
+
585
+ def live?(let, dead)
586
+ name = let.send_node.first_argument.value
587
+ scope = scope_of(let)
588
+ exposed_to_reader?(name, scope) ||
589
+ exposed_to_harness?(name, scope) ||
590
+ redefined?(name) ||
591
+ referenced_in_lineage?(name, let, scope, dead)
592
+ end
593
+
594
+ def exposed_to_reader?(name, scope)
595
+ reader_sites.any? do |site_scope, reads|
596
+ lineage?(site_scope, scope) &&
597
+ (reads.nil? || reads.include?(name) || (reads.inputs && inert_references.fetch(name, []).any? { |inert_scope| lineage?(inert_scope, site_scope) }))
598
+ end
599
+ end
600
+
601
+ # Hash keys and factory, stub and lookup arguments, by name and scope: not references, but a
602
+ # reader that dispatches to names its inputs carry could be handed one.
603
+ def inert_references
604
+ @inert_references ||= processed_source.ast.each_node(:sym, :str).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |node, refs|
605
+ next unless self.class.inert_symbol?(node)
606
+
607
+ (node.sym_type? ? [node.value] : self.class.string_tokens(node)).each { |name| refs[name] << scope_of(node) }
608
+ end
609
+ end
610
+
611
+ # By file, a name also defined as a `let`/`subject` under `spec/support/**` likely overrides a
612
+ # contract some harness reads. Per example group, the harnesses that metadata in the lineage
613
+ # pulls in are resolved and only their reads count.
614
+ def exposed_to_harness?(name, scope)
615
+ return self.class.framework_let_names.include?(name) unless per_example_group?
616
+
617
+ reads = self.class.harness_reads(provided_metadata(scope), processed_source)
618
+ reads.nil? || reads.include?(name)
619
+ end
620
+
621
+ # The metadata examples in the lineage of `scope` carry: written on its groups, plus the
622
+ # `type` rspec-rails infers from the file's directory. A bare `:key` reads as `key: true`.
623
+ def provided_metadata(scope)
624
+ written = metadata_scopes.select { |group| lineage?(group, scope) }.flat_map do |group|
625
+ group.send_node.arguments.drop(1).flat_map do |arg|
626
+ next [[arg.value, true]] if arg.sym_type?
627
+
628
+ arg.hash_type? ? arg.pairs.select { |pair| pair.key.sym_type? }.map { |pair| [pair.key.value, self.class.literal_value(pair.value)] } : []
629
+ end
630
+ end
631
+ written + inferred_type
632
+ end
633
+
634
+ def inferred_type
635
+ @inferred_type ||= INFERRED_TYPES.filter_map { |type, directory| [:type, type] if processed_source.file_path.to_s.match?(directory) }
636
+ end
637
+
638
+ def redefined?(name)
639
+ per_example_group? ? subject_names.include?(name) : overridden?(name)
640
+ end
641
+
642
+ def referenced_in_lineage?(name, let, scope, dead)
643
+ references.fetch(name, []).any? do |reference_scope, container|
644
+ next false if cascade? && (container.equal?(let) || dead.include?(container))
645
+
646
+ lineage?(reference_scope, scope)
647
+ end
648
+ end
649
+
650
+ # Two scopes share a lineage when one encloses the other: a reference in an enclosing group runs
651
+ # in every nested example, and a reference in a nested group resolves outward to the definition.
652
+ # `nil` is file-wide and shares a lineage with everything.
653
+ def lineage?(scope, other)
654
+ scope.nil? || other.nil? || scope.equal?(other) ||
655
+ scope.each_ancestor(:any_block).any? { |ancestor| ancestor.equal?(other) } ||
656
+ other.each_ancestor(:any_block).any? { |ancestor| ancestor.equal?(scope) }
657
+ end
658
+
659
+ # The innermost example group (or block passed to an include such as `it_behaves_like`)
660
+ # enclosing the node, or `nil` for file-wide: outside any group, inside a shared-group
661
+ # definition (its body runs wherever it is included), or when scoping per group is off.
662
+ def scope_of(node)
663
+ return unless per_example_group?
664
+
665
+ ancestors = node.each_ancestor(:any_block).to_a
666
+ return if ancestors.any? { |ancestor| shared_group?(ancestor) }
667
+
668
+ ancestors.find { |ancestor| example_group?(ancestor) || nesting_include?(ancestor) }
669
+ end
670
+
671
+ def nesting_include?(block)
672
+ block.receiver.nil? && NESTING_INCLUDES.include?(block.method_name)
673
+ end
674
+
675
+ def lazy_lets
676
+ @lazy_lets ||= identity_set.merge(processed_source.ast.each_node(:any_block).select { |node| lazy_let?(node) })
677
+ end
678
+
679
+ def subject_names
680
+ @subject_names ||= processed_source.ast.each_node(:any_block).filter_map { |node| subject_name(node) }.to_set
681
+ end
682
+
683
+ # Every place code could read a let other than by naming it, as `[scope, Reads]` (`nil` when
684
+ # unbounded): shared-group includes and, per example group, calls to helper methods defined
685
+ # under `spec/support/**` and reflective calls that build the name they dispatch to.
686
+ def reader_sites
687
+ @reader_sites ||= processed_source.ast.each_node(:send).filter_map do |node|
688
+ if include?(node)
689
+ [scope_of(node.block_node || node), included_reads(node)]
690
+ elsif per_example_group?
691
+ group_reader_site(node)
692
+ end
693
+ end
694
+ end
695
+
696
+ def group_reader_site(node)
697
+ if node.receiver.nil? && self.class.support_helper?(node.method_name, processed_source)
698
+ [scope_of(node), self.class.support_reads(:defs, node.method_name, processed_source)]
699
+ elsif (read = self.class.dispatch_read(node))
700
+ [scope_of(node), dispatch_reads(read)]
701
+ end
702
+ end
703
+
704
+ def dispatch_reads(read)
705
+ case read
706
+ when ::Regexp then Reads.new(Set.new, [read], false)
707
+ when :inputs then Reads.new(Set.new, [], true)
708
+ end
709
+ end
710
+
711
+ def included_reads(node)
712
+ return unless per_example_group?
713
+ return local_context_reads(node) if local_context_includes.include?(node.method_name)
714
+ return unless RESOLVABLE_INCLUDES.include?(node.method_name)
715
+
716
+ group = self.class.literal_name(node.first_argument)
717
+ return unless group
718
+ # A group defined in this file is read through its body's references, which count file-wide.
719
+ return Reads.empty if local_shared_groups.include?(group)
720
+
721
+ self.class.support_reads(:groups, group, processed_source)
722
+ end
723
+
724
+ # A local-context include evaluates a sibling `context.rb`, or `<name>_context.rb` when named.
725
+ def local_context_reads(node)
726
+ name = self.class.literal_name(node.first_argument)
727
+ return if node.first_argument && !name
728
+
729
+ path = ::File.join(::File.dirname(processed_source.file_path), name ? "#{name}_context.rb" : "context.rb")
730
+ self.class.local_context_reads(path, processed_source)
731
+ end
732
+
733
+ def local_shared_groups
734
+ @local_shared_groups ||= processed_source.ast.each_node(:any_block).filter_map do |node|
735
+ self.class.literal_name(node.send_node.first_argument) if shared_group?(node)
736
+ end.to_set
737
+ end
738
+
739
+ def metadata_scopes
740
+ @metadata_scopes ||= processed_source.ast.each_node(:any_block).select do |node|
741
+ example_group?(node) && node.send_node.arguments.drop(1).any? { |arg| arg.type?(:sym, :hash) }
742
+ end
743
+ end
744
+
745
+ # Every reference to a name, keyed by name, as `[scope, enclosing lazy let]` pairs. A name is
746
+ # referenced where it is called bare (`foo`), appears as a symbol (`:foo`) other than a
747
+ # definition's own name argument, or appears as an identifier-shaped token inside any
748
+ # string/heredoc literal. The symbol and string cases cover indirect invocation --
749
+ # `send(:foo)` / `send("foo")`, a `:foo` listed in a data table the spec later dispatches on,
750
+ # or a binding named only inside raw SQL/GraphQL text -- which static analysis cannot
751
+ # otherwise follow. A `let!` references its own name from its own scope, since its eager
752
+ # hook calls it before every example there.
753
+ def references
754
+ @references ||= processed_source.ast.each_node(:sym, :str, :call, :any_block).each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |node, refs|
755
+ reference_names(node).each { |name| refs[name] << [scope_of(node), (enclosing_lazy_let(node) if cascade?)] }
756
+ end
757
+ end
758
+
759
+ def reference_names(node)
760
+ case node.type
761
+ when :sym then definition_name_argument?(node) || (per_example_group? && self.class.inert_symbol?(node)) ? [] : [node.value]
762
+ when :str then per_example_group? && self.class.inert_symbol?(node) ? [] : self.class.string_tokens(node)
763
+ when :send, :csend then node.arguments.empty? && example_receiver?(node.receiver) ? [node.method_name] : []
764
+ else Array(eager_let_name(node))
765
+ end
766
+ end
767
+
768
+ # A call reaches a `let` when it has no receiver, or, per example group, when its receiver may be
769
+ # the example itself: `self`, or a block parameter (`->(example) { example.payroll }`).
770
+ def example_receiver?(receiver)
771
+ return true if receiver.nil?
772
+ return false unless per_example_group?
773
+
774
+ receiver.self_type? || (receiver.lvar_type? && block_parameter?(receiver))
775
+ end
776
+
777
+ def block_parameter?(lvar)
778
+ lvar.each_ancestor(:any_block).any? do |block|
779
+ block.respond_to?(:argument_list) && block.argument_list.any? { |argument| argument.name == lvar.children.first }
780
+ end
781
+ end
782
+
783
+ def enclosing_lazy_let(node)
784
+ node.each_ancestor(:any_block).find { |ancestor| lazy_lets.include?(ancestor) }
148
785
  end
149
786
 
150
787
  # Delete the `let` block, plus:
@@ -193,14 +830,12 @@ module RuboCop
193
830
  sibling
194
831
  end
195
832
 
833
+ # Per example group, a shared group nested in an example group is only visible to this file, so
834
+ # its `let`s are judged file-wide like any other; a top-level one may be included elsewhere.
196
835
  def within_shared_definition?(node)
197
- node.each_ancestor(:any_block).any? { |ancestor| shared_group?(ancestor) }
198
- end
199
-
200
- def consumes_shared_examples?
201
- return @consumes_shared_examples unless @consumes_shared_examples.nil?
202
-
203
- @consumes_shared_examples = processed_source.ast.each_node(:call).any? { |send_node| include?(send_node) }
836
+ node.each_ancestor(:any_block).any? do |ancestor|
837
+ shared_group?(ancestor) && !(per_example_group? && ancestor.each_ancestor(:any_block).any? { |outer| example_group?(outer) })
838
+ end
204
839
  end
205
840
 
206
841
  # True when the file reflectively dispatches through a name we cannot resolve statically --
@@ -210,12 +845,7 @@ module RuboCop
210
845
  def dynamic_dispatch?
211
846
  return @dynamic_dispatch unless @dynamic_dispatch.nil?
212
847
 
213
- @dynamic_dispatch = processed_source.ast.each_node(:call).any? do |send_node|
214
- next false unless DYNAMIC_DISPATCH_METHODS.include?(send_node.method_name)
215
-
216
- target = send_node.first_argument
217
- target && !target.sym_type? && !target.str_type?
218
- end
848
+ @dynamic_dispatch = processed_source.ast.each_node(:call).any? { |node| self.class.dynamic_target?(node) }
219
849
  end
220
850
 
221
851
  def overridden?(name)
@@ -229,33 +859,6 @@ module RuboCop
229
859
  end
230
860
  end
231
861
 
232
- def referenced?(name)
233
- referenced_names.include?(name)
234
- end
235
-
236
- # A name is "referenced" if it is called as a bare method (`foo`), appears as a symbol
237
- # literal (`:foo`) other than the let/subject's own name argument, or appears as an
238
- # identifier-shaped token inside any string/heredoc literal. The symbol and string cases
239
- # cover indirect invocation -- `send(:foo)` / `send("foo")`, a `:foo`/`"foo"` listed in a
240
- # data table the spec later dispatches on, or a binding named only inside raw SQL/GraphQL
241
- # text the spec executes -- which file-scoped analysis cannot otherwise follow. (Tokenizing
242
- # string bodies, rather than matching the whole string, keeps a `let` referenced only from
243
- # inside a multi-word heredoc from being deleted.) Interpolated-string *dispatch* is handled
244
- # separately by `dynamic_dispatch?`, which exempts the whole file.
245
- def referenced_names
246
- @referenced_names ||= processed_source.ast.each_node(:sym, :str, :call).each_with_object(Set.new) do |node, names|
247
- if node.sym_type?
248
- names << node.value unless definition_name_argument?(node)
249
- elsif node.str_type?
250
- # A string with invalid encoding (e.g. a deliberate bad-UTF-8 test fixture) cannot
251
- # contain an identifier-shaped reference and would raise on `scan`, so skip it.
252
- node.value.scan(IDENTIFIER_IN_STRING) { |token| names << token.to_sym } if node.value.valid_encoding?
253
- elsif node.receiver.nil? && node.arguments.empty?
254
- names << node.method_name
255
- end
256
- end
257
- end
258
-
259
862
  def definition_name_argument?(sym_node)
260
863
  parent = sym_node.parent
261
864
  parent.send_type? && parent.receiver.nil? && DEFINITION_METHODS.include?(parent.method_name)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Gusto
5
- VERSION = "11.9.1"
5
+ VERSION = "11.10.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: 11.9.1
4
+ version: 11.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineering
@@ -194,6 +194,7 @@ files:
194
194
  - lib/rubocop/cop/gusto/smart_todo_team.rb
195
195
  - lib/rubocop/cop/gusto/sorbet/predicate_boolean_return.rb
196
196
  - lib/rubocop/cop/gusto/sudden_associations.rb
197
+ - lib/rubocop/cop/gusto/timeout_timeout.rb
197
198
  - lib/rubocop/cop/gusto/toplevel_constants.rb
198
199
  - lib/rubocop/cop/gusto/unreferenced_let.rb
199
200
  - lib/rubocop/cop/gusto/use_paint_not_colorize.rb