rubocop-gusto 11.9.1 → 11.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5581a398e181fa5cee526273621f36357e83dba9fe912433df5a4cbe2129b2e7
4
- data.tar.gz: db3825a6506974aa565e427d2e83fa1cbb618b31136c4bdbbb13840e81dd1eb1
3
+ metadata.gz: 7f748fe3b65ef76b93bb947a6058c589a813ab9cb5b1e941e4728c566b595e3d
4
+ data.tar.gz: 96f689862a97b10381440f8a06ce03dd5c52cc440908249bee2500b74571b16c
5
5
  SHA512:
6
- metadata.gz: '089fac7ad4fea249760ba0d5138e78a42152f198796b7f920d78ee656ba5cfb4d56b66a20918a90488431534b497110b366a66f467703c22797d5d4a51a94aba'
7
- data.tar.gz: 681ef77cd2115bac46da1f0713adfa9fd05ed77204cec941c47cbe7a45d30b62d2b3128997665567923eaaa6509464f10c78867869fed7aed34d9b7bc3870f21
6
+ metadata.gz: a15c149f1360e7bd8a1fa2c49e73f52410a5992c1f7caaf235331fa28127de6011658a1abb36414bb762647f8ddb6afbd4563c7b146e2e31011a340686132e3e
7
+ data.tar.gz: 35e67c0e1cee739e8cb07ad6be077813b0e296c942ca593a1109153894e6bba933a789455b97fdbf94b826135bd91c05f1e618856230a885c0504f2cd2e92e52
data/CHANGELOG.md CHANGED
@@ -3,9 +3,31 @@
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.11.0](https://github.com/Gusto/rubocop-gusto/compare/v11.10.0...v11.11.0) (2026-10-27)
7
+
8
+
9
+ ### Features
10
+
11
+ * add Gusto/Sorbet/StructDefaultEvaluatedOnce cop ([#194](https://github.com/Gusto/rubocop-gusto/issues/194)) ([e58c907](https://github.com/Gusto/rubocop-gusto/commit/e58c907195fd589cf94c1ab67fbd89fbc603102b))
12
+ * Flag PII accessors and raw params in logs ([#119](https://github.com/Gusto/rubocop-gusto/issues/119)) ([c9f5342](https://github.com/Gusto/rubocop-gusto/commit/c9f53428b2d99bfb31091f066395d3c896155f4e))
13
+ * move the Rack and Sidekiq cops under the Gusto department ([#192](https://github.com/Gusto/rubocop-gusto/issues/192)) ([acd6a2c](https://github.com/Gusto/rubocop-gusto/commit/acd6a2c65a51abf81f74ab1072dbb8de03af2238))
14
+
15
+ ## [11.10.0](https://github.com/Gusto/rubocop-gusto/compare/v11.9.1...v11.10.0) (2026-10-22)
16
+
17
+
18
+ ### Features
19
+
20
+ * add Gusto/TimeoutTimeout cop ([#195](https://github.com/Gusto/rubocop-gusto/issues/195)) ([ef28938](https://github.com/Gusto/rubocop-gusto/commit/ef28938194582f3e7f804694a6a9fa7f6b005b99))
21
+ * per-group scoping for Gusto/UnreferencedLet ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
22
+ * per-group UnreferencedLet scoping ([#187](https://github.com/Gusto/rubocop-gusto/issues/187)) ([de8504c](https://github.com/Gusto/rubocop-gusto/commit/de8504c66110dea8d2ab1a57ea66870a181ce633))
23
+
6
24
  ## [11.9.1](https://github.com/Gusto/rubocop-gusto/compare/v11.9.0...v11.9.1) (2026-09-28)
7
25
 
8
26
 
27
+ ### Features
28
+
29
+ * flag transitive discouraged gems via the lockfile and discourage ostruct ([#188](https://github.com/Gusto/rubocop-gusto/issues/188)) ([3fb0a2c](https://github.com/Gusto/rubocop-gusto/commit/3fb0a2c825777d768c4d2d92b8504eae44723d5a))
30
+
9
31
  ### Bug Fixes
10
32
 
11
33
  * port upstream review of Gusto/HardcodedAbsentRecordId ([#180](https://github.com/Gusto/rubocop-gusto/issues/180)) ([6198928](https://github.com/Gusto/rubocop-gusto/commit/6198928187db6920c18c882c3167a1cf8edeccf1))
data/README.md CHANGED
@@ -88,14 +88,31 @@ inherit_gem:
88
88
 
89
89
  If your project also uses Rails, include `config/rails.yml` as well (order does not matter). Re-run `bundle exec rubocop-gusto init` to merge this in automatically.
90
90
 
91
+ #### Logging configuration
92
+
93
+ The `Gusto/Logging` cops report likely sensitive data in `Rails.logger`, `Sidekiq.logger`,
94
+ and bare `logger` calls, including chained `.tagged(...)` loggers and message blocks.
95
+ Each cop has its own severity, exclusions, todo entries, and inline disables:
96
+
97
+ - `PiiAccessor` checks configured PII methods such as `user.email`.
98
+ - `RawParams` checks unfiltered `params`, including serialization and `require` without narrowing.
99
+
100
+ All ship at `Severity: info`, which does not fail RuboCop at its default failure threshold.
101
+ Spec and test files are excluded. Promote each cop independently after reviewing its hits;
102
+ for example, set `Gusto/Logging/PiiAccessor: Severity: warning` in your project configuration.
103
+ Configure `PiiMethods` on `Gusto/Logging` to customize the shared sensitive-method list.
104
+
105
+ These are syntax-based heuristics: selecting a params key is allowed, not proof that it is safe;
106
+ plain local variables and values assembled outside the log call may escape detection.
107
+
91
108
  ### Available cops
92
109
 
93
110
  Custom cops live under the following namespaces:
94
111
 
95
112
  - `Gusto/` — general Gusto-specific cops (see [`lib/rubocop/cop/gusto/`](lib/rubocop/cop/gusto/))
96
113
  - `Gusto/Graphql/` — cops scoped to the graphql-ruby schema DSL (see [`lib/rubocop/cop/gusto/graphql/`](lib/rubocop/cop/gusto/graphql/)); configured in [`config/graphql.yml`](config/graphql.yml)
97
- - `Sidekiq/` — cops scoped to Sidekiq patterns (see [`lib/rubocop/cop/sidekiq/`](lib/rubocop/cop/sidekiq/)); configured in [`config/sidekiq.yml`](config/sidekiq.yml)
98
- - `Rack/` — cops scoped to Rack middleware patterns (see [`lib/rubocop/cop/rack/`](lib/rubocop/cop/rack/))
114
+ - `Gusto/Logging/` — independently configurable checks for sensitive data in logs (see [`lib/rubocop/cop/gusto/logging/`](lib/rubocop/cop/gusto/logging/))
115
+ - `Gusto/Sidekiq/` — cops scoped to Sidekiq patterns (see [`lib/rubocop/cop/gusto/sidekiq/`](lib/rubocop/cop/gusto/sidekiq/)); enabled by [`config/sidekiq.yml`](config/sidekiq.yml)
99
116
 
100
117
  ## Publishing New Versions
101
118
 
data/config/default.yml CHANGED
@@ -242,38 +242,9 @@ RSpec/StubbedMock:
242
242
  RSpec/SubjectStub:
243
243
  Enabled: false
244
244
 
245
- Rack/LowercaseHeaderKeys:
246
- Description: 'HTTP response header keys should be lowercase for HTTP/2 and modern web standards compatibility.'
247
- Enabled: true
248
- Include:
249
- - 'lib/middleware/**/*.rb'
250
- - 'app/middleware/**/*.rb'
251
- - 'app/controllers/**/*.rb'
252
- - 'app/public/**/*controller*.rb'
253
- - 'app/helpers/**/*.rb'
254
- - 'packs/**/middleware/**/*.rb'
255
- - 'packs/**/controllers/**/*.rb'
256
- - 'packs/**/app/public/**/*controller*.rb'
257
- - 'packs/**/app/helpers/**/*.rb'
258
- - 'packs/**/lib/**/*_app.rb'
259
- - 'components/**/middleware/**/*.rb'
260
- - 'components/**/controllers/**/*.rb'
261
- - 'config/initializers/**/*.rb'
262
- Exclude:
263
- - '**/spec/**/*'
264
-
265
245
  Rake/ClassDefinitionInTask:
266
246
  Enabled: false
267
247
 
268
- Sidekiq/PerformAsyncStub:
269
- Description: 'Prefer checking enqueued jobs over stubbing `perform_async`.'
270
- Enabled: false
271
- SafeAutoCorrect: false # Autocorrect appends `.and_call_original`, which runs real `perform_async` during the example
272
- Include:
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/**/*'
276
-
277
248
  Sorbet/ForbidTUnsafe:
278
249
  # T.unsafe completely disables typechecking. Prefer T.cast, shims, or
279
250
  # reorganizing the code over silently turning off the type checker.
@@ -112,6 +112,66 @@ Gusto/HardcodedId:
112
112
  AllowedFactories: []
113
113
  AllowedReceivers: []
114
114
 
115
+ Gusto/Logging:
116
+ # Exclusions live on each cop because RuboCop 1.76 replaces department Exclude arrays.
117
+ PiiMethods:
118
+ - email
119
+ - ssn
120
+ - social_security_number
121
+ - first_name
122
+ - last_name
123
+ - full_name
124
+ - phone
125
+ - phone_number
126
+ - ein
127
+ - tin
128
+ - tax_id
129
+ - account_number
130
+ - routing_number
131
+ - date_of_birth
132
+ - address
133
+ - bank_account_number
134
+
135
+ Gusto/Logging/PiiAccessor:
136
+ Description: 'Flags PII accessor methods such as `.email` in log calls.'
137
+ Severity: info
138
+ inherit_mode:
139
+ merge:
140
+ - Exclude
141
+ Exclude:
142
+ - '**/spec/**/*'
143
+ - '**/test/**/*'
144
+
145
+ Gusto/Logging/RawParams:
146
+ Description: 'Flags logging raw `params`, which may contain PII.'
147
+ Severity: info
148
+ inherit_mode:
149
+ merge:
150
+ - Exclude
151
+ Exclude:
152
+ - '**/spec/**/*'
153
+ - '**/test/**/*'
154
+
155
+ Gusto/LowercaseHeaders:
156
+ Description: 'HTTP response header keys should be lowercase for HTTP/2 and modern web standards compatibility.'
157
+ Enabled: true
158
+ Include:
159
+ - 'lib/middleware/**/*.rb'
160
+ - 'app/middleware/**/*.rb'
161
+ - 'app/controllers/**/*.rb'
162
+ - 'app/public/**/*controller*.rb'
163
+ - 'app/helpers/**/*.rb'
164
+ - 'packs/**/middleware/**/*.rb'
165
+ - 'packs/**/controllers/**/*.rb'
166
+ - 'packs/**/app/public/**/*controller*.rb'
167
+ - 'packs/**/app/helpers/**/*.rb'
168
+ - 'packs/**/lib/**/*_app.rb'
169
+ - 'components/**/middleware/**/*.rb'
170
+ - 'components/**/controllers/**/*.rb'
171
+ - 'config/initializers/**/*.rb'
172
+ Exclude:
173
+ - '**/spec/**/*'
174
+
115
175
  Gusto/MinByMaxBy:
116
176
  Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
117
177
  Safe: false
@@ -176,6 +236,16 @@ Gusto/RspecDateTimeMock:
176
236
  - '**/spec/**/*'
177
237
  Safe: false
178
238
 
239
+ Gusto/Sidekiq/PerformAsyncStub:
240
+ Description: 'Prefer checking enqueued jobs over stubbing `perform_async`.'
241
+ # Opted into via config/sidekiq.yml when the project depends on Sidekiq.
242
+ Enabled: false
243
+ SafeAutoCorrect: false # Autocorrect appends `.and_call_original`, which runs real `perform_async` during the example
244
+ Include:
245
+ # Not just `*_spec.rb`: shared contexts, support files and system-spec helpers install these
246
+ # stubs on behalf of the specs that include them, which is where they are hardest to spot.
247
+ - '**/spec/**/*'
248
+
179
249
  Gusto/SidekiqParams:
180
250
  Description: 'Sidekiq perform methods cannot take keyword arguments.'
181
251
 
@@ -191,6 +261,13 @@ Gusto/Sorbet/PredicateBooleanReturn:
191
261
  # which can change behavior in subtle ways.
192
262
  SafeAutoCorrect: false
193
263
 
264
+ Gusto/Sorbet/StructDefaultEvaluatedOnce:
265
+ Description: 'T::Struct `default:` values are evaluated once at class load; use `factory:` for clock or random values.'
266
+ # Opted into via config/sorbet.yml when the project depends on Sorbet.
267
+ Enabled: false
268
+ # Autocorrect gives each instance a fresh value instead of the one computed at load time.
269
+ SafeAutoCorrect: false
270
+
194
271
  Gusto/SuddenAssociations:
195
272
  Description: 'Prevents defining Rails associations on another class from outside the class body.'
196
273
  # Opted into via config/rails.yml; the association DSL only exists in a Rails project.
@@ -198,6 +275,11 @@ Gusto/SuddenAssociations:
198
275
  Exclude:
199
276
  - '**/spec/**/*' # allow stubbing, although this is still odd
200
277
 
278
+ Gusto/TimeoutTimeout:
279
+ Description: 'Flags `Timeout.timeout`, which interrupts its block with `Thread#raise` at an arbitrary point.'
280
+ Exclude:
281
+ - '**/spec/**/*'
282
+
201
283
  Gusto/ToplevelConstants:
202
284
  Description: 'Prevents top-level constants from being defined outside of initializers.'
203
285
  Include:
@@ -220,6 +302,9 @@ Gusto/UnreferencedLet:
220
302
  Include:
221
303
  - '**/spec/**/*_spec.rb'
222
304
  SafeAutoCorrect: false
305
+ PerExampleGroup: false
306
+ Cascade: false
307
+ LocalContextIncludes: []
223
308
 
224
309
  Gusto/UsePaintNotColorize:
225
310
  Description: 'Use Paint instead of colorize for terminal colors.'
@@ -22,3 +22,13 @@ renamed:
22
22
  Graphql/PreventScalarsDate: GraphQL/DisallowedTypes
23
23
  Graphql/ResolverIgnoresObject: Gusto/Graphql/ResolverIgnoresObject
24
24
  Graphql/TypeVariables: Gusto/Graphql/TypeVariables
25
+
26
+ # `Rack` and `Sidekiq` held one Gusto-authored cop each, alongside departments other gems
27
+ # own. They now live under `Gusto/`. These warn rather than error, so a consumer's existing
28
+ # config for the old name keeps applying to the new one until it is renamed.
29
+ Rack/LowercaseHeaderKeys:
30
+ new_name: Gusto/LowercaseHeaders
31
+ severity: warning
32
+ Sidekiq/PerformAsyncStub:
33
+ new_name: Gusto/Sidekiq/PerformAsyncStub
34
+ severity: warning
data/config/sidekiq.yml CHANGED
@@ -3,5 +3,5 @@
3
3
  #
4
4
  # After you add a rule, sort this file with `bundle exec rubocop-gusto sort config/sidekiq.yml`
5
5
 
6
- Sidekiq/PerformAsyncStub:
6
+ Gusto/Sidekiq/PerformAsyncStub:
7
7
  Enabled: true
data/config/sorbet.yml CHANGED
@@ -5,3 +5,6 @@
5
5
 
6
6
  Gusto/Sorbet/PredicateBooleanReturn:
7
7
  Enabled: true
8
+
9
+ Gusto/Sorbet/StructDefaultEvaluatedOnce:
10
+ Enabled: true
@@ -4,4 +4,4 @@ require "rubocop"
4
4
  require_relative "../../gusto"
5
5
  require_relative "../../gusto/version"
6
6
 
7
- Dir.glob("#{__dir__}/*.rb").each { |f| require f unless f == __FILE__ }
7
+ Dir.glob("#{__dir__}/**/*.rb").each { |f| require f unless f == __FILE__ }
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ module Logging
7
+ # Log-call detection and argument traversal shared by the `Gusto/Logging` cops.
8
+ module LogCallConcerns
9
+ extend NodePattern::Macros
10
+
11
+ LOG_METHODS = %i(debug info warn error fatal log).freeze
12
+
13
+ # @!method rails_logger?(node)
14
+ def_node_matcher :rails_logger?, <<~PATTERN
15
+ (send (const {nil? cbase} :Rails) :logger)
16
+ PATTERN
17
+
18
+ # @!method sidekiq_logger?(node)
19
+ def_node_matcher :sidekiq_logger?, <<~PATTERN
20
+ (send (const {nil? cbase} :Sidekiq) :logger)
21
+ PATTERN
22
+
23
+ # @!method bare_logger?(node)
24
+ def_node_matcher :bare_logger?, <<~PATTERN
25
+ (send nil? :logger)
26
+ PATTERN
27
+
28
+ # @!method raw_params?(node)
29
+ def_node_matcher :raw_params?, <<~PATTERN
30
+ (send nil? :params)
31
+ PATTERN
32
+
33
+ private
34
+
35
+ def logger_call?(node)
36
+ supported_logger?(node.receiver)
37
+ end
38
+
39
+ def supported_logger?(node)
40
+ return false unless node
41
+ return true if rails_logger?(node) || sidekiq_logger?(node) || bare_logger?(node)
42
+
43
+ node.call_type? && node.method?(:tagged) && supported_logger?(node.receiver)
44
+ end
45
+
46
+ def pii_methods
47
+ @pii_methods ||= Array(config.dig("Gusto/Logging", "PiiMethods")).map(&:to_s).to_set
48
+ end
49
+
50
+ def pii_method?(method_name)
51
+ name = method_name.to_s
52
+ pii_methods.include?(name) || pii_methods.any? { |pii| name.end_with?("_#{pii}") }
53
+ end
54
+
55
+ def each_send_in_log(log_node, &callback)
56
+ log_node.arguments.each do |arg|
57
+ yield_sends_from(arg, &callback)
58
+ end
59
+
60
+ # Block-form logging carries the message in the block, not the arguments: `logger.info { "..." }`
61
+ parent_node = log_node.parent
62
+ if parent_node&.block_type? && parent_node.send_node == log_node
63
+ yield_sends_from(parent_node.body, &callback)
64
+ end
65
+ end
66
+
67
+ def yield_sends_from(node, &callback)
68
+ return unless node
69
+
70
+ yield node if node.call_type?
71
+ node.each_descendant(:call, &callback)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log_call_concerns"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Gusto
8
+ module Logging
9
+ # Flags PII accessor methods, such as `.email` or `.ssn`, in log calls.
10
+ #
11
+ # Only calls with a receiver count (`user.email`, not a bare `email`). A predicate on the
12
+ # accessor (`user.email.present?`) is allowed because it logs a boolean, not the value.
13
+ # `PiiMethods` comes from the `Gusto/Logging` department. Each entry also
14
+ # matches as an underscore suffix, so `tax_id` covers `client_tax_id`.
15
+ #
16
+ # @example
17
+ # # bad
18
+ # Rails.logger.info("User: #{user.email}")
19
+ # logger.warn("Failed for #{employee.ssn}")
20
+ # Rails.logger.info("Tax ID: #{input.client_tax_id}")
21
+ #
22
+ # # good
23
+ # Rails.logger.info("User: #{user.id}")
24
+ # Rails.logger.info("Has email: #{user.email.present?}")
25
+ #
26
+ class PiiAccessor < Base
27
+ include LogCallConcerns
28
+
29
+ MSG = "Avoid logging PII accessor `.%{method}`. Log an identifier instead."
30
+ RESTRICT_ON_SEND = LOG_METHODS
31
+
32
+ def on_send(node)
33
+ return unless logger_call?(node)
34
+
35
+ each_send_in_log(node) do |send_node|
36
+ next unless send_node.receiver
37
+ next unless pii_method?(send_node.method_name)
38
+ next if predicate_receiver?(send_node)
39
+
40
+ add_offense(send_node, message: format(MSG, method: send_node.method_name))
41
+ end
42
+ end
43
+
44
+ alias_method :on_csend, :on_send
45
+
46
+ private
47
+
48
+ def predicate_receiver?(node)
49
+ parent = node.parent
50
+ parent.call_type? && parent.predicate_method?
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "log_call_concerns"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Gusto
8
+ module Logging
9
+ # Flags logging raw `params`, which may contain PII.
10
+ #
11
+ # `slice`, `permit`, `fetch`, `dig`, and `[]` are allowed; single-key access may still return a nested hash.
12
+ # `params.require(:key)` alone is flagged because it returns the whole nested hash.
13
+ #
14
+ # @example
15
+ # # bad
16
+ # Rails.logger.info(params)
17
+ # logger.info(params.inspect)
18
+ # Rails.logger.info(params.require(:user))
19
+ #
20
+ # # good
21
+ # Rails.logger.info(params.slice(:id, :status))
22
+ # Rails.logger.info(params.require(:user).permit(:id))
23
+ #
24
+ class RawParams < Base
25
+ include LogCallConcerns
26
+
27
+ MSG = "Avoid logging raw `params` which may contain PII. " \
28
+ "Select reviewed fields with `params.slice(...)`. " \
29
+ "In Rails, `params.permit(...)` also selects fields, " \
30
+ "or use `request.filtered_parameters` for configured filtering."
31
+ RESTRICT_ON_SEND = LOG_METHODS
32
+
33
+ # @!method params_serialization?(node)
34
+ def_node_matcher :params_serialization?, <<~PATTERN
35
+ (call (send nil? :params) {:to_s :inspect :to_json :to_yaml} ...)
36
+ PATTERN
37
+
38
+ # @!method required_params?(node)
39
+ def_node_matcher :required_params?, <<~PATTERN
40
+ (call (send nil? :params) :require ...)
41
+ PATTERN
42
+
43
+ # @!method safe_params?(node)
44
+ def_node_matcher :safe_params?, <<~PATTERN
45
+ (call {(send nil? :params) #required_params?} {:slice :permit :fetch :dig :[]} ...)
46
+ PATTERN
47
+
48
+ def on_send(node)
49
+ return unless logger_call?(node)
50
+
51
+ each_send_in_log(node) do |send_node|
52
+ add_offense(send_node) if exposed_params?(send_node)
53
+ end
54
+ end
55
+
56
+ alias_method :on_csend, :on_send
57
+
58
+ private
59
+
60
+ def exposed_params?(node)
61
+ raw_params?(node) ? !params_with_method_call?(node) : params_serialization?(node)
62
+ end
63
+
64
+ def params_with_method_call?(params_node)
65
+ parent = params_node.parent
66
+ return false unless parent.call_type?
67
+ # `params.require(:key)` returns the whole nested hash, so it is only safe once narrowed.
68
+ return safe_params?(parent.parent) if required_params?(parent)
69
+
70
+ safe_params?(parent) || params_serialization?(parent)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module RuboCop
4
4
  module Cop
5
- module Rack
5
+ module Gusto
6
6
  # Detects HTTP response headers with uppercase characters.
7
7
  # HTTP response header keys should be lowercase for compatibility
8
8
  # with HTTP/2 and modern web standards.
@@ -24,7 +24,7 @@ module RuboCop
24
24
  # response.set_header('x-custom', 'value')
25
25
  # response.headers['content-security-policy'] += policy
26
26
  #
27
- class LowercaseHeaderKeys < Base
27
+ class LowercaseHeaders < Base
28
28
  extend AutoCorrector
29
29
 
30
30
  MSG = "HTTP response header keys should be lowercase. Use `%{downcased}` instead of `%{original}`."
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ module Sidekiq
7
+ # Checks that `perform_async` calls to enqueue Sidekiq jobs are not stubbed
8
+ #
9
+ # @example
10
+ # # bad
11
+ # allow(Foo).to receive(:perform_async)
12
+ # expect(Foo).to receive(:perform_async)
13
+ # expect(Foo).not_to receive(:perform_async)
14
+ #
15
+ # # bad - the stub it verifies is itself the offense, and it may have been
16
+ # # set up in a shared context this cop cannot see
17
+ # expect(Foo).to have_received(:perform_async)
18
+ #
19
+ # # good (still invokes the real method)
20
+ # allow(Foo).to receive(:perform_async).and_call_original
21
+ # expect(Foo).to receive(:perform_async).with(arg).and_call_original
22
+ # allow(Foo).to receive(:perform_async).and_wrap_original { |m, *args| m.call(*args) }
23
+ #
24
+ # # good (checking enqueued jobs)
25
+ # expect { subject }.to change(Foo.jobs, :count).by(n)
26
+ # expect { subject }.not_to change(Foo.jobs, :count)
27
+ # expect(Foo.jobs.count).to eq(n)
28
+ #
29
+ # # good - there is no other way to make an enqueue fail. Sidekiq's testing API
30
+ # # pushes onto Foo.jobs and offers no failure injection, so a spec covering the
31
+ # # rescue around an enqueue has to raise from the stub.
32
+ # allow(Foo).to receive(:perform_async).and_raise(StandardError, "redis down")
33
+ #
34
+ # @safety
35
+ # Autocorrect is unsafe: it appends `.and_call_original` on positive `receive` only, which runs
36
+ # the real `perform_async` during the example (may enqueue jobs, hit external code, or
37
+ # change expectations vs a pure stub). There is no autocorrect for `not_to` / `to_not receive`,
38
+ # since `.and_call_original` would not apply to a negative expectation, nor for
39
+ # `have_received`, whose stub lives elsewhere. Autocorrect is also suppressed when the
40
+ # expectation uses a block, since appending `.and_call_original` would rebind the block to
41
+ # the wrong method.
42
+ class PerformAsyncStub < Base
43
+ extend AutoCorrector
44
+
45
+ MSG = "Prefer checking enqueued jobs over stubbing `perform_async`."
46
+ MSG_RECEIVE = "Prefer checking enqueued jobs over stubbing `perform_async` or add `.and_call_original`."
47
+ RESTRICT_ON_SEND = %i(receive have_received).freeze
48
+
49
+ # Chain modifiers that leave the real `perform_async` reachable, so the example is not
50
+ # replacing the enqueue with a canned result. `and_raise` belongs here even though it
51
+ # never calls the original: simulating a failed enqueue is the one thing Sidekiq's
52
+ # testing API cannot express, so a spec covering the rescue has no other route.
53
+ ALLOWED_CHAIN = %i(and_call_original and_wrap_original and_raise).freeze
54
+
55
+ # @!method stub_perform_async?(node)
56
+ def_node_matcher :stub_perform_async?, <<~PATTERN
57
+ (send nil? {:receive :have_received} (sym :perform_async))
58
+ PATTERN
59
+
60
+ def on_send(node)
61
+ return unless stub_perform_async?(node)
62
+ # `have_received` only verifies a stub that was installed somewhere else, possibly in a
63
+ # shared context or support file out of this cop's reach, so it is flagged on sight and
64
+ # there is nothing local to autocorrect.
65
+ return add_offense(node) if node.method?(:have_received)
66
+
67
+ negative_expectation = false
68
+ allowed_chain = false
69
+
70
+ current = node.parent
71
+ while current&.call_type?
72
+ negative_expectation = true if current.method?(:not_to) || current.method?(:to_not)
73
+ allowed_chain = true if ALLOWED_CHAIN.include?(current.method_name)
74
+
75
+ current = current.parent
76
+ end
77
+
78
+ return add_offense(node) if negative_expectation
79
+ return if allowed_chain
80
+
81
+ tail = message_expectation_chain_tail(node)
82
+ return add_offense(node, message: MSG_RECEIVE) if tail.parent&.block_type?
83
+
84
+ add_offense(node, message: MSG_RECEIVE) do |corrector|
85
+ corrector.insert_after(tail, ".and_call_original")
86
+ end
87
+ end
88
+
89
+ alias_method :on_csend, :on_send
90
+
91
+ private
92
+
93
+ def message_expectation_chain_tail(node)
94
+ tail = node
95
+ loop do
96
+ parent = tail.parent
97
+ break unless parent&.call_type?
98
+ break unless parent.receiver.equal?(tail)
99
+
100
+ tail = parent
101
+ end
102
+ tail
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end