rubocop-gusto 11.9.0 → 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: ae5d8c5b6e24aaf92ff21ac52781b6e6ad098d4893a0051b530d372bd6fee0d3
4
- data.tar.gz: f95be1484e3291a03aa5fb2e337b18fcdcf48cb4762422bc0fc39bed4550ef6c
3
+ metadata.gz: 5581a398e181fa5cee526273621f36357e83dba9fe912433df5a4cbe2129b2e7
4
+ data.tar.gz: db3825a6506974aa565e427d2e83fa1cbb618b31136c4bdbbb13840e81dd1eb1
5
5
  SHA512:
6
- metadata.gz: 5c773623cef1e70d0289490fd8409a30d05ce91c0051ac29f7be5946849d8d3087ca9edb26b90e54cb14b9afe99c26fa712063810be1ba74252010ac32d26706
7
- data.tar.gz: a0e8c1775d2850925cdda007713667410a2ab32f60b73f928f372bfa24ec4fa4ab2a707466aca9ef10ce4b5e61407e0c3a9752b489ae9c9e5a8f0f92bfa57533
6
+ metadata.gz: '089fac7ad4fea249760ba0d5138e78a42152f198796b7f920d78ee656ba5cfb4d56b66a20918a90488431534b497110b366a66f467703c22797d5d4a51a94aba'
7
+ data.tar.gz: 681ef77cd2115bac46da1f0713adfa9fd05ed77204cec941c47cbe7a45d30b62d2b3128997665567923eaaa6509464f10c78867869fed7aed34d9b7bc3870f21
data/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
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
+
6
13
  ## [11.9.0](https://github.com/Gusto/rubocop-gusto/compare/v11.8.1...v11.9.0) (2026-09-24)
7
14
 
8
15
 
@@ -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."
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
@@ -22,7 +22,15 @@ module RuboCop
22
22
  # In a list, a literal sitting beside a real `record.id` also qualifies on its own: you
23
23
  # would write another `record.id` if you wanted one that exists. That matters because
24
24
  # those groups are usually named for the plural ("with multiple bank account IDs") rather
25
- # than for the gap.
25
+ # than for the gap. Several literals each correct to their own negative (`-1`, `-2`), so
26
+ # the count the example asserts is preserved. A list only reads as record ids when every
27
+ # member is one: `['story', '123', '']` enumerates the shapes an id parser must reject,
28
+ # so its numeric member is a format, not a primary key.
29
+ #
30
+ # The description vocabulary is wider than "does not exist" on purpose. "Invalid company
31
+ # id", "belongs to another company" and "for a different employee" are, in practice,
32
+ # how specs name an id that matches no row -- and a positive literal there collides with
33
+ # the example's own record as soon as the counter reaches it.
26
34
  #
27
35
  # Five shapes are deliberately not offenses, because in none of them can the counter reach
28
36
  # the literal, or a single replacement would change what the example asserts:
@@ -91,14 +99,13 @@ module RuboCop
91
99
  (does|do|did)\s*n[o']?t\s+exist | \bnot\s+exist |
92
100
  \bnot\s+found\b | (cannot|can\s*not|can't|couldn't)\s+be\s+found |
93
101
  is\s*n[o']?t\s+found | non-?\s?existent | no\s+such |
94
- invalid\s+\w*\s*\bid\b | \bid\b\s+is\s+invalid |
102
+ invalid\s+(\w+\s+)?ids?\b | \bids?\s+(is|are)\s+invalid |
95
103
  does\s*n[o']?t\s+belong | not\s+belong\s+to | unauthorized\s+access |
96
104
  (another|other|different)\s+(account|company|customer|employee|member|org|person|user)
97
105
  /xi
98
106
 
99
107
  NOT_FOUND = /RecordNotFound/
100
108
  NOT_FOUND_STATUS = /\A:?(not_found|404)\z/
101
- BUILDERS = %i(build build! build_list build_stubbed build_stubbed_list create create! create_list).freeze
102
109
  DEFAULT_MAX_ID = 1_000_000
103
110
 
104
111
  # @!method let_definition(node)
@@ -147,19 +154,32 @@ module RuboCop
147
154
  register(literal)
148
155
  end
149
156
 
150
- # Only a lone literal qualifies. Two would both correct to `-1`, and a duplicated id in
151
- # a list collapses on lookup, changing the count the example asserts.
152
157
  def check_id_list(node, name, array)
153
- literals = array.children.select { |item| collidable_id(item) }
154
- return unless literals.one?
158
+ return if format_fixture?(array)
155
159
 
160
+ literals = array.children.select { |item| collidable_id(item) }
156
161
  group = enclosing_example_group(node)
157
- return unless list_qualifies?(array, group)
162
+ return if literals.empty? || !list_qualifies?(array, group)
163
+
164
+ register_list(literals, group, name)
165
+ end
158
166
 
159
- literal = literals.first
160
- return if group && built_with?(group, name, collidable_id(literal))
167
+ # Each literal gets its own negative: a duplicated id collapses on lookup, which would
168
+ # change the count the example asserts.
169
+ def register_list(literals, group, name)
170
+ literals.each_with_index do |literal, index|
171
+ next if group && built_with?(group, name, collidable_id(literal))
161
172
 
162
- register(literal)
173
+ register(literal, -(index + 1))
174
+ end
175
+ end
176
+
177
+ # `['story', 'story-', '123', '']` lists the shapes an id parser has to reject, so its
178
+ # numeric member is a format, not a primary key.
179
+ def format_fixture?(array)
180
+ array.children.any? do |item|
181
+ item.type?(:str, :sym) && Integer(item.value.to_s, exception: false).nil?
182
+ end
163
183
  end
164
184
 
165
185
  def list_qualifies?(array, group)
@@ -168,9 +188,9 @@ module RuboCop
168
188
  !group.nil? && absent_context?(group)
169
189
  end
170
190
 
171
- def register(literal)
191
+ def register(literal, replacement = -1)
172
192
  add_offense(literal) do |corrector|
173
- corrector.replace(literal, literal.str_type? ? "'-1'" : "-1")
193
+ corrector.replace(literal, literal.str_type? ? "'#{replacement}'" : replacement.to_s)
174
194
  end
175
195
  end
176
196
 
@@ -228,6 +248,12 @@ module RuboCop
228
248
  (any_block (send nil? {:let :let!} (sym $_) ...) _ $_)
229
249
  PATTERN
230
250
 
251
+ # @!method builder_calls(node)
252
+ def_node_search :builder_calls, <<~PATTERN
253
+ $(send _ {:build :build! :build_list :build_pair :build_stubbed :build_stubbed_list
254
+ :create :create! :create_list :create_pair} ...)
255
+ PATTERN
256
+
231
257
  def initialize(group, max_id)
232
258
  @group = group
233
259
  @max_id = max_id
@@ -240,9 +266,11 @@ module RuboCop
240
266
  # A group that builds a record carrying this id means the row is meant to exist, so
241
267
  # the absence the group describes is about something else.
242
268
  def builds?(name, value)
243
- group.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) }
269
+ builder_calls(group).any? do |call|
270
+ factory = factory_name(call)
271
+ call.each_descendant(:send, :pair).any? do |node|
272
+ references?(node, name) || own_attribute?(node, factory, value)
273
+ end
246
274
  end
247
275
  end
248
276
 
@@ -307,11 +335,23 @@ module RuboCop
307
335
  node.send_type? && node.receiver.nil? && node.method?(name)
308
336
  end
309
337
 
310
- # `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
338
+ def factory_name(call)
339
+ first_argument = call.first_argument
340
+ first_argument.value.to_s if first_argument&.sym_type?
341
+ end
342
+
343
+ # The built record carries the id when the key is its own primary key, or when the
344
+ # attribute is named for the factory, as in `create(:card, corepro_card_id: 123)`. A key
345
+ # naming another table is a foreign key: `create(:evaluation, company_id: 1)` points at
346
+ # the companies row the example says is absent rather than creating it, so it is not
347
+ # treated as building it.
348
+ def own_attribute?(node, factory, value)
349
+ return false unless node.pair_type? && node.key.type?(:sym, :str)
350
+
351
+ key = node.key.value.to_s
352
+ return false unless key.match?(ID_NAME) && collidable_id(node.value) == value
353
+
354
+ key == "id" || (!factory.nil? && key.include?(factory))
315
355
  end
316
356
 
317
357
  def own_lets(node, found = [])
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Gusto
5
- VERSION = "11.9.0"
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.9.0
4
+ version: 11.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineering