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 +4 -4
- data/CHANGELOG.md +7 -0
- data/config/gusto_cops.yml +8 -3
- data/config/rails.yml +0 -4
- data/lib/rubocop/cop/gusto/discouraged_gem.rb +115 -7
- data/lib/rubocop/cop/gusto/hardcoded_absent_record_id.rb +61 -21
- data/lib/rubocop/gusto/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5581a398e181fa5cee526273621f36357e83dba9fe912433df5a4cbe2129b2e7
|
|
4
|
+
data.tar.gz: db3825a6506974aa565e427d2e83fa1cbb618b31136c4bdbbb13840e81dd1eb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/config/gusto_cops.yml
CHANGED
|
@@ -32,9 +32,14 @@ Gusto/DescribedClassConstantReference:
|
|
|
32
32
|
- '**/spec/**/*'
|
|
33
33
|
|
|
34
34
|
Gusto/DiscouragedGem:
|
|
35
|
-
Description: 'Flags
|
|
36
|
-
Enabled:
|
|
37
|
-
|
|
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.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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: {
|
|
16
|
+
# @example Gems: { ostruct: "Use Struct or Data instead of OpenStruct." }
|
|
11
17
|
# # bad
|
|
12
|
-
# gem "
|
|
18
|
+
# gem "ostruct"
|
|
13
19
|
#
|
|
14
|
-
# #
|
|
15
|
-
#
|
|
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+\
|
|
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
|
-
|
|
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
|
|
162
|
+
return if literals.empty? || !list_qualifies?(array, group)
|
|
163
|
+
|
|
164
|
+
register_list(literals, group, name)
|
|
165
|
+
end
|
|
158
166
|
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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? ? "'
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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 = [])
|