rubocop-gusto 11.8.1 → 11.9.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 +10 -0
- data/config/default.yml +6 -1
- data/config/gusto_cops.yml +17 -0
- data/lib/rubocop/cop/gusto/hardcoded_absent_record_id.rb +330 -0
- data/lib/rubocop/cop/gusto/hardcoded_id.rb +110 -0
- data/lib/rubocop/cop/sidekiq/perform_async_stub.rb +27 -11
- data/lib/rubocop/gusto/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae5d8c5b6e24aaf92ff21ac52781b6e6ad098d4893a0051b530d372bd6fee0d3
|
|
4
|
+
data.tar.gz: f95be1484e3291a03aa5fb2e337b18fcdcf48cb4762422bc0fc39bed4550ef6c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c773623cef1e70d0289490fd8409a30d05ce91c0051ac29f7be5946849d8d3087ca9edb26b90e54cb14b9afe99c26fa712063810be1ba74252010ac32d26706
|
|
7
|
+
data.tar.gz: a0e8c1775d2850925cdda007713667410a2ab32f60b73f928f372bfa24ec4fa4ab2a707466aca9ef10ce4b5e61407e0c3a9752b489ae9c9e5a8f0f92bfa57533
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.9.0](https://github.com/Gusto/rubocop-gusto/compare/v11.8.1...v11.9.0) (2026-09-24)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* add Gusto/HardcodedAbsentRecordId cop ([#166](https://github.com/Gusto/rubocop-gusto/issues/166)) ([f38282a](https://github.com/Gusto/rubocop-gusto/commit/f38282af1034a9d286a77ff8291bb551c91239ea))
|
|
12
|
+
* add Gusto/HardcodedId cop ([#167](https://github.com/Gusto/rubocop-gusto/issues/167)) ([4cf3fff](https://github.com/Gusto/rubocop-gusto/commit/4cf3fff30192582cc7f19542172ab435b39c4839))
|
|
13
|
+
* enforce with_fixed_indentation for Layout/ArgumentAlignment ([#165](https://github.com/Gusto/rubocop-gusto/issues/165)) ([2e4d8e7](https://github.com/Gusto/rubocop-gusto/commit/2e4d8e73c3f4f50b0a7ea1c32261beccac1bc4d5))
|
|
14
|
+
* flag have_received and allow and_raise in Sidekiq/PerformAsyncStub ([#174](https://github.com/Gusto/rubocop-gusto/issues/174)) ([f6e2289](https://github.com/Gusto/rubocop-gusto/commit/f6e2289f2052a41d9387a481616e6c22b45004f2))
|
|
15
|
+
|
|
6
16
|
## [11.8.1](https://github.com/Gusto/rubocop-gusto/compare/v11.8.0...v11.8.1) (2026-09-22)
|
|
7
17
|
|
|
8
18
|
|
data/config/default.yml
CHANGED
|
@@ -32,6 +32,9 @@ Gemspec/RequiredRubyVersion:
|
|
|
32
32
|
# We manage our Ruby version in our version file and import that to our Gemfile.
|
|
33
33
|
Enabled: false
|
|
34
34
|
|
|
35
|
+
Layout/ArgumentAlignment:
|
|
36
|
+
EnforcedStyle: with_fixed_indentation
|
|
37
|
+
|
|
35
38
|
Layout/BlockAlignment:
|
|
36
39
|
EnforcedStyleAlignWith: start_of_block
|
|
37
40
|
|
|
@@ -267,7 +270,9 @@ Sidekiq/PerformAsyncStub:
|
|
|
267
270
|
Enabled: false
|
|
268
271
|
SafeAutoCorrect: false # Autocorrect appends `.and_call_original`, which runs real `perform_async` during the example
|
|
269
272
|
Include:
|
|
270
|
-
|
|
273
|
+
# Not just `*_spec.rb`: shared contexts, support files and system-spec helpers install these
|
|
274
|
+
# stubs on behalf of the specs that include them, which is where they are hardest to spot.
|
|
275
|
+
- '**/spec/**/*'
|
|
271
276
|
|
|
272
277
|
Sorbet/ForbidTUnsafe:
|
|
273
278
|
# T.unsafe completely disables typechecking. Prefer T.cast, shims, or
|
data/config/gusto_cops.yml
CHANGED
|
@@ -90,6 +90,23 @@ Gusto/Graphql/TypeVariables:
|
|
|
90
90
|
Description: 'Requires `type_member` and `type_template` in GraphQL objects to name a type rather than `T.untyped`.'
|
|
91
91
|
Enabled: false
|
|
92
92
|
|
|
93
|
+
Gusto/HardcodedAbsentRecordId:
|
|
94
|
+
Description: 'Flags a hardcoded positive id standing in for a record an example expects to be absent.'
|
|
95
|
+
Include:
|
|
96
|
+
- '**/spec/**/*_spec.rb'
|
|
97
|
+
# Names of `*_id` fields holding a third-party identifier rather than a primary key the
|
|
98
|
+
# auto-increment counter can reach. Prefer adding a name here over a file-path exclude.
|
|
99
|
+
AllowedNames: []
|
|
100
|
+
MaxId: 1000000
|
|
101
|
+
SafeAutoCorrect: false
|
|
102
|
+
|
|
103
|
+
Gusto/HardcodedId:
|
|
104
|
+
Description: 'Bans passing a hardcoded `id:` to create, which forces a primary key.'
|
|
105
|
+
Include:
|
|
106
|
+
- '**/spec/**/*_spec.rb'
|
|
107
|
+
AllowedFactories: []
|
|
108
|
+
AllowedReceivers: []
|
|
109
|
+
|
|
93
110
|
Gusto/MinByMaxBy:
|
|
94
111
|
Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
|
|
95
112
|
Safe: false
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop-rspec"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Gusto
|
|
8
|
+
# Flags a hardcoded positive id standing in for a record an example expects to be absent.
|
|
9
|
+
#
|
|
10
|
+
# Auto-increment climbs across every example on a test node and is not reclaimed by
|
|
11
|
+
# transactional rollback, so the counter eventually reaches the literal. From then on a
|
|
12
|
+
# real row occupies it, the lookup succeeds, and the example stops testing absence --
|
|
13
|
+
# passing locally, where the counter is low, and failing in CI. A negative id cannot
|
|
14
|
+
# collide, because auto-increment never emits one.
|
|
15
|
+
#
|
|
16
|
+
# An id counts as standing in for an absent record when its own example group either
|
|
17
|
+
# asserts absence (`ActiveRecord::RecordNotFound`, a `:not_found` response) or says so in
|
|
18
|
+
# its description. Both are read from the group the `let` is written in, not the whole
|
|
19
|
+
# file, so a shared id in an outer group is not attributed to a nested example that never
|
|
20
|
+
# uses it.
|
|
21
|
+
#
|
|
22
|
+
# In a list, a literal sitting beside a real `record.id` also qualifies on its own: you
|
|
23
|
+
# would write another `record.id` if you wanted one that exists. That matters because
|
|
24
|
+
# those groups are usually named for the plural ("with multiple bank account IDs") rather
|
|
25
|
+
# than for the gap.
|
|
26
|
+
#
|
|
27
|
+
# Five shapes are deliberately not offenses, because in none of them can the counter reach
|
|
28
|
+
# the literal, or a single replacement would change what the example asserts:
|
|
29
|
+
#
|
|
30
|
+
# - a group that builds a record carrying that id -- the row is meant to exist, so the
|
|
31
|
+
# absence the group describes is about something else, a cache or a header;
|
|
32
|
+
# - an `ActiveRecord::RecordNotFound` raised by reloading a row the example just deleted,
|
|
33
|
+
# which is a statement about that object rather than about the literal;
|
|
34
|
+
# - a literal above `MaxId`, which is a deliberate never-collides sentinel;
|
|
35
|
+
# - a group pinning two ids, which are usually pinned to differ from each other, and one
|
|
36
|
+
# shared replacement collapses that and inverts the example; and
|
|
37
|
+
# - an example group under VCR, whose cassette matches on the id in the request URI.
|
|
38
|
+
#
|
|
39
|
+
# @safety
|
|
40
|
+
# Autocorrection is unsafe. `-1` is right for a primary key, but it also changes the
|
|
41
|
+
# value the example feeds to everything else, so an id matched against a stubbed request
|
|
42
|
+
# URI or an asserted error message needs those updated to match. `-1` is not free
|
|
43
|
+
# everywhere either: a codebase may reserve it as a sentinel of its own, and an unsigned
|
|
44
|
+
# column or protobuf field rejects it outright. Where a negative will not do, a sentinel
|
|
45
|
+
# above `MaxId` is the sanctioned alternative.
|
|
46
|
+
#
|
|
47
|
+
# @example
|
|
48
|
+
# # bad
|
|
49
|
+
# context 'when the company does not exist' do
|
|
50
|
+
# let(:company_id) { 123 }
|
|
51
|
+
#
|
|
52
|
+
# it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# # bad - the literal is there precisely because no record has it
|
|
56
|
+
# let(:bank_account_ids) { [bank_account.id, other_account.id, 999_999] }
|
|
57
|
+
#
|
|
58
|
+
# # good - auto-increment never emits a negative id
|
|
59
|
+
# context 'when the company does not exist' do
|
|
60
|
+
# let(:company_id) { -1 }
|
|
61
|
+
#
|
|
62
|
+
# it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# # good - a deleted record's id is a reference, not a magic constant
|
|
66
|
+
# context 'when the company does not exist' do
|
|
67
|
+
# let(:company_id) { deleted_company.id }
|
|
68
|
+
# end
|
|
69
|
+
#
|
|
70
|
+
# @example AllowedNames: ['wise_id'] (default: [])
|
|
71
|
+
# # good - a third-party identifier, not a primary key the counter can reach
|
|
72
|
+
# context 'when the transfer does not exist' do
|
|
73
|
+
# let(:wise_id) { 5678 }
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# @example MaxId: 1000000 (default)
|
|
77
|
+
# # good - above MaxId the literal is a deliberate never-collides sentinel
|
|
78
|
+
# let(:company_id) { 9_999_999_999 }
|
|
79
|
+
class HardcodedAbsentRecordId < ::RuboCop::Cop::RSpec::Base
|
|
80
|
+
extend AutoCorrector
|
|
81
|
+
|
|
82
|
+
MSG = "Use a negative id for a record expected to be absent."
|
|
83
|
+
RESTRICT_ON_SEND = %i(let let!).freeze
|
|
84
|
+
|
|
85
|
+
# `id` itself, or any `<something>_id`, singular or plural.
|
|
86
|
+
ID_NAME = /\A(ids?|.+_ids?)\z/
|
|
87
|
+
|
|
88
|
+
# Each phrase is unambiguous on its own: a bare "missing" or "unknown" reads just as
|
|
89
|
+
# naturally about a header or a flag, so neither is here.
|
|
90
|
+
ABSENT_DESCRIPTION = /
|
|
91
|
+
(does|do|did)\s*n[o']?t\s+exist | \bnot\s+exist |
|
|
92
|
+
\bnot\s+found\b | (cannot|can\s*not|can't|couldn't)\s+be\s+found |
|
|
93
|
+
is\s*n[o']?t\s+found | non-?\s?existent | no\s+such |
|
|
94
|
+
invalid\s+\w*\s*\bid\b | \bid\b\s+is\s+invalid |
|
|
95
|
+
does\s*n[o']?t\s+belong | not\s+belong\s+to | unauthorized\s+access |
|
|
96
|
+
(another|other|different)\s+(account|company|customer|employee|member|org|person|user)
|
|
97
|
+
/xi
|
|
98
|
+
|
|
99
|
+
NOT_FOUND = /RecordNotFound/
|
|
100
|
+
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
|
+
DEFAULT_MAX_ID = 1_000_000
|
|
103
|
+
|
|
104
|
+
# @!method let_definition(node)
|
|
105
|
+
def_node_matcher :let_definition, <<~PATTERN
|
|
106
|
+
(any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
|
|
107
|
+
PATTERN
|
|
108
|
+
|
|
109
|
+
# @!method vcr_metadata?(node)
|
|
110
|
+
def_node_matcher :vcr_metadata?, <<~PATTERN
|
|
111
|
+
{(sym :vcr) (hash <(pair {(sym :vcr) (str "vcr")} _) ...>)}
|
|
112
|
+
PATTERN
|
|
113
|
+
|
|
114
|
+
def on_send(node)
|
|
115
|
+
return unless node.parent&.type?(:any_block)
|
|
116
|
+
|
|
117
|
+
name, body = let_definition(node.parent)
|
|
118
|
+
return unless body && id_name?(name)
|
|
119
|
+
return if cassette_controlled?(node)
|
|
120
|
+
|
|
121
|
+
if body.array_type?
|
|
122
|
+
check_id_list(node, name, body)
|
|
123
|
+
else
|
|
124
|
+
check_id_literal(node, name, body)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
# A third-party identifier stored in a `*_id` field is not a primary key the
|
|
131
|
+
# auto-increment counter can reach, so a name listed in `AllowedNames` is skipped
|
|
132
|
+
# outright -- a named exception in config beats a file-path exclude or a disable.
|
|
133
|
+
def id_name?(name)
|
|
134
|
+
name.to_s.match?(ID_NAME) && !allowed_names.include?(name.to_s)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def allowed_names
|
|
138
|
+
@allowed_names ||= Array(cop_config["AllowedNames"]).to_set(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def check_id_literal(node, name, literal)
|
|
142
|
+
value = collidable_id(literal)
|
|
143
|
+
group = value && enclosing_example_group(node)
|
|
144
|
+
return unless group && absent_context?(group)
|
|
145
|
+
return if built_with?(group, name, value) || paired_ids?(group)
|
|
146
|
+
|
|
147
|
+
register(literal)
|
|
148
|
+
end
|
|
149
|
+
|
|
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
|
+
def check_id_list(node, name, array)
|
|
153
|
+
literals = array.children.select { |item| collidable_id(item) }
|
|
154
|
+
return unless literals.one?
|
|
155
|
+
|
|
156
|
+
group = enclosing_example_group(node)
|
|
157
|
+
return unless list_qualifies?(array, group)
|
|
158
|
+
|
|
159
|
+
literal = literals.first
|
|
160
|
+
return if group && built_with?(group, name, collidable_id(literal))
|
|
161
|
+
|
|
162
|
+
register(literal)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def list_qualifies?(array, group)
|
|
166
|
+
return true if array.children.any? { |item| item.send_type? && item.method?(:id) }
|
|
167
|
+
|
|
168
|
+
!group.nil? && absent_context?(group)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def register(literal)
|
|
172
|
+
add_offense(literal) do |corrector|
|
|
173
|
+
corrector.replace(literal, literal.str_type? ? "'-1'" : "-1")
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A string id reaches the same column as an integer one, so both count; only the sign
|
|
178
|
+
# and the magnitude matter.
|
|
179
|
+
def collidable_id(node)
|
|
180
|
+
value = case node.type
|
|
181
|
+
when :int then node.value
|
|
182
|
+
when :str then Integer(node.value, exception: false)
|
|
183
|
+
end
|
|
184
|
+
value if value&.positive? && value <= max_id
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def max_id
|
|
188
|
+
cop_config.fetch("MaxId", DEFAULT_MAX_ID)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def enclosing_example_group(node)
|
|
192
|
+
node.each_ancestor(:any_block).find { |ancestor| example_group?(ancestor) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def absent_context?(group)
|
|
196
|
+
inspect_group(group).absent?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def built_with?(group, name, value)
|
|
200
|
+
inspect_group(group).builds?(name, value)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def paired_ids?(group)
|
|
204
|
+
inspect_group(group).paired_ids?
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def inspect_group(group)
|
|
208
|
+
GroupInspector.new(group, max_id)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# A cassette keys its recorded interactions off the request URI, so an id in the path is
|
|
212
|
+
# what tells two same-route requests apart. Rewriting it makes the example miss the
|
|
213
|
+
# recording rather than assert absence, and `record: :none` then fails it outright.
|
|
214
|
+
def cassette_controlled?(node)
|
|
215
|
+
node.each_ancestor(:any_block).any? do |ancestor|
|
|
216
|
+
example_group?(ancestor) && ancestor.send_node.arguments.any? { |arg| vcr_metadata?(arg) }
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Answers the group-level questions: does this group say the record is absent, does it
|
|
221
|
+
# build the record, and does it pin two ids that are meant to differ?
|
|
222
|
+
class GroupInspector
|
|
223
|
+
include ::RuboCop::RSpec::Language
|
|
224
|
+
extend ::RuboCop::AST::NodePattern::Macros
|
|
225
|
+
|
|
226
|
+
# @!method let_definition(node)
|
|
227
|
+
def_node_matcher :let_definition, <<~PATTERN
|
|
228
|
+
(any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
|
|
229
|
+
PATTERN
|
|
230
|
+
|
|
231
|
+
def initialize(group, max_id)
|
|
232
|
+
@group = group
|
|
233
|
+
@max_id = max_id
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def absent?
|
|
237
|
+
absent_description? || not_found_assertion?(group.body)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# A group that builds a record carrying this id means the row is meant to exist, so
|
|
241
|
+
# the absence the group describes is about something else.
|
|
242
|
+
def builds?(name, value)
|
|
243
|
+
group.each_descendant(:send).any? do |send_node|
|
|
244
|
+
BUILDERS.include?(send_node.method_name) &&
|
|
245
|
+
send_node.each_descendant.any? { |arg| references?(arg, name) || same_id_value?(arg, value) }
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Two ids pinned in one group are usually pinned to differ from each other -- a record
|
|
250
|
+
# and the "other company" it must not match. One shared replacement collapses that.
|
|
251
|
+
def paired_ids?
|
|
252
|
+
own_lets(group.body).count { |_name, body| collidable_id(body) } > 1
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private
|
|
256
|
+
|
|
257
|
+
attr_reader :group, :max_id
|
|
258
|
+
|
|
259
|
+
# Matched against the source rather than the value so an interpolated description
|
|
260
|
+
# ("when #{model} does not exist") still reads as one.
|
|
261
|
+
def absent_description?
|
|
262
|
+
description = group.send_node.first_argument
|
|
263
|
+
return false unless description&.type?(:str, :dstr, :sym)
|
|
264
|
+
|
|
265
|
+
ABSENT_DESCRIPTION.match?(description.source)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Scoped to this group: the walk stops at a nested example group, whose assertions
|
|
269
|
+
# belong to whatever that group redefines rather than to this `let`.
|
|
270
|
+
def not_found_assertion?(node)
|
|
271
|
+
return false if node.type?(:any_block) && example_group?(node)
|
|
272
|
+
return true if node.send_type? && not_found_matcher?(node)
|
|
273
|
+
|
|
274
|
+
node.each_child_node.any? { |child| not_found_assertion?(child) }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def not_found_matcher?(node)
|
|
278
|
+
case node.method_name
|
|
279
|
+
when :raise_error, :raise_exception
|
|
280
|
+
node.arguments.any? { |arg| NOT_FOUND.match?(arg.source) } && !deleted_record_assertion?(node)
|
|
281
|
+
when :have_http_status
|
|
282
|
+
NOT_FOUND_STATUS.match?(node.first_argument&.source.to_s)
|
|
283
|
+
when :be_not_found then true
|
|
284
|
+
else false
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# `expect { record.reload }.to raise_error(ActiveRecord::RecordNotFound)` asserts that
|
|
289
|
+
# a row the example already holds was deleted by the code under test. That is about
|
|
290
|
+
# the object, not about the literal -- which is usually what selected it for deletion.
|
|
291
|
+
def deleted_record_assertion?(node)
|
|
292
|
+
subject = node.each_ancestor(:send).first&.receiver
|
|
293
|
+
return false unless subject&.type?(:any_block)
|
|
294
|
+
|
|
295
|
+
subject.body&.send_type? && subject.body.method?(:reload)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def collidable_id(node)
|
|
299
|
+
value = case node.type
|
|
300
|
+
when :int then node.value
|
|
301
|
+
when :str then Integer(node.value, exception: false)
|
|
302
|
+
end
|
|
303
|
+
value if value&.positive? && value <= max_id
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def references?(node, name)
|
|
307
|
+
node.send_type? && node.receiver.nil? && node.method?(name)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# `create(:evaluation, company_id: 1)` beside `let(:company_id) { 1 }` builds the row
|
|
311
|
+
# this id selects, even though the example inlined the literal rather than the `let`.
|
|
312
|
+
def same_id_value?(node, value)
|
|
313
|
+
node.pair_type? && node.key.type?(:sym, :str) &&
|
|
314
|
+
node.key.value.to_s.match?(ID_NAME) && collidable_id(node.value) == value
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def own_lets(node, found = [])
|
|
318
|
+
return found if node.type?(:any_block) && example_group?(node)
|
|
319
|
+
|
|
320
|
+
definition = let_definition(node)
|
|
321
|
+
body = definition&.last
|
|
322
|
+
found << definition if body && !body.array_type?
|
|
323
|
+
node.each_child_node { |child| own_lets(child, found) }
|
|
324
|
+
found
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Gusto
|
|
6
|
+
# Flags a hardcoded `id:` passed to `create`/`create!`/`create_list`.
|
|
7
|
+
#
|
|
8
|
+
# Forcing a primary key in a spec collides with whatever else occupies that row --
|
|
9
|
+
# fixtures, parallel workers, the table's auto-increment sequence -- and is a common
|
|
10
|
+
# source of flaky, order-dependent failures. Let the database assign the id and reference
|
|
11
|
+
# the returned record.
|
|
12
|
+
#
|
|
13
|
+
# Only the persisting builders are flagged: they insert a row, so a forced primary key is
|
|
14
|
+
# an outright collision risk. `create_list` is worse still, forcing the identical id onto
|
|
15
|
+
# every generated record, which is a guaranteed unique-constraint failure. `build` and
|
|
16
|
+
# `build_stubbed` never insert, so they are left alone.
|
|
17
|
+
#
|
|
18
|
+
# Both `id:` and `'id' =>` are flagged: FactoryBot symbolizes override keys and
|
|
19
|
+
# ActiveRecord treats string and symbol attribute keys identically on mass-assignment, so
|
|
20
|
+
# a string-rocket key forces the same primary key.
|
|
21
|
+
#
|
|
22
|
+
# Only literal values are flagged. A value read from another record or a variable
|
|
23
|
+
# (`id: company.id`, `id: company_id`) is a reference, not a magic constant.
|
|
24
|
+
#
|
|
25
|
+
# Two shapes are configured away rather than disabled per site, because in neither is the
|
|
26
|
+
# literal a database primary key:
|
|
27
|
+
#
|
|
28
|
+
# - `AllowedFactories` -- factories that build a value object instead of inserting a row
|
|
29
|
+
# (`skip_create` / `initialize_with`), so `id:` is the object's own identity; plus the
|
|
30
|
+
# rare table declared `create_table ..., id: false`, whose column has no auto-increment
|
|
31
|
+
# and so requires an explicit id.
|
|
32
|
+
# - `AllowedReceivers` -- constants whose `create` is not ActiveRecord's, such as a
|
|
33
|
+
# `T::Struct` whose `id:` is a required keyword argument.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# # bad
|
|
37
|
+
# create(:company, id: 123)
|
|
38
|
+
# create(:company, 'id' => 123)
|
|
39
|
+
# create_list(:company, 3, id: 123)
|
|
40
|
+
# Company.create!(id: '123456789')
|
|
41
|
+
#
|
|
42
|
+
# # good - let the database assign the id
|
|
43
|
+
# company = create(:company)
|
|
44
|
+
#
|
|
45
|
+
# # good - a transitive record id is a reference, not a magic constant
|
|
46
|
+
# create(:employee, id: company.id)
|
|
47
|
+
#
|
|
48
|
+
# # better - pass the association so the child's own id is never forced
|
|
49
|
+
# create(:employee, company:)
|
|
50
|
+
#
|
|
51
|
+
# @example AllowedFactories: ['money'] (default: [])
|
|
52
|
+
# # good - a factory that builds a value object rather than inserting a row
|
|
53
|
+
# create(:money, id: 123)
|
|
54
|
+
#
|
|
55
|
+
# @example AllowedReceivers: ['Reporting::Row'] (default: [])
|
|
56
|
+
# # good - a constant whose `create` is not ActiveRecord's
|
|
57
|
+
# Reporting::Row.create(id: 123)
|
|
58
|
+
class HardcodedId < Base
|
|
59
|
+
MSG = "Do not pass a hardcoded `id:` to `create`/`create!`/`create_list`; let the database assign it."
|
|
60
|
+
RESTRICT_ON_SEND = %i(create create! create_list).freeze
|
|
61
|
+
|
|
62
|
+
def on_send(node)
|
|
63
|
+
return if allowed_receiver?(node) || allowed_factory?(node)
|
|
64
|
+
|
|
65
|
+
node.arguments.each do |argument|
|
|
66
|
+
# Every call shape (bare trailing kwargs, braced hash, positional hash) parses to a
|
|
67
|
+
# plain `hash` node.
|
|
68
|
+
next unless argument.hash_type?
|
|
69
|
+
|
|
70
|
+
argument.pairs.each { |pair| add_offense(pair) if hardcoded_id?(pair) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
alias_method :on_csend, :on_send
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def hardcoded_id?(pair)
|
|
78
|
+
key = pair.key
|
|
79
|
+
return false unless key.type?(:sym, :str) && key.value.to_s == "id"
|
|
80
|
+
|
|
81
|
+
pair.value.type?(:int, :str)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `::Foo::Bar` and `Foo::Bar` name the same constant, so compare without the leading
|
|
85
|
+
# scope operator.
|
|
86
|
+
def allowed_receiver?(node)
|
|
87
|
+
receiver = node.receiver
|
|
88
|
+
return false unless receiver
|
|
89
|
+
|
|
90
|
+
allowed_receivers.include?(receiver.source.delete_prefix("::"))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def allowed_factory?(node)
|
|
94
|
+
factory = node.first_argument
|
|
95
|
+
return false unless factory&.sym_type?
|
|
96
|
+
|
|
97
|
+
allowed_factories.include?(factory.value.to_s)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def allowed_receivers
|
|
101
|
+
@allowed_receivers ||= Array(cop_config["AllowedReceivers"]).to_set { |name| name.to_s.delete_prefix("::") }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def allowed_factories
|
|
105
|
+
@allowed_factories ||= Array(cop_config["AllowedFactories"]).to_set(&:to_s)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -11,6 +11,10 @@ module RuboCop
|
|
|
11
11
|
# expect(Foo).to receive(:perform_async)
|
|
12
12
|
# expect(Foo).not_to receive(:perform_async)
|
|
13
13
|
#
|
|
14
|
+
# # bad - the stub it verifies is itself the offense, and it may have been
|
|
15
|
+
# # set up in a shared context this cop cannot see
|
|
16
|
+
# expect(Foo).to have_received(:perform_async)
|
|
17
|
+
#
|
|
14
18
|
# # good (still invokes the real method)
|
|
15
19
|
# allow(Foo).to receive(:perform_async).and_call_original
|
|
16
20
|
# expect(Foo).to receive(:perform_async).with(arg).and_call_original
|
|
@@ -21,45 +25,57 @@ module RuboCop
|
|
|
21
25
|
# expect { subject }.not_to change(Foo.jobs, :count)
|
|
22
26
|
# expect(Foo.jobs.count).to eq(n)
|
|
23
27
|
#
|
|
24
|
-
# # good
|
|
25
|
-
#
|
|
28
|
+
# # good - there is no other way to make an enqueue fail. Sidekiq's testing API
|
|
29
|
+
# # pushes onto Foo.jobs and offers no failure injection, so a spec covering the
|
|
30
|
+
# # rescue around an enqueue has to raise from the stub.
|
|
31
|
+
# allow(Foo).to receive(:perform_async).and_raise(StandardError, "redis down")
|
|
26
32
|
#
|
|
27
33
|
# @safety
|
|
28
34
|
# Autocorrect is unsafe: it appends `.and_call_original` on positive `receive` only, which runs
|
|
29
35
|
# the real `perform_async` during the example (may enqueue jobs, hit external code, or
|
|
30
36
|
# change expectations vs a pure stub). There is no autocorrect for `not_to` / `to_not receive`,
|
|
31
|
-
# since `.and_call_original` would not apply to a negative expectation
|
|
32
|
-
#
|
|
33
|
-
#
|
|
37
|
+
# since `.and_call_original` would not apply to a negative expectation, nor for
|
|
38
|
+
# `have_received`, whose stub lives elsewhere. Autocorrect is also suppressed when the
|
|
39
|
+
# expectation uses a block, since appending `.and_call_original` would rebind the block to
|
|
40
|
+
# the wrong method.
|
|
34
41
|
class PerformAsyncStub < Base
|
|
35
42
|
extend AutoCorrector
|
|
36
43
|
|
|
37
44
|
MSG = "Prefer checking enqueued jobs over stubbing `perform_async`."
|
|
38
45
|
MSG_RECEIVE = "Prefer checking enqueued jobs over stubbing `perform_async` or add `.and_call_original`."
|
|
39
|
-
RESTRICT_ON_SEND = %i(receive).freeze
|
|
46
|
+
RESTRICT_ON_SEND = %i(receive have_received).freeze
|
|
47
|
+
|
|
48
|
+
# Chain modifiers that leave the real `perform_async` reachable, so the example is not
|
|
49
|
+
# replacing the enqueue with a canned result. `and_raise` belongs here even though it
|
|
50
|
+
# never calls the original: simulating a failed enqueue is the one thing Sidekiq's
|
|
51
|
+
# testing API cannot express, so a spec covering the rescue has no other route.
|
|
52
|
+
ALLOWED_CHAIN = %i(and_call_original and_wrap_original and_raise).freeze
|
|
40
53
|
|
|
41
|
-
# TODO: this should match on perform_async, not on receive, requires pattern update
|
|
42
54
|
# @!method stub_perform_async?(node)
|
|
43
55
|
def_node_matcher :stub_perform_async?, <<~PATTERN
|
|
44
|
-
(send nil? :receive (sym :perform_async))
|
|
56
|
+
(send nil? {:receive :have_received} (sym :perform_async))
|
|
45
57
|
PATTERN
|
|
46
58
|
|
|
47
59
|
def on_send(node)
|
|
48
60
|
return unless stub_perform_async?(node)
|
|
61
|
+
# `have_received` only verifies a stub that was installed somewhere else, possibly in a
|
|
62
|
+
# shared context or support file out of this cop's reach, so it is flagged on sight and
|
|
63
|
+
# there is nothing local to autocorrect.
|
|
64
|
+
return add_offense(node) if node.method?(:have_received)
|
|
49
65
|
|
|
50
66
|
negative_expectation = false
|
|
51
|
-
|
|
67
|
+
allowed_chain = false
|
|
52
68
|
|
|
53
69
|
current = node.parent
|
|
54
70
|
while current&.call_type?
|
|
55
71
|
negative_expectation = true if current.method?(:not_to) || current.method?(:to_not)
|
|
56
|
-
|
|
72
|
+
allowed_chain = true if ALLOWED_CHAIN.include?(current.method_name)
|
|
57
73
|
|
|
58
74
|
current = current.parent
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
return add_offense(node) if negative_expectation
|
|
62
|
-
return if
|
|
78
|
+
return if allowed_chain
|
|
63
79
|
|
|
64
80
|
tail = message_expectation_chain_tail(node)
|
|
65
81
|
return add_offense(node, message: MSG_RECEIVE) if tail.parent&.block_type?
|
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.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gusto Engineering
|
|
@@ -174,6 +174,8 @@ files:
|
|
|
174
174
|
- lib/rubocop/cop/gusto/graphql/prevent_field_can_can_action.rb
|
|
175
175
|
- lib/rubocop/cop/gusto/graphql/resolver_ignores_object.rb
|
|
176
176
|
- lib/rubocop/cop/gusto/graphql/type_variables.rb
|
|
177
|
+
- lib/rubocop/cop/gusto/hardcoded_absent_record_id.rb
|
|
178
|
+
- lib/rubocop/cop/gusto/hardcoded_id.rb
|
|
177
179
|
- lib/rubocop/cop/gusto/min_by_max_by.rb
|
|
178
180
|
- lib/rubocop/cop/gusto/no_metaprogramming.rb
|
|
179
181
|
- lib/rubocop/cop/gusto/no_rescue_error_message_checking.rb
|