rubocop-gusto 11.9.0 → 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: ae5d8c5b6e24aaf92ff21ac52781b6e6ad098d4893a0051b530d372bd6fee0d3
4
- data.tar.gz: f95be1484e3291a03aa5fb2e337b18fcdcf48cb4762422bc0fc39bed4550ef6c
3
+ metadata.gz: 389e8e8a7531fb3ad32c281f658bc51fa59c0c65707599089e7c45f0b8cddf19
4
+ data.tar.gz: 3c9654b75327dc9420e107dc43fff4ac94b32586639f9a925794c98eb95d4e0d
5
5
  SHA512:
6
- metadata.gz: 5c773623cef1e70d0289490fd8409a30d05ce91c0051ac29f7be5946849d8d3087ca9edb26b90e54cb14b9afe99c26fa712063810be1ba74252010ac32d26706
7
- data.tar.gz: a0e8c1775d2850925cdda007713667410a2ab32f60b73f928f372bfa24ec4fa4ab2a707466aca9ef10ce4b5e61407e0c3a9752b489ae9c9e5a8f0f92bfa57533
6
+ metadata.gz: 5d116ac3f0047391c0d9e6fc17fab5849ce6a67bcd1681ef5050c9dcf501801fe97ad6f59f3a7f31f505babe20a718e2b6ff61a7fadfd306fdc4073366ad6db5
7
+ data.tar.gz: 7ab7f1cebafc19303cadff84e8091b8f52f196edb9cac54add0c2acc8719bf6420a5a8f7a2fbe4aaeae13d95747c72a16c8a5e1e1380004328e8e05f4f3916b5
data/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
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
+
15
+ ## [11.9.1](https://github.com/Gusto/rubocop-gusto/compare/v11.9.0...v11.9.1) (2026-09-28)
16
+
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
+
22
+ ### Bug Fixes
23
+
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))
25
+
6
26
  ## [11.9.0](https://github.com/Gusto/rubocop-gusto/compare/v11.8.1...v11.9.0) (2026-09-24)
7
27
 
8
28
 
@@ -32,9 +32,14 @@ Gusto/DescribedClassConstantReference:
32
32
  - '**/spec/**/*'
33
33
 
34
34
  Gusto/DiscouragedGem:
35
- Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
36
- Enabled: false
37
- Gems: {}
35
+ Description: 'Flags discouraged gems in Gemfiles and gemspecs, including ones pulled in transitively per the lockfile.'
36
+ Enabled: true
37
+ Include:
38
+ - '**/*.gemspec'
39
+ - '**/Gemfile'
40
+ - '**/gems.rb'
41
+ Gems:
42
+ ostruct: "OpenStruct is officially discouraged (slow, and defeats method caching); use Struct, Data, a Hash, or T::Struct. See https://forum.exercism.org/t/openstruct-is-now-officially-discouraged/17490"
38
43
 
39
44
  Gusto/ExecuteMigration:
40
45
  Description: "Don't use `execute` in migrations. Use a backfill rake task instead."
@@ -193,6 +198,11 @@ Gusto/SuddenAssociations:
193
198
  Exclude:
194
199
  - '**/spec/**/*' # allow stubbing, although this is still odd
195
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
+
196
206
  Gusto/ToplevelConstants:
197
207
  Description: 'Prevents top-level constants from being defined outside of initializers.'
198
208
  Include:
@@ -215,6 +225,9 @@ Gusto/UnreferencedLet:
215
225
  Include:
216
226
  - '**/spec/**/*_spec.rb'
217
227
  SafeAutoCorrect: false
228
+ PerExampleGroup: false
229
+ Cascade: false
230
+ LocalContextIncludes: []
218
231
 
219
232
  Gusto/UsePaintNotColorize:
220
233
  Description: 'Use Paint instead of colorize for terminal colors.'
data/config/rails.yml CHANGED
@@ -11,10 +11,6 @@ AllCops:
11
11
  TargetRubyVersion: <%= RbConfig::CONFIG['RUBY_API_VERSION'] %>
12
12
 
13
13
  Gusto/DiscouragedGem:
14
- Enabled: true
15
- Include:
16
- - '**/*.gemspec'
17
- - '**/Gemfile'
18
14
  Gems:
19
15
  timecop: "Use Rails' time helpers (e.g., freeze_time, travel_to) instead of Timecop."
20
16
 
@@ -1,27 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler"
4
+ require "digest"
5
+
3
6
  module RuboCop
4
7
  module Cop
5
8
  module Gusto
6
- # Flag installation of discouraged gems (e.g. timecop) in Gemfiles and
7
- # gemspecs. The discouraged gems an advice about alternatives are configured under
8
- # `Gems:`; intended to be enabled in Rails projects via config/rails.yml.
9
+ # Flag installation of discouraged gems (e.g. ostruct) in Gemfiles and gemspecs. The
10
+ # discouraged gems and advice about alternatives are configured under `Gems:`.
11
+ #
12
+ # When a Gemfile has a lockfile beside it, discouraged gems pulled in transitively are
13
+ # flagged on the line of the Gemfile dependency that pulls them in, so each existing case
14
+ # can be exempted with its own inline `rubocop:todo` and any new one still fails.
9
15
  #
10
- # @example Gems: { timecop: "Use Rails' time helpers instead of Timecop." }
16
+ # @example Gems: { ostruct: "Use Struct or Data instead of OpenStruct." }
11
17
  # # bad
12
- # gem "timecop"
18
+ # gem "ostruct"
13
19
  #
14
- # # good
15
- # # Use Rails' time helpers (freeze_time, travel_to) instead.
20
+ # # bad - Gemfile.lock resolves slack-ruby-client -> gli -> ostruct
21
+ # gem "slack-ruby-client"
16
22
  class DiscouragedGem < Base
17
23
  MSG = "Avoid using the '%{gem}' gem. %{advice}"
24
+ MSG_TRANSITIVE = "Avoid using the '%{gem}' gem, pulled in via %{path}. %{advice}"
25
+ MSG_UNREADABLE_LOCKFILE = "Could not parse %{lockfile} to check for discouraged gems."
26
+ LOCKFILES = { "Gemfile" => "Gemfile.lock", "gems.rb" => "gems.locked" }.freeze
18
27
 
19
28
  RESTRICT_ON_SEND = %i(gem add_dependency add_development_dependency).freeze
20
29
 
30
+ # @!method gem_declaration(node)
31
+ def_node_matcher :gem_declaration, "(send nil? :gem ({str sym} $_) ...)"
32
+
33
+ # @!method gemspec_declaration?(node)
34
+ def_node_matcher :gemspec_declaration?, "(send nil? :gemspec ...)"
35
+
36
+ def on_new_investigation
37
+ return if discouraged_gems.empty? || processed_source.ast.nil?
38
+
39
+ lockfile = lockfile_path
40
+ check_lockfile(lockfile) if lockfile && ::File.file?(lockfile)
41
+ end
42
+
21
43
  def on_send(node)
22
44
  check_gem_usage(node)
23
45
  end
24
46
 
47
+ # Asked once per run, so only a lockfile-only change at the project root busts the cache.
48
+ def external_dependency_checksum
49
+ lockfiles = LOCKFILES.values.select { |path| ::File.file?(path) }
50
+ return if lockfiles.empty?
51
+
52
+ Digest::SHA256.hexdigest(lockfiles.map { |path| ::File.read(path) }.join)
53
+ end
54
+
25
55
  private
26
56
 
27
57
  def check_gem_usage(node)
@@ -32,6 +62,84 @@ module RuboCop
32
62
  # No autocorrect: removing dependencies is a project decision.
33
63
  end
34
64
 
65
+ def lockfile_path
66
+ file_path = processed_source.file_path.to_s
67
+ lockfile = LOCKFILES[::File.basename(file_path)]
68
+ ::File.join(::File.dirname(file_path), lockfile) if lockfile
69
+ end
70
+
71
+ def check_lockfile(lockfile)
72
+ parser = ::Bundler::LockfileParser.new(::File.read(lockfile))
73
+ rescue ::Bundler::LockfileError
74
+ add_offense(fallback_location, message: format(MSG_UNREADABLE_LOCKFILE, lockfile: ::File.basename(lockfile)))
75
+ else
76
+ graph = dependency_graph(parser.specs)
77
+ declared = declared_gems
78
+ paths_by_location = Hash.new { |hash, location| hash[location] = [] }
79
+ parser.dependencies.each_key do |root|
80
+ discouraged_paths(root, graph).each do |path|
81
+ # A declared discouraged gem is already flagged by on_send.
82
+ next if path.one? && declared.key?(root)
83
+
84
+ paths_by_location[declared.fetch(root) { fallback_location }] << path
85
+ end
86
+ end
87
+ # RuboCop keeps only the first offense per location, so they are combined.
88
+ paths_by_location.each do |location, paths|
89
+ add_offense(location, message: paths.map { |path| message_for_path(path) }.join(" "))
90
+ end
91
+ end
92
+
93
+ # Platform-specific specs share a name, so their dependencies are merged.
94
+ def dependency_graph(specs)
95
+ specs.each_with_object({}) do |spec, graph|
96
+ (graph[spec.name] ||= []).concat(spec.dependencies.map(&:name)).uniq!
97
+ end
98
+ end
99
+
100
+ # Breadth-first, so each discouraged gem is reported via its shortest path from root.
101
+ def discouraged_paths(root, graph)
102
+ parents = { root => nil }
103
+ queue = [root]
104
+ found = []
105
+ until queue.empty?
106
+ name = queue.shift
107
+ found << name if discouraged_gems.include?(name)
108
+ graph.fetch(name, []).each do |dependency|
109
+ next if parents.key?(dependency)
110
+
111
+ parents[dependency] = name
112
+ queue << dependency
113
+ end
114
+ end
115
+ found.map { |name| path_to(name, parents) }
116
+ end
117
+
118
+ def path_to(name, parents)
119
+ path = [name]
120
+ path.unshift(parents[path.first]) while parents[path.first]
121
+ path
122
+ end
123
+
124
+ def declared_gems
125
+ processed_source.ast.each_node(:send).with_object({}) do |node, declared|
126
+ name = gem_declaration(node)
127
+ declared[name.to_s] ||= node if name
128
+ end
129
+ end
130
+
131
+ # Dependencies that come from a gemspec, eval_gemfile, or similar have no `gem` line.
132
+ def fallback_location
133
+ processed_source.ast.each_node(:send).find { |node| gemspec_declaration?(node) } ||
134
+ processed_source.buffer.line_range(1)
135
+ end
136
+
137
+ def message_for_path(path)
138
+ return message_for(path.first) if path.one?
139
+
140
+ format(MSG_TRANSITIVE, gem: path.last, path: path.join(" -> "), advice: advice_for(path.last))
141
+ end
142
+
35
143
  def discouraged_gems
36
144
  @discouraged_gems ||= gems_config.keys.map(&:to_s)
37
145
  end
@@ -22,7 +22,15 @@ module RuboCop
22
22
  # In a list, a literal sitting beside a real `record.id` also qualifies on its own: you
23
23
  # would write another `record.id` if you wanted one that exists. That matters because
24
24
  # those groups are usually named for the plural ("with multiple bank account IDs") rather
25
- # than for the gap.
25
+ # than for the gap. Several literals each correct to their own negative (`-1`, `-2`), so
26
+ # the count the example asserts is preserved. A list only reads as record ids when every
27
+ # member is one: `['story', '123', '']` enumerates the shapes an id parser must reject,
28
+ # so its numeric member is a format, not a primary key.
29
+ #
30
+ # The description vocabulary is wider than "does not exist" on purpose. "Invalid company
31
+ # id", "belongs to another company" and "for a different employee" are, in practice,
32
+ # how specs name an id that matches no row -- and a positive literal there collides with
33
+ # the example's own record as soon as the counter reaches it.
26
34
  #
27
35
  # Five shapes are deliberately not offenses, because in none of them can the counter reach
28
36
  # the literal, or a single replacement would change what the example asserts:
@@ -91,14 +99,13 @@ module RuboCop
91
99
  (does|do|did)\s*n[o']?t\s+exist | \bnot\s+exist |
92
100
  \bnot\s+found\b | (cannot|can\s*not|can't|couldn't)\s+be\s+found |
93
101
  is\s*n[o']?t\s+found | non-?\s?existent | no\s+such |
94
- invalid\s+\w*\s*\bid\b | \bid\b\s+is\s+invalid |
102
+ invalid\s+(\w+\s+)?ids?\b | \bids?\s+(is|are)\s+invalid |
95
103
  does\s*n[o']?t\s+belong | not\s+belong\s+to | unauthorized\s+access |
96
104
  (another|other|different)\s+(account|company|customer|employee|member|org|person|user)
97
105
  /xi
98
106
 
99
107
  NOT_FOUND = /RecordNotFound/
100
108
  NOT_FOUND_STATUS = /\A:?(not_found|404)\z/
101
- BUILDERS = %i(build build! build_list build_stubbed build_stubbed_list create create! create_list).freeze
102
109
  DEFAULT_MAX_ID = 1_000_000
103
110
 
104
111
  # @!method let_definition(node)
@@ -147,19 +154,32 @@ module RuboCop
147
154
  register(literal)
148
155
  end
149
156
 
150
- # Only a lone literal qualifies. Two would both correct to `-1`, and a duplicated id in
151
- # a list collapses on lookup, changing the count the example asserts.
152
157
  def check_id_list(node, name, array)
153
- literals = array.children.select { |item| collidable_id(item) }
154
- return unless literals.one?
158
+ return if format_fixture?(array)
155
159
 
160
+ literals = array.children.select { |item| collidable_id(item) }
156
161
  group = enclosing_example_group(node)
157
- return unless list_qualifies?(array, group)
162
+ return if literals.empty? || !list_qualifies?(array, group)
163
+
164
+ register_list(literals, group, name)
165
+ end
158
166
 
159
- literal = literals.first
160
- return if group && built_with?(group, name, collidable_id(literal))
167
+ # Each literal gets its own negative: a duplicated id collapses on lookup, which would
168
+ # change the count the example asserts.
169
+ def register_list(literals, group, name)
170
+ literals.each_with_index do |literal, index|
171
+ next if group && built_with?(group, name, collidable_id(literal))
161
172
 
162
- register(literal)
173
+ register(literal, -(index + 1))
174
+ end
175
+ end
176
+
177
+ # `['story', 'story-', '123', '']` lists the shapes an id parser has to reject, so its
178
+ # numeric member is a format, not a primary key.
179
+ def format_fixture?(array)
180
+ array.children.any? do |item|
181
+ item.type?(:str, :sym) && Integer(item.value.to_s, exception: false).nil?
182
+ end
163
183
  end
164
184
 
165
185
  def list_qualifies?(array, group)
@@ -168,9 +188,9 @@ module RuboCop
168
188
  !group.nil? && absent_context?(group)
169
189
  end
170
190
 
171
- def register(literal)
191
+ def register(literal, replacement = -1)
172
192
  add_offense(literal) do |corrector|
173
- corrector.replace(literal, literal.str_type? ? "'-1'" : "-1")
193
+ corrector.replace(literal, literal.str_type? ? "'#{replacement}'" : replacement.to_s)
174
194
  end
175
195
  end
176
196
 
@@ -228,6 +248,12 @@ module RuboCop
228
248
  (any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
229
249
  PATTERN
230
250
 
251
+ # @!method builder_calls(node)
252
+ def_node_search :builder_calls, <<~PATTERN
253
+ $(send _ {:build :build! :build_list :build_pair :build_stubbed :build_stubbed_list
254
+ :create :create! :create_list :create_pair} ...)
255
+ PATTERN
256
+
231
257
  def initialize(group, max_id)
232
258
  @group = group
233
259
  @max_id = max_id
@@ -240,9 +266,11 @@ module RuboCop
240
266
  # A group that builds a record carrying this id means the row is meant to exist, so
241
267
  # the absence the group describes is about something else.
242
268
  def builds?(name, value)
243
- group.each_descendant(:send).any? do |send_node|
244
- BUILDERS.include?(send_node.method_name) &&
245
- send_node.each_descendant.any? { |arg| references?(arg, name) || same_id_value?(arg, value) }
269
+ builder_calls(group).any? do |call|
270
+ factory = factory_name(call)
271
+ call.each_descendant(:send, :pair).any? do |node|
272
+ references?(node, name) || own_attribute?(node, factory, value)
273
+ end
246
274
  end
247
275
  end
248
276
 
@@ -307,11 +335,23 @@ module RuboCop
307
335
  node.send_type? && node.receiver.nil? && node.method?(name)
308
336
  end
309
337
 
310
- # `create(:evaluation, company_id: 1)` beside `let(:company_id) { 1 }` builds the row
311
- # this id selects, even though the example inlined the literal rather than the `let`.
312
- def same_id_value?(node, value)
313
- node.pair_type? && node.key.type?(:sym, :str) &&
314
- node.key.value.to_s.match?(ID_NAME) && collidable_id(node.value) == value
338
+ def factory_name(call)
339
+ first_argument = call.first_argument
340
+ first_argument.value.to_s if first_argument&.sym_type?
341
+ end
342
+
343
+ # The built record carries the id when the key is its own primary key, or when the
344
+ # attribute is named for the factory, as in `create(:card, corepro_card_id: 123)`. A key
345
+ # naming another table is a foreign key: `create(:evaluation, company_id: 1)` points at
346
+ # the companies row the example says is absent rather than creating it, so it is not
347
+ # treated as building it.
348
+ def own_attribute?(node, factory, value)
349
+ return false unless node.pair_type? && node.key.type?(:sym, :str)
350
+
351
+ key = node.key.value.to_s
352
+ return false unless key.match?(ID_NAME) && collidable_id(node.value) == value
353
+
354
+ key == "id" || (!factory.nil? && key.include?(factory))
315
355
  end
316
356
 
317
357
  def own_lets(node, found = [])
@@ -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.0"
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.0
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