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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 51513bd8e37d74a7df172f0f418d71dc91dfa804f94b8d771b48dc5b21b18e04
4
- data.tar.gz: 01af10674be546cc67050bad71849a846c3ed018c6e3c963b3d70109cc4015dc
3
+ metadata.gz: ae5d8c5b6e24aaf92ff21ac52781b6e6ad098d4893a0051b530d372bd6fee0d3
4
+ data.tar.gz: f95be1484e3291a03aa5fb2e337b18fcdcf48cb4762422bc0fc39bed4550ef6c
5
5
  SHA512:
6
- metadata.gz: 12e87528f2d9f51b6c5ee50dd3a1af68d919027ada144710a5e4eb87c71a80e3c525b3697936a2f8faeebbb8ea785c2d8182dfb75494e033e49205e0f5f361d7
7
- data.tar.gz: e6cc1a36174b943a266646c76ebaeba03e384432d5edd457d3c7a93c5a844aeba22e67847b72a3cd0489099e30eb73d0355ee923ee16a085c10a47c8a825a39a
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
- - '**/spec/**/*_spec.rb'
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
@@ -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 (only checks previously pre-stubbed objects)
25
- # expect(Foo).to have_received(:perform_async)
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. Autocorrect is also
32
- # suppressed when the expectation uses a block, since appending `.and_call_original` would
33
- # rebind the block to the wrong method.
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
- calls_original = false
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
- calls_original = true if current.method?(:and_call_original) || current.method?(:and_wrap_original)
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 calls_original # already have .and_call_original, not an offense
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?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Gusto
5
- VERSION = "11.8.1"
5
+ VERSION = "11.9.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-gusto
3
3
  version: !ruby/object:Gem::Version
4
- version: 11.8.1
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