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 +4 -4
- data/CHANGELOG.md +17 -0
- data/config/default.yml +6 -1
- data/config/gusto_cops.yml +25 -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 +370 -0
- data/lib/rubocop/cop/gusto/hardcoded_id.rb +110 -0
- data/lib/rubocop/cop/sidekiq/perform_async_stub.rb +27 -11
- data/lib/rubocop/gusto/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 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,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
|
-
|
|
273
|
+
# Not just `*_spec.rb`: shared contexts, support files and system-spec helpers install these
|
|
274
|
+
# stubs on behalf of the specs that include them, which is where they are hardest to spot.
|
|
275
|
+
- '**/spec/**/*'
|
|
271
276
|
|
|
272
277
|
Sorbet/ForbidTUnsafe:
|
|
273
278
|
# T.unsafe completely disables typechecking. Prefer T.cast, shims, or
|
data/config/gusto_cops.yml
CHANGED
|
@@ -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."
|
|
@@ -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.
|
|
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
|
|
@@ -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
|
|
25
|
-
#
|
|
28
|
+
# # good - there is no other way to make an enqueue fail. Sidekiq's testing API
|
|
29
|
+
# # pushes onto Foo.jobs and offers no failure injection, so a spec covering the
|
|
30
|
+
# # rescue around an enqueue has to raise from the stub.
|
|
31
|
+
# allow(Foo).to receive(:perform_async).and_raise(StandardError, "redis down")
|
|
26
32
|
#
|
|
27
33
|
# @safety
|
|
28
34
|
# Autocorrect is unsafe: it appends `.and_call_original` on positive `receive` only, which runs
|
|
29
35
|
# the real `perform_async` during the example (may enqueue jobs, hit external code, or
|
|
30
36
|
# change expectations vs a pure stub). There is no autocorrect for `not_to` / `to_not receive`,
|
|
31
|
-
# since `.and_call_original` would not apply to a negative expectation
|
|
32
|
-
#
|
|
33
|
-
#
|
|
37
|
+
# since `.and_call_original` would not apply to a negative expectation, nor for
|
|
38
|
+
# `have_received`, whose stub lives elsewhere. Autocorrect is also suppressed when the
|
|
39
|
+
# expectation uses a block, since appending `.and_call_original` would rebind the block to
|
|
40
|
+
# the wrong method.
|
|
34
41
|
class PerformAsyncStub < Base
|
|
35
42
|
extend AutoCorrector
|
|
36
43
|
|
|
37
44
|
MSG = "Prefer checking enqueued jobs over stubbing `perform_async`."
|
|
38
45
|
MSG_RECEIVE = "Prefer checking enqueued jobs over stubbing `perform_async` or add `.and_call_original`."
|
|
39
|
-
RESTRICT_ON_SEND = %i(receive).freeze
|
|
46
|
+
RESTRICT_ON_SEND = %i(receive have_received).freeze
|
|
47
|
+
|
|
48
|
+
# Chain modifiers that leave the real `perform_async` reachable, so the example is not
|
|
49
|
+
# replacing the enqueue with a canned result. `and_raise` belongs here even though it
|
|
50
|
+
# never calls the original: simulating a failed enqueue is the one thing Sidekiq's
|
|
51
|
+
# testing API cannot express, so a spec covering the rescue has no other route.
|
|
52
|
+
ALLOWED_CHAIN = %i(and_call_original and_wrap_original and_raise).freeze
|
|
40
53
|
|
|
41
|
-
# TODO: this should match on perform_async, not on receive, requires pattern update
|
|
42
54
|
# @!method stub_perform_async?(node)
|
|
43
55
|
def_node_matcher :stub_perform_async?, <<~PATTERN
|
|
44
|
-
(send nil? :receive (sym :perform_async))
|
|
56
|
+
(send nil? {:receive :have_received} (sym :perform_async))
|
|
45
57
|
PATTERN
|
|
46
58
|
|
|
47
59
|
def on_send(node)
|
|
48
60
|
return unless stub_perform_async?(node)
|
|
61
|
+
# `have_received` only verifies a stub that was installed somewhere else, possibly in a
|
|
62
|
+
# shared context or support file out of this cop's reach, so it is flagged on sight and
|
|
63
|
+
# there is nothing local to autocorrect.
|
|
64
|
+
return add_offense(node) if node.method?(:have_received)
|
|
49
65
|
|
|
50
66
|
negative_expectation = false
|
|
51
|
-
|
|
67
|
+
allowed_chain = false
|
|
52
68
|
|
|
53
69
|
current = node.parent
|
|
54
70
|
while current&.call_type?
|
|
55
71
|
negative_expectation = true if current.method?(:not_to) || current.method?(:to_not)
|
|
56
|
-
|
|
72
|
+
allowed_chain = true if ALLOWED_CHAIN.include?(current.method_name)
|
|
57
73
|
|
|
58
74
|
current = current.parent
|
|
59
75
|
end
|
|
60
76
|
|
|
61
77
|
return add_offense(node) if negative_expectation
|
|
62
|
-
return if
|
|
78
|
+
return if allowed_chain
|
|
63
79
|
|
|
64
80
|
tail = message_expectation_chain_tail(node)
|
|
65
81
|
return add_offense(node, message: MSG_RECEIVE) if tail.parent&.block_type?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-gusto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 11.
|
|
4
|
+
version: 11.9.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
|