rubocop-gusto 11.9.1 → 11.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/config/gusto_cops.yml +8 -0
- 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,9 +3,22 @@
|
|
|
3
3
|
- Remove redundant `Rails: Enabled: true` from `config/rails.yml` (already set by rubocop-rails' own defaults)
|
|
4
4
|
- Enable `Rails/DefaultScope` cop (disabled by default in rubocop-rails)
|
|
5
5
|
|
|
6
|
+
## [11.10.0](https://github.com/Gusto/rubocop-gusto/compare/v11.9.1...v11.10.0) (2026-10-22)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add Gusto/TimeoutTimeout cop ([#195](https://github.com/Gusto/rubocop-gusto/issues/195)) ([ef28938](https://github.com/Gusto/rubocop-gusto/commit/ef28938194582f3e7f804694a6a9fa7f6b005b99))
|
|
12
|
+
* per-group scoping for Gusto/UnreferencedLet ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
|
|
13
|
+
* per-group UnreferencedLet scoping ([#187](https://github.com/Gusto/rubocop-gusto/issues/187)) ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
|
|
14
|
+
|
|
6
15
|
## [11.9.1](https://github.com/Gusto/rubocop-gusto/compare/v11.9.0...v11.9.1) (2026-09-28)
|
|
7
16
|
|
|
8
17
|
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* flag transitive discouraged gems via the lockfile and discourage ostruct ([#188](https://github.com/Gusto/rubocop-gusto/issues/188)) ([3fb0a2c](https://github.com/Gusto/rubocop-gusto/commit/3fb0a2c825777d768c4d2d92b8504eae44723d5a))
|
|
21
|
+
|
|
9
22
|
### Bug Fixes
|
|
10
23
|
|
|
11
24
|
* port upstream review of Gusto/HardcodedAbsentRecordId ([#180](https://github.com/Gusto/rubocop-gusto/issues/180)) ([6198928](https://github.com/Gusto/rubocop-gusto/commit/6198928187db6920c18c882c3167a1cf8edeccf1))
|
data/config/gusto_cops.yml
CHANGED
|
@@ -198,6 +198,11 @@ Gusto/SuddenAssociations:
|
|
|
198
198
|
Exclude:
|
|
199
199
|
- '**/spec/**/*' # allow stubbing, although this is still odd
|
|
200
200
|
|
|
201
|
+
Gusto/TimeoutTimeout:
|
|
202
|
+
Description: 'Flags `Timeout.timeout`, which interrupts its block with `Thread#raise` at an arbitrary point.'
|
|
203
|
+
Exclude:
|
|
204
|
+
- '**/spec/**/*'
|
|
205
|
+
|
|
201
206
|
Gusto/ToplevelConstants:
|
|
202
207
|
Description: 'Prevents top-level constants from being defined outside of initializers.'
|
|
203
208
|
Include:
|
|
@@ -220,6 +225,9 @@ Gusto/UnreferencedLet:
|
|
|
220
225
|
Include:
|
|
221
226
|
- '**/spec/**/*_spec.rb'
|
|
222
227
|
SafeAutoCorrect: false
|
|
228
|
+
PerExampleGroup: false
|
|
229
|
+
Cascade: false
|
|
230
|
+
LocalContextIncludes: []
|
|
223
231
|
|
|
224
232
|
Gusto/UsePaintNotColorize:
|
|
225
233
|
Description: 'Use Paint instead of colorize for terminal colors.'
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Gusto
|
|
6
|
+
# Flags calls to `Timeout.timeout`.
|
|
7
|
+
#
|
|
8
|
+
# `Timeout.timeout` stops its block by calling `Thread#raise` from a
|
|
9
|
+
# watcher thread, so the exception can land on any line of the block,
|
|
10
|
+
# including inside an `ensure` clause or halfway through a database write
|
|
11
|
+
# or a connection checkout. That can leak locks, return a half-read socket
|
|
12
|
+
# to a pool, or leave records partially updated. There is no safe way to
|
|
13
|
+
# interrupt an arbitrary block of code; see
|
|
14
|
+
# https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
|
|
15
|
+
#
|
|
16
|
+
# Instead, use the timeout the waiting operation itself supports, which
|
|
17
|
+
# fails at a known point: `Net::HTTP#read_timeout`, a database
|
|
18
|
+
# `statement_timeout`, `IO#timeout=`, `Queue#pop(timeout:)`, or a deadline
|
|
19
|
+
# checked with `Process.clock_gettime(Process::CLOCK_MONOTONIC)`.
|
|
20
|
+
#
|
|
21
|
+
# `Timeout` is a constant, so it is never nil and safe navigation
|
|
22
|
+
# (`Timeout&.timeout`) is never used; the cop only handles `on_send`.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # bad
|
|
26
|
+
# Timeout.timeout(5) { http.get(path) }
|
|
27
|
+
# ::Timeout.timeout(5, MyError) { do_work }
|
|
28
|
+
#
|
|
29
|
+
# # good
|
|
30
|
+
# http.read_timeout = 5
|
|
31
|
+
# http.get(path)
|
|
32
|
+
class TimeoutTimeout < Base
|
|
33
|
+
MSG = "Avoid `Timeout.timeout`; it interrupts the block with `Thread#raise` at an arbitrary point. " \
|
|
34
|
+
"Use the timeout option of the operation being waited on instead, " \
|
|
35
|
+
"such as `Net::HTTP#read_timeout` or a database `statement_timeout`."
|
|
36
|
+
RESTRICT_ON_SEND = %i(timeout).freeze
|
|
37
|
+
|
|
38
|
+
# @!method timeout_timeout?(node)
|
|
39
|
+
def_node_matcher :timeout_timeout?, <<~PATTERN
|
|
40
|
+
(send (const {nil? cbase} :Timeout) :timeout ...)
|
|
41
|
+
PATTERN
|
|
42
|
+
|
|
43
|
+
def on_send(node)
|
|
44
|
+
add_offense(node) if timeout_timeout?(node)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -13,8 +13,8 @@ module RuboCop
|
|
|
13
13
|
# side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is
|
|
14
14
|
# handled here.
|
|
15
15
|
#
|
|
16
|
-
#
|
|
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
|