rubocop-gusto 11.8.1 → 11.9.1

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: 5581a398e181fa5cee526273621f36357e83dba9fe912433df5a4cbe2129b2e7
4
+ data.tar.gz: db3825a6506974aa565e427d2e83fa1cbb618b31136c4bdbbb13840e81dd1eb1
5
5
  SHA512:
6
- metadata.gz: 12e87528f2d9f51b6c5ee50dd3a1af68d919027ada144710a5e4eb87c71a80e3c525b3697936a2f8faeebbb8ea785c2d8182dfb75494e033e49205e0f5f361d7
7
- data.tar.gz: e6cc1a36174b943a266646c76ebaeba03e384432d5edd457d3c7a93c5a844aeba22e67847b72a3cd0489099e30eb73d0355ee923ee16a085c10a47c8a825a39a
6
+ metadata.gz: '089fac7ad4fea249760ba0d5138e78a42152f198796b7f920d78ee656ba5cfb4d56b66a20918a90488431534b497110b366a66f467703c22797d5d4a51a94aba'
7
+ data.tar.gz: 681ef77cd2115bac46da1f0713adfa9fd05ed77204cec941c47cbe7a45d30b62d2b3128997665567923eaaa6509464f10c78867869fed7aed34d9b7bc3870f21
data/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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.1](https://github.com/Gusto/rubocop-gusto/compare/v11.9.0...v11.9.1) (2026-09-28)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+ * port upstream review of Gusto/HardcodedAbsentRecordId ([#180](https://github.com/Gusto/rubocop-gusto/issues/180)) ([6198928](https://github.com/Gusto/rubocop-gusto/commit/6198928187db6920c18c882c3167a1cf8edeccf1))
12
+
13
+ ## [11.9.0](https://github.com/Gusto/rubocop-gusto/compare/v11.8.1...v11.9.0) (2026-09-24)
14
+
15
+
16
+ ### Features
17
+
18
+ * add Gusto/HardcodedAbsentRecordId cop ([#166](https://github.com/Gusto/rubocop-gusto/issues/166)) ([f38282a](https://github.com/Gusto/rubocop-gusto/commit/f38282af1034a9d286a77ff8291bb551c91239ea))
19
+ * add Gusto/HardcodedId cop ([#167](https://github.com/Gusto/rubocop-gusto/issues/167)) ([4cf3fff](https://github.com/Gusto/rubocop-gusto/commit/4cf3fff30192582cc7f19542172ab435b39c4839))
20
+ * 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))
21
+ * 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))
22
+
6
23
  ## [11.8.1](https://github.com/Gusto/rubocop-gusto/compare/v11.8.0...v11.8.1) (2026-09-22)
7
24
 
8
25
 
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
@@ -32,9 +32,14 @@ Gusto/DescribedClassConstantReference:
32
32
  - '**/spec/**/*'
33
33
 
34
34
  Gusto/DiscouragedGem:
35
- Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
36
- Enabled: false
37
- Gems: {}
35
+ Description: 'Flags discouraged gems in Gemfiles and gemspecs, including ones pulled in transitively per the lockfile.'
36
+ Enabled: true
37
+ Include:
38
+ - '**/*.gemspec'
39
+ - '**/Gemfile'
40
+ - '**/gems.rb'
41
+ Gems:
42
+ ostruct: "OpenStruct is officially discouraged (slow, and defeats method caching); use Struct, Data, a Hash, or T::Struct. See https://forum.exercism.org/t/openstruct-is-now-officially-discouraged/17490"
38
43
 
39
44
  Gusto/ExecuteMigration:
40
45
  Description: "Don't use `execute` in migrations. Use a backfill rake task instead."
@@ -90,6 +95,23 @@ Gusto/Graphql/TypeVariables:
90
95
  Description: 'Requires `type_member` and `type_template` in GraphQL objects to name a type rather than `T.untyped`.'
91
96
  Enabled: false
92
97
 
98
+ Gusto/HardcodedAbsentRecordId:
99
+ Description: 'Flags a hardcoded positive id standing in for a record an example expects to be absent.'
100
+ Include:
101
+ - '**/spec/**/*_spec.rb'
102
+ # Names of `*_id` fields holding a third-party identifier rather than a primary key the
103
+ # auto-increment counter can reach. Prefer adding a name here over a file-path exclude.
104
+ AllowedNames: []
105
+ MaxId: 1000000
106
+ SafeAutoCorrect: false
107
+
108
+ Gusto/HardcodedId:
109
+ Description: 'Bans passing a hardcoded `id:` to create, which forces a primary key.'
110
+ Include:
111
+ - '**/spec/**/*_spec.rb'
112
+ AllowedFactories: []
113
+ AllowedReceivers: []
114
+
93
115
  Gusto/MinByMaxBy:
94
116
  Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
95
117
  Safe: false
data/config/rails.yml CHANGED
@@ -11,10 +11,6 @@ AllCops:
11
11
  TargetRubyVersion: <%= RbConfig::CONFIG['RUBY_API_VERSION'] %>
12
12
 
13
13
  Gusto/DiscouragedGem:
14
- Enabled: true
15
- Include:
16
- - '**/*.gemspec'
17
- - '**/Gemfile'
18
14
  Gems:
19
15
  timecop: "Use Rails' time helpers (e.g., freeze_time, travel_to) instead of Timecop."
20
16
 
@@ -1,27 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bundler"
4
+ require "digest"
5
+
3
6
  module RuboCop
4
7
  module Cop
5
8
  module Gusto
6
- # Flag installation of discouraged gems (e.g. timecop) in Gemfiles and
7
- # gemspecs. The discouraged gems an advice about alternatives are configured under
8
- # `Gems:`; intended to be enabled in Rails projects via config/rails.yml.
9
+ # Flag installation of discouraged gems (e.g. ostruct) in Gemfiles and gemspecs. The
10
+ # discouraged gems and advice about alternatives are configured under `Gems:`.
11
+ #
12
+ # When a Gemfile has a lockfile beside it, discouraged gems pulled in transitively are
13
+ # flagged on the line of the Gemfile dependency that pulls them in, so each existing case
14
+ # can be exempted with its own inline `rubocop:todo` and any new one still fails.
9
15
  #
10
- # @example Gems: { timecop: "Use Rails' time helpers instead of Timecop." }
16
+ # @example Gems: { ostruct: "Use Struct or Data instead of OpenStruct." }
11
17
  # # bad
12
- # gem "timecop"
18
+ # gem "ostruct"
13
19
  #
14
- # # good
15
- # # Use Rails' time helpers (freeze_time, travel_to) instead.
20
+ # # bad - Gemfile.lock resolves slack-ruby-client -> gli -> ostruct
21
+ # gem "slack-ruby-client"
16
22
  class DiscouragedGem < Base
17
23
  MSG = "Avoid using the '%{gem}' gem. %{advice}"
24
+ MSG_TRANSITIVE = "Avoid using the '%{gem}' gem, pulled in via %{path}. %{advice}"
25
+ MSG_UNREADABLE_LOCKFILE = "Could not parse %{lockfile} to check for discouraged gems."
26
+ LOCKFILES = { "Gemfile" => "Gemfile.lock", "gems.rb" => "gems.locked" }.freeze
18
27
 
19
28
  RESTRICT_ON_SEND = %i(gem add_dependency add_development_dependency).freeze
20
29
 
30
+ # @!method gem_declaration(node)
31
+ def_node_matcher :gem_declaration, "(send nil? :gem ({str sym} $_) ...)"
32
+
33
+ # @!method gemspec_declaration?(node)
34
+ def_node_matcher :gemspec_declaration?, "(send nil? :gemspec ...)"
35
+
36
+ def on_new_investigation
37
+ return if discouraged_gems.empty? || processed_source.ast.nil?
38
+
39
+ lockfile = lockfile_path
40
+ check_lockfile(lockfile) if lockfile && ::File.file?(lockfile)
41
+ end
42
+
21
43
  def on_send(node)
22
44
  check_gem_usage(node)
23
45
  end
24
46
 
47
+ # Asked once per run, so only a lockfile-only change at the project root busts the cache.
48
+ def external_dependency_checksum
49
+ lockfiles = LOCKFILES.values.select { |path| ::File.file?(path) }
50
+ return if lockfiles.empty?
51
+
52
+ Digest::SHA256.hexdigest(lockfiles.map { |path| ::File.read(path) }.join)
53
+ end
54
+
25
55
  private
26
56
 
27
57
  def check_gem_usage(node)
@@ -32,6 +62,84 @@ module RuboCop
32
62
  # No autocorrect: removing dependencies is a project decision.
33
63
  end
34
64
 
65
+ def lockfile_path
66
+ file_path = processed_source.file_path.to_s
67
+ lockfile = LOCKFILES[::File.basename(file_path)]
68
+ ::File.join(::File.dirname(file_path), lockfile) if lockfile
69
+ end
70
+
71
+ def check_lockfile(lockfile)
72
+ parser = ::Bundler::LockfileParser.new(::File.read(lockfile))
73
+ rescue ::Bundler::LockfileError
74
+ add_offense(fallback_location, message: format(MSG_UNREADABLE_LOCKFILE, lockfile: ::File.basename(lockfile)))
75
+ else
76
+ graph = dependency_graph(parser.specs)
77
+ declared = declared_gems
78
+ paths_by_location = Hash.new { |hash, location| hash[location] = [] }
79
+ parser.dependencies.each_key do |root|
80
+ discouraged_paths(root, graph).each do |path|
81
+ # A declared discouraged gem is already flagged by on_send.
82
+ next if path.one? && declared.key?(root)
83
+
84
+ paths_by_location[declared.fetch(root) { fallback_location }] << path
85
+ end
86
+ end
87
+ # RuboCop keeps only the first offense per location, so they are combined.
88
+ paths_by_location.each do |location, paths|
89
+ add_offense(location, message: paths.map { |path| message_for_path(path) }.join(" "))
90
+ end
91
+ end
92
+
93
+ # Platform-specific specs share a name, so their dependencies are merged.
94
+ def dependency_graph(specs)
95
+ specs.each_with_object({}) do |spec, graph|
96
+ (graph[spec.name] ||= []).concat(spec.dependencies.map(&:name)).uniq!
97
+ end
98
+ end
99
+
100
+ # Breadth-first, so each discouraged gem is reported via its shortest path from root.
101
+ def discouraged_paths(root, graph)
102
+ parents = { root => nil }
103
+ queue = [root]
104
+ found = []
105
+ until queue.empty?
106
+ name = queue.shift
107
+ found << name if discouraged_gems.include?(name)
108
+ graph.fetch(name, []).each do |dependency|
109
+ next if parents.key?(dependency)
110
+
111
+ parents[dependency] = name
112
+ queue << dependency
113
+ end
114
+ end
115
+ found.map { |name| path_to(name, parents) }
116
+ end
117
+
118
+ def path_to(name, parents)
119
+ path = [name]
120
+ path.unshift(parents[path.first]) while parents[path.first]
121
+ path
122
+ end
123
+
124
+ def declared_gems
125
+ processed_source.ast.each_node(:send).with_object({}) do |node, declared|
126
+ name = gem_declaration(node)
127
+ declared[name.to_s] ||= node if name
128
+ end
129
+ end
130
+
131
+ # Dependencies that come from a gemspec, eval_gemfile, or similar have no `gem` line.
132
+ def fallback_location
133
+ processed_source.ast.each_node(:send).find { |node| gemspec_declaration?(node) } ||
134
+ processed_source.buffer.line_range(1)
135
+ end
136
+
137
+ def message_for_path(path)
138
+ return message_for(path.first) if path.one?
139
+
140
+ format(MSG_TRANSITIVE, gem: path.last, path: path.join(" -> "), advice: advice_for(path.last))
141
+ end
142
+
35
143
  def discouraged_gems
36
144
  @discouraged_gems ||= gems_config.keys.map(&:to_s)
37
145
  end
@@ -0,0 +1,370 @@
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. Several literals each correct to their own negative (`-1`, `-2`), so
26
+ # the count the example asserts is preserved. A list only reads as record ids when every
27
+ # member is one: `['story', '123', '']` enumerates the shapes an id parser must reject,
28
+ # so its numeric member is a format, not a primary key.
29
+ #
30
+ # The description vocabulary is wider than "does not exist" on purpose. "Invalid company
31
+ # id", "belongs to another company" and "for a different employee" are, in practice,
32
+ # how specs name an id that matches no row -- and a positive literal there collides with
33
+ # the example's own record as soon as the counter reaches it.
34
+ #
35
+ # Five shapes are deliberately not offenses, because in none of them can the counter reach
36
+ # the literal, or a single replacement would change what the example asserts:
37
+ #
38
+ # - a group that builds a record carrying that id -- the row is meant to exist, so the
39
+ # absence the group describes is about something else, a cache or a header;
40
+ # - an `ActiveRecord::RecordNotFound` raised by reloading a row the example just deleted,
41
+ # which is a statement about that object rather than about the literal;
42
+ # - a literal above `MaxId`, which is a deliberate never-collides sentinel;
43
+ # - a group pinning two ids, which are usually pinned to differ from each other, and one
44
+ # shared replacement collapses that and inverts the example; and
45
+ # - an example group under VCR, whose cassette matches on the id in the request URI.
46
+ #
47
+ # @safety
48
+ # Autocorrection is unsafe. `-1` is right for a primary key, but it also changes the
49
+ # value the example feeds to everything else, so an id matched against a stubbed request
50
+ # URI or an asserted error message needs those updated to match. `-1` is not free
51
+ # everywhere either: a codebase may reserve it as a sentinel of its own, and an unsigned
52
+ # column or protobuf field rejects it outright. Where a negative will not do, a sentinel
53
+ # above `MaxId` is the sanctioned alternative.
54
+ #
55
+ # @example
56
+ # # bad
57
+ # context 'when the company does not exist' do
58
+ # let(:company_id) { 123 }
59
+ #
60
+ # it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
61
+ # end
62
+ #
63
+ # # bad - the literal is there precisely because no record has it
64
+ # let(:bank_account_ids) { [bank_account.id, other_account.id, 999_999] }
65
+ #
66
+ # # good - auto-increment never emits a negative id
67
+ # context 'when the company does not exist' do
68
+ # let(:company_id) { -1 }
69
+ #
70
+ # it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
71
+ # end
72
+ #
73
+ # # good - a deleted record's id is a reference, not a magic constant
74
+ # context 'when the company does not exist' do
75
+ # let(:company_id) { deleted_company.id }
76
+ # end
77
+ #
78
+ # @example AllowedNames: ['wise_id'] (default: [])
79
+ # # good - a third-party identifier, not a primary key the counter can reach
80
+ # context 'when the transfer does not exist' do
81
+ # let(:wise_id) { 5678 }
82
+ # end
83
+ #
84
+ # @example MaxId: 1000000 (default)
85
+ # # good - above MaxId the literal is a deliberate never-collides sentinel
86
+ # let(:company_id) { 9_999_999_999 }
87
+ class HardcodedAbsentRecordId < ::RuboCop::Cop::RSpec::Base
88
+ extend AutoCorrector
89
+
90
+ MSG = "Use a negative id for a record expected to be absent."
91
+ RESTRICT_ON_SEND = %i(let let!).freeze
92
+
93
+ # `id` itself, or any `<something>_id`, singular or plural.
94
+ ID_NAME = /\A(ids?|.+_ids?)\z/
95
+
96
+ # Each phrase is unambiguous on its own: a bare "missing" or "unknown" reads just as
97
+ # naturally about a header or a flag, so neither is here.
98
+ ABSENT_DESCRIPTION = /
99
+ (does|do|did)\s*n[o']?t\s+exist | \bnot\s+exist |
100
+ \bnot\s+found\b | (cannot|can\s*not|can't|couldn't)\s+be\s+found |
101
+ is\s*n[o']?t\s+found | non-?\s?existent | no\s+such |
102
+ invalid\s+(\w+\s+)?ids?\b | \bids?\s+(is|are)\s+invalid |
103
+ does\s*n[o']?t\s+belong | not\s+belong\s+to | unauthorized\s+access |
104
+ (another|other|different)\s+(account|company|customer|employee|member|org|person|user)
105
+ /xi
106
+
107
+ NOT_FOUND = /RecordNotFound/
108
+ NOT_FOUND_STATUS = /\A:?(not_found|404)\z/
109
+ DEFAULT_MAX_ID = 1_000_000
110
+
111
+ # @!method let_definition(node)
112
+ def_node_matcher :let_definition, <<~PATTERN
113
+ (any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
114
+ PATTERN
115
+
116
+ # @!method vcr_metadata?(node)
117
+ def_node_matcher :vcr_metadata?, <<~PATTERN
118
+ {(sym :vcr) (hash <(pair {(sym :vcr) (str "vcr")} _) ...>)}
119
+ PATTERN
120
+
121
+ def on_send(node)
122
+ return unless node.parent&.type?(:any_block)
123
+
124
+ name, body = let_definition(node.parent)
125
+ return unless body && id_name?(name)
126
+ return if cassette_controlled?(node)
127
+
128
+ if body.array_type?
129
+ check_id_list(node, name, body)
130
+ else
131
+ check_id_literal(node, name, body)
132
+ end
133
+ end
134
+
135
+ private
136
+
137
+ # A third-party identifier stored in a `*_id` field is not a primary key the
138
+ # auto-increment counter can reach, so a name listed in `AllowedNames` is skipped
139
+ # outright -- a named exception in config beats a file-path exclude or a disable.
140
+ def id_name?(name)
141
+ name.to_s.match?(ID_NAME) && !allowed_names.include?(name.to_s)
142
+ end
143
+
144
+ def allowed_names
145
+ @allowed_names ||= Array(cop_config["AllowedNames"]).to_set(&:to_s)
146
+ end
147
+
148
+ def check_id_literal(node, name, literal)
149
+ value = collidable_id(literal)
150
+ group = value && enclosing_example_group(node)
151
+ return unless group && absent_context?(group)
152
+ return if built_with?(group, name, value) || paired_ids?(group)
153
+
154
+ register(literal)
155
+ end
156
+
157
+ def check_id_list(node, name, array)
158
+ return if format_fixture?(array)
159
+
160
+ literals = array.children.select { |item| collidable_id(item) }
161
+ group = enclosing_example_group(node)
162
+ return if literals.empty? || !list_qualifies?(array, group)
163
+
164
+ register_list(literals, group, name)
165
+ end
166
+
167
+ # Each literal gets its own negative: a duplicated id collapses on lookup, which would
168
+ # change the count the example asserts.
169
+ def register_list(literals, group, name)
170
+ literals.each_with_index do |literal, index|
171
+ next if group && built_with?(group, name, collidable_id(literal))
172
+
173
+ register(literal, -(index + 1))
174
+ end
175
+ end
176
+
177
+ # `['story', 'story-', '123', '']` lists the shapes an id parser has to reject, so its
178
+ # numeric member is a format, not a primary key.
179
+ def format_fixture?(array)
180
+ array.children.any? do |item|
181
+ item.type?(:str, :sym) && Integer(item.value.to_s, exception: false).nil?
182
+ end
183
+ end
184
+
185
+ def list_qualifies?(array, group)
186
+ return true if array.children.any? { |item| item.send_type? && item.method?(:id) }
187
+
188
+ !group.nil? && absent_context?(group)
189
+ end
190
+
191
+ def register(literal, replacement = -1)
192
+ add_offense(literal) do |corrector|
193
+ corrector.replace(literal, literal.str_type? ? "'#{replacement}'" : replacement.to_s)
194
+ end
195
+ end
196
+
197
+ # A string id reaches the same column as an integer one, so both count; only the sign
198
+ # and the magnitude matter.
199
+ def collidable_id(node)
200
+ value = case node.type
201
+ when :int then node.value
202
+ when :str then Integer(node.value, exception: false)
203
+ end
204
+ value if value&.positive? && value <= max_id
205
+ end
206
+
207
+ def max_id
208
+ cop_config.fetch("MaxId", DEFAULT_MAX_ID)
209
+ end
210
+
211
+ def enclosing_example_group(node)
212
+ node.each_ancestor(:any_block).find { |ancestor| example_group?(ancestor) }
213
+ end
214
+
215
+ def absent_context?(group)
216
+ inspect_group(group).absent?
217
+ end
218
+
219
+ def built_with?(group, name, value)
220
+ inspect_group(group).builds?(name, value)
221
+ end
222
+
223
+ def paired_ids?(group)
224
+ inspect_group(group).paired_ids?
225
+ end
226
+
227
+ def inspect_group(group)
228
+ GroupInspector.new(group, max_id)
229
+ end
230
+
231
+ # A cassette keys its recorded interactions off the request URI, so an id in the path is
232
+ # what tells two same-route requests apart. Rewriting it makes the example miss the
233
+ # recording rather than assert absence, and `record: :none` then fails it outright.
234
+ def cassette_controlled?(node)
235
+ node.each_ancestor(:any_block).any? do |ancestor|
236
+ example_group?(ancestor) && ancestor.send_node.arguments.any? { |arg| vcr_metadata?(arg) }
237
+ end
238
+ end
239
+
240
+ # Answers the group-level questions: does this group say the record is absent, does it
241
+ # build the record, and does it pin two ids that are meant to differ?
242
+ class GroupInspector
243
+ include ::RuboCop::RSpec::Language
244
+ extend ::RuboCop::AST::NodePattern::Macros
245
+
246
+ # @!method let_definition(node)
247
+ def_node_matcher :let_definition, <<~PATTERN
248
+ (any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
249
+ PATTERN
250
+
251
+ # @!method builder_calls(node)
252
+ def_node_search :builder_calls, <<~PATTERN
253
+ $(send _ {:build :build! :build_list :build_pair :build_stubbed :build_stubbed_list
254
+ :create :create! :create_list :create_pair} ...)
255
+ PATTERN
256
+
257
+ def initialize(group, max_id)
258
+ @group = group
259
+ @max_id = max_id
260
+ end
261
+
262
+ def absent?
263
+ absent_description? || not_found_assertion?(group.body)
264
+ end
265
+
266
+ # A group that builds a record carrying this id means the row is meant to exist, so
267
+ # the absence the group describes is about something else.
268
+ def builds?(name, value)
269
+ builder_calls(group).any? do |call|
270
+ factory = factory_name(call)
271
+ call.each_descendant(:send, :pair).any? do |node|
272
+ references?(node, name) || own_attribute?(node, factory, value)
273
+ end
274
+ end
275
+ end
276
+
277
+ # Two ids pinned in one group are usually pinned to differ from each other -- a record
278
+ # and the "other company" it must not match. One shared replacement collapses that.
279
+ def paired_ids?
280
+ own_lets(group.body).count { |_name, body| collidable_id(body) } > 1
281
+ end
282
+
283
+ private
284
+
285
+ attr_reader :group, :max_id
286
+
287
+ # Matched against the source rather than the value so an interpolated description
288
+ # ("when #{model} does not exist") still reads as one.
289
+ def absent_description?
290
+ description = group.send_node.first_argument
291
+ return false unless description&.type?(:str, :dstr, :sym)
292
+
293
+ ABSENT_DESCRIPTION.match?(description.source)
294
+ end
295
+
296
+ # Scoped to this group: the walk stops at a nested example group, whose assertions
297
+ # belong to whatever that group redefines rather than to this `let`.
298
+ def not_found_assertion?(node)
299
+ return false if node.type?(:any_block) && example_group?(node)
300
+ return true if node.send_type? && not_found_matcher?(node)
301
+
302
+ node.each_child_node.any? { |child| not_found_assertion?(child) }
303
+ end
304
+
305
+ def not_found_matcher?(node)
306
+ case node.method_name
307
+ when :raise_error, :raise_exception
308
+ node.arguments.any? { |arg| NOT_FOUND.match?(arg.source) } && !deleted_record_assertion?(node)
309
+ when :have_http_status
310
+ NOT_FOUND_STATUS.match?(node.first_argument&.source.to_s)
311
+ when :be_not_found then true
312
+ else false
313
+ end
314
+ end
315
+
316
+ # `expect { record.reload }.to raise_error(ActiveRecord::RecordNotFound)` asserts that
317
+ # a row the example already holds was deleted by the code under test. That is about
318
+ # the object, not about the literal -- which is usually what selected it for deletion.
319
+ def deleted_record_assertion?(node)
320
+ subject = node.each_ancestor(:send).first&.receiver
321
+ return false unless subject&.type?(:any_block)
322
+
323
+ subject.body&.send_type? && subject.body.method?(:reload)
324
+ end
325
+
326
+ def collidable_id(node)
327
+ value = case node.type
328
+ when :int then node.value
329
+ when :str then Integer(node.value, exception: false)
330
+ end
331
+ value if value&.positive? && value <= max_id
332
+ end
333
+
334
+ def references?(node, name)
335
+ node.send_type? && node.receiver.nil? && node.method?(name)
336
+ end
337
+
338
+ def factory_name(call)
339
+ first_argument = call.first_argument
340
+ first_argument.value.to_s if first_argument&.sym_type?
341
+ end
342
+
343
+ # The built record carries the id when the key is its own primary key, or when the
344
+ # attribute is named for the factory, as in `create(:card, corepro_card_id: 123)`. A key
345
+ # naming another table is a foreign key: `create(:evaluation, company_id: 1)` points at
346
+ # the companies row the example says is absent rather than creating it, so it is not
347
+ # treated as building it.
348
+ def own_attribute?(node, factory, value)
349
+ return false unless node.pair_type? && node.key.type?(:sym, :str)
350
+
351
+ key = node.key.value.to_s
352
+ return false unless key.match?(ID_NAME) && collidable_id(node.value) == value
353
+
354
+ key == "id" || (!factory.nil? && key.include?(factory))
355
+ end
356
+
357
+ def own_lets(node, found = [])
358
+ return found if node.type?(:any_block) && example_group?(node)
359
+
360
+ definition = let_definition(node)
361
+ body = definition&.last
362
+ found << definition if body && !body.array_type?
363
+ node.each_child_node { |child| own_lets(child, found) }
364
+ found
365
+ end
366
+ end
367
+ end
368
+ end
369
+ end
370
+ 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.1"
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.1
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