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 +4 -4
- data/CHANGELOG.md +20 -0
- data/config/gusto_cops.yml +16 -3
- data/config/rails.yml +0 -4
- data/lib/rubocop/cop/gusto/discouraged_gem.rb +115 -7
- data/lib/rubocop/cop/gusto/hardcoded_absent_record_id.rb +61 -21
- data/lib/rubocop/cop/gusto/timeout_timeout.rb +49 -0
- data/lib/rubocop/cop/gusto/unreferenced_let.rb +657 -54
- data/lib/rubocop/gusto/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 389e8e8a7531fb3ad32c281f658bc51fa59c0c65707599089e7c45f0b8cddf19
|
|
4
|
+
data.tar.gz: 3c9654b75327dc9420e107dc43fff4ac94b32586639f9a925794c98eb95d4e0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/config/gusto_cops.yml
CHANGED
|
@@ -32,9 +32,14 @@ Gusto/DescribedClassConstantReference:
|
|
|
32
32
|
- '**/spec/**/*'
|
|
33
33
|
|
|
34
34
|
Gusto/DiscouragedGem:
|
|
35
|
-
Description: 'Flags
|
|
36
|
-
Enabled:
|
|
37
|
-
|
|
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.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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: {
|
|
16
|
+
# @example Gems: { ostruct: "Use Struct or Data instead of OpenStruct." }
|
|
11
17
|
# # bad
|
|
12
|
-
# gem "
|
|
18
|
+
# gem "ostruct"
|
|
13
19
|
#
|
|
14
|
-
# #
|
|
15
|
-
#
|
|
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+\
|
|
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
|
-
|
|
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
|
|
162
|
+
return if literals.empty? || !list_qualifies?(array, group)
|
|
163
|
+
|
|
164
|
+
register_list(literals, group, name)
|
|
165
|
+
end
|
|
158
166
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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? ? "'
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
#
|
|
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(
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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?
|
|
198
|
-
|
|
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?
|
|
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)
|
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.
|
|
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
|