rubocop-gusto 10.10.0 → 11.1.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: 9326f07188d8534d2cad736cf8d79c3e9a40d0296ef6630dd6d88cf859851df2
4
- data.tar.gz: '05178b0a1c63907c768878e893529eb3c095fb1a89f88b0810d58a5ae9a91f0b'
3
+ metadata.gz: 5814e91aac6e402768ff64bd32f0c196838e20d90cbd927286238f68bb9d25b9
4
+ data.tar.gz: d4e988b9973101b7ea9a683900ae7489cbfe128b681d4137c7317f06a45cc247
5
5
  SHA512:
6
- metadata.gz: f78012905401ef80f171d1f05d50d85e4fa897225f3c36bca1f1cb508dd147b2b1809074c1fdef29ccb590c0b41f1d3b47453e6b02afab4c1be87bb6f5e7b65c
7
- data.tar.gz: 25e25916a6a0e3096a5f64678d991674f4d6734abaac9dbb10956d825cb4189dd8f00cac23c9f0f9c28fc43efbbd638b0d48db8044dc60b159e738e09ec873ce
6
+ metadata.gz: 82385ed24fdf344897513f4f034e1f8ae662a5d41b62ccc8dc2c14e735d479deb31acf4c28047fbad1f59d828485798a4677148ca203989f15489d768cb88995
7
+ data.tar.gz: 403192785be424b372d709d2c7d9bac18491603c2a9449a2edeb5b35c981652e7ee87c307265a381b51d5285134fda5a0178adb04087bb6d2db7c10b51d41ae9
data/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
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.1.0](https://github.com/Gusto/rubocop-gusto/compare/v11.0.0...v11.1.0) (2026-06-17)
7
+
8
+
9
+ ### Features
10
+
11
+ * add `Sidekiq/PerformAsyncStub` in a separate config ([#131](https://github.com/Gusto/rubocop-gusto/issues/131)) ([22670d4](https://github.com/Gusto/rubocop-gusto/commit/22670d4a34487417bdb50b8be25e82fb0cac3614))
12
+ * add cops-only entrypoints for selective adoption ([#132](https://github.com/Gusto/rubocop-gusto/issues/132)) ([f0911c2](https://github.com/Gusto/rubocop-gusto/commit/f0911c2f9fc6d272bc8ccb5af830daf659c6ba62))
13
+
14
+ ## [11.0.0](https://github.com/Gusto/rubocop-gusto/compare/v10.10.0...v11.0.0) (2026-06-08)
15
+
16
+
17
+ ### ⚠ BREAKING CHANGES
18
+
19
+ * rubocop-gusto now requires Ruby >= 3.4.
20
+
21
+ ### Features
22
+
23
+ * add Gusto/DescribedClassConstantReference cop ([#127](https://github.com/Gusto/rubocop-gusto/issues/127)) ([f7cf636](https://github.com/Gusto/rubocop-gusto/commit/f7cf6362f3f522f322251da0fdf5c8affa35bc0f))
24
+ * add Gusto/UnreferencedLet cop (requires Ruby >= 3.4) ([#128](https://github.com/Gusto/rubocop-gusto/issues/128)) ([99a2df7](https://github.com/Gusto/rubocop-gusto/commit/99a2df761b52ce11f4f6bf65a5c8e414153efa53))
25
+
6
26
  ## [10.10.0](https://github.com/Gusto/rubocop-gusto/compare/v10.9.4...v10.10.0) (2026-06-01)
7
27
 
8
28
 
data/README.md CHANGED
@@ -28,15 +28,31 @@ $ bundle
28
28
  bundle exec rubocop-gusto init
29
29
  ```
30
30
 
31
- This adds `rubocop-gusto` to your `.rubocop.yml` `plugins:` list and includes any relevant configuration (e.g. Rails-specific rules when Rails is detected).
31
+ This adds `rubocop-gusto` to your `.rubocop.yml` `plugins:` list and includes any relevant configuration (e.g. Rails-specific rules when Rails is detected, Sidekiq-specific rules when the Sidekiq gem is present).
32
32
 
33
33
  If this is an existing project, it is recommended to run the autocorrector (`bundle exec rubocop -a`) and then to regenerate the `.rubocop_todo.yml` (`bundle exec rubocop --auto-gen-config`), so issues can be dealt with piecemeal.
34
34
 
35
+ #### Sidekiq configuration
36
+
37
+ Sidekiq-specific cops live in `config/sidekiq.yml` and are **not** included in `config/default.yml`, so projects without Sidekiq are not linted for those patterns. Running `bundle exec rubocop-gusto init` adds `config/sidekiq.yml` to your `inherit_gem` list automatically when Sidekiq is listed in your `Gemfile` or `Gemfile.lock`.
38
+
39
+ For an existing project that already uses Sidekiq, add the Sidekiq config to your `.rubocop.yml`:
40
+
41
+ ```yaml
42
+ inherit_gem:
43
+ rubocop-gusto:
44
+ - config/default.yml
45
+ - config/sidekiq.yml
46
+ ```
47
+
48
+ 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.
49
+
35
50
  ### Available cops
36
51
 
37
52
  Custom cops live under the following namespaces:
38
53
 
39
54
  - `Gusto/` — general Gusto-specific cops (see [`lib/rubocop/cop/gusto/`](lib/rubocop/cop/gusto/))
55
+ - `Sidekiq/` — cops scoped to Sidekiq patterns (see [`lib/rubocop/cop/sidekiq/`](lib/rubocop/cop/sidekiq/)); configured in [`config/sidekiq.yml`](config/sidekiq.yml)
40
56
  - `Rack/` — cops scoped to Rack middleware patterns (see [`lib/rubocop/cop/rack/`](lib/rubocop/cop/rack/))
41
57
 
42
58
  ## Publishing New Versions
@@ -66,7 +82,7 @@ PR titles must use [Conventional Commits](https://www.conventionalcommits.org/)
66
82
 
67
83
  ### Adding a new cop
68
84
 
69
- 1. Create `lib/rubocop/cop/gusto/<cop_name>.rb`
85
+ 1. Create `lib/rubocop/cop/gusto/<cop_name>.rb`, see [how to create a new cop](https://docs.rubocop.org/rubocop/latest/development.html#create-a-new-cop) and [how to choose a name](https://docs.rubocop.org/rubocop-rspec/development.html#choose-a-name)
70
86
  2. Add an entry to `config/default.yml`, then sort it:
71
87
  ```sh
72
88
  bundle exec rubocop-gusto sort config/default.yml
data/config/default.yml CHANGED
@@ -1,3 +1,5 @@
1
+ inherit_from: gusto_cops.yml
2
+
1
3
  plugins:
2
4
  - rubocop-rspec
3
5
  - rubocop-performance
@@ -30,119 +32,6 @@ Gemspec/RequiredRubyVersion:
30
32
  # We manage our Ruby version in our version file and import that to our Gemfile.
31
33
  Enabled: false
32
34
 
33
- Gusto/BootsnapLoadFile:
34
- Description: 'Do not use Bootsnap to load files. Use `require` instead.'
35
-
36
- Gusto/DatadogConstant:
37
- Exclude:
38
- # calling DataDog directly only allowed in initializers, its library, and tests
39
- - 'config/application.rb'
40
- - 'config/initializers/datadog.rb'
41
- - 'lib/datadog/**/*'
42
- - '**/spec/**/*'
43
- Description: 'Do not call Datadog directly, use an appropriate wrapper library.'
44
-
45
- Gusto/DiscouragedGem:
46
- Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
47
- Enabled: false
48
- Gems: {}
49
-
50
- Gusto/ExecuteMigration:
51
- Description: "Don't use `execute` in migrations. Use a backfill rake task instead."
52
- Include:
53
- - 'db/migrate/*.rb'
54
-
55
- Gusto/FactoryClassesOrModules:
56
- Description: 'Do not define modules or classes in factory directories - they break reloading.'
57
- Include:
58
- - 'spec/**/factories/*.rb'
59
-
60
- Gusto/MinByMaxBy:
61
- Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
62
- Safe: false
63
- Severity: error
64
-
65
- Gusto/NoMetaprogramming:
66
- Description: 'Avoid using metaprogramming techniques like define_method and instance_eval which make code harder to understand and debug.'
67
-
68
- Gusto/NoRescueErrorMessageChecking:
69
- Description: 'Checks for the presence of error message checking within rescue blocks.'
70
-
71
- Gusto/NoSend:
72
- Description: 'Do not call a private method via `__send__`.'
73
- Exclude:
74
- - '**/spec/**/*'
75
-
76
- Gusto/PaperclipOrAttachable:
77
- Description: 'No more new paperclip or Attachable are allowed. Use ActiveStorage instead.'
78
-
79
- Gusto/PerformClassMethod:
80
- Description: 'Prevents accidental definition of `perform` class methods (should be instance methods instead).'
81
- # List of modules that include Sidekiq::Worker.
82
- # Add your other base modules here if they include Sidekiq::Worker too.
83
- WorkerModules:
84
- - Sidekiq::Worker
85
-
86
- Gusto/PolymorphicTypeValidation:
87
- Description: 'Ensures that polymorphic relations include a type validation, which is necessary for generating Sorbet types.'
88
- Include:
89
- - '**/models/**/*.rb'
90
-
91
- Gusto/PreferProcessLastStatus:
92
- Description: 'Prefer using `Process.last_status` instead of the global variables: `$?` and `$CHILD_STATUS`.'
93
-
94
- Gusto/RablExtends:
95
- Description: 'Disallows the use of `extends` in Rabl templates due to poor caching performance. Inline the templating to generate your JSON instead.'
96
- Include:
97
- - '**/*.json.rabl'
98
-
99
- Gusto/RailsEnv:
100
- Description: 'Use Feature Flags or config instead of `Rails.env`.'
101
-
102
- Gusto/RakeConstants:
103
- Description: 'Do not define a constant in rake file, because they are sometimes `load`ed, instead of `require`d which can lead to warnings about redefining constants.'
104
- Include:
105
- - '**/*.rake'
106
- - 'Rakefile'
107
-
108
- Gusto/RegexpBypass:
109
- Description: 'Ensures regular expressions use \A and \z anchors instead of ^ and $ for security validation.'
110
- Exclude:
111
- - '**/spec/**/*'
112
- Safe: false
113
-
114
- Gusto/RspecDateTimeMock:
115
- Description: "Don't mock Date/Time/DateTime directly. Use Rails Testing Time Helpers (eg `freeze_time` and `travel_to`) instead."
116
- Include:
117
- - '**/spec/**/*'
118
- Safe: false
119
-
120
- Gusto/SidekiqParams:
121
- Description: 'Sidekiq perform methods cannot take keyword arguments.'
122
-
123
- Gusto/ToplevelConstants:
124
- Description: 'Prevents top-level constants from being defined outside of initializers.'
125
- Include:
126
- - '**/*.rb'
127
- Exclude:
128
- - '**/bin/*'
129
- - 'bin/*'
130
- - 'config/**/*'
131
- - 'lib/*.rb'
132
- - 'packs/**/{db/seeds,lib,config/initializers}/**/*'
133
- - 'script/**/*'
134
- - 'spec/rails_helper.rb'
135
- - '**/spec/support/**/*'
136
- - '**/*/spec_helper.rb'
137
- - 'spec/support/**/*.rb'
138
-
139
- Gusto/UsePaintNotColorize:
140
- Description: 'Use Paint instead of colorize for terminal colors.'
141
- SafeAutoCorrect: false
142
-
143
- Gusto/VcrRecordings:
144
- Description: 'VCR should be set to not record in tests. Use vcr: {record: :none}.'
145
-
146
35
  Layout/BlockAlignment:
147
36
  EnforcedStyleAlignWith: start_of_block
148
37
 
@@ -373,6 +262,13 @@ Rack/LowercaseHeaderKeys:
373
262
  Rake/ClassDefinitionInTask:
374
263
  Enabled: false
375
264
 
265
+ Sidekiq/PerformAsyncStub:
266
+ Description: 'Prefer checking enqueued jobs over stubbing `perform_async`.'
267
+ Enabled: false
268
+ SafeAutoCorrect: false # Autocorrect appends `.and_call_original`, which runs real `perform_async` during the example
269
+ Include:
270
+ - '**/spec/**/*_spec.rb'
271
+
376
272
  Sorbet/ForbidTUnsafe:
377
273
  # T.unsafe completely disables typechecking. Prefer T.cast, shims, or
378
274
  # reorganizing the code over silently turning off the type checker.
@@ -0,0 +1,135 @@
1
+ # Gusto cop defaults only — no built-in cop overrides.
2
+ # Use this with `inherit_gem` when you want Gusto cop scoping (Include/Exclude/Severity)
3
+ # without inheriting rubocop-gusto's full configuration layer.
4
+ #
5
+ # In your .rubocop.yml:
6
+ # require:
7
+ # - rubocop/cop/gusto/all
8
+ # inherit_gem:
9
+ # rubocop-gusto:
10
+ # - config/gusto_cops.yml
11
+
12
+ Gusto/BootsnapLoadFile:
13
+ Description: 'Do not use Bootsnap to load files. Use `require` instead.'
14
+
15
+ Gusto/DatadogConstant:
16
+ Exclude:
17
+ # calling DataDog directly only allowed in initializers, its library, and tests
18
+ - 'config/application.rb'
19
+ - 'config/initializers/datadog.rb'
20
+ - 'lib/datadog/**/*'
21
+ - '**/spec/**/*'
22
+ Description: 'Do not call Datadog directly, use an appropriate wrapper library.'
23
+
24
+ Gusto/DescribedClassConstantReference:
25
+ Description: 'Flags constants scoped through `described_class` (e.g. `described_class::Foo`), which Sorbet cannot resolve statically.'
26
+ Enabled: true
27
+ SafeAutoCorrect: false
28
+ Include:
29
+ - '**/spec/**/*'
30
+
31
+ Gusto/DiscouragedGem:
32
+ Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
33
+ Enabled: false
34
+ Gems: {}
35
+
36
+ Gusto/ExecuteMigration:
37
+ Description: "Don't use `execute` in migrations. Use a backfill rake task instead."
38
+ Include:
39
+ - 'db/migrate/*.rb'
40
+
41
+ Gusto/FactoryClassesOrModules:
42
+ Description: 'Do not define modules or classes in factory directories - they break reloading.'
43
+ Include:
44
+ - 'spec/**/factories/*.rb'
45
+
46
+ Gusto/MinByMaxBy:
47
+ Description: 'Checks for the use of `min` or `max` with a proc. Corrects to `min_by` or `max_by`.'
48
+ Safe: false
49
+ Severity: error
50
+
51
+ Gusto/NoMetaprogramming:
52
+ Description: 'Avoid using metaprogramming techniques like define_method and instance_eval which make code harder to understand and debug.'
53
+
54
+ Gusto/NoRescueErrorMessageChecking:
55
+ Description: 'Checks for the presence of error message checking within rescue blocks.'
56
+
57
+ Gusto/NoSend:
58
+ Description: 'Do not call a private method via `__send__`.'
59
+ Exclude:
60
+ - '**/spec/**/*'
61
+
62
+ Gusto/PaperclipOrAttachable:
63
+ Description: 'No more new paperclip or Attachable are allowed. Use ActiveStorage instead.'
64
+
65
+ Gusto/PerformClassMethod:
66
+ Description: 'Prevents accidental definition of `perform` class methods (should be instance methods instead).'
67
+ WorkerModules:
68
+ - Sidekiq::Worker
69
+
70
+ Gusto/PolymorphicTypeValidation:
71
+ Description: 'Ensures that polymorphic relations include a type validation, which is necessary for generating Sorbet types.'
72
+ Include:
73
+ - '**/models/**/*.rb'
74
+
75
+ Gusto/PreferProcessLastStatus:
76
+ Description: 'Prefer using `Process.last_status` instead of the global variables: `$?` and `$CHILD_STATUS`.'
77
+
78
+ Gusto/RablExtends:
79
+ Description: 'Disallows the use of `extends` in Rabl templates due to poor caching performance. Inline the templating to generate your JSON instead.'
80
+ Include:
81
+ - '**/*.json.rabl'
82
+
83
+ Gusto/RailsEnv:
84
+ Description: 'Use Feature Flags or config instead of `Rails.env`.'
85
+
86
+ Gusto/RakeConstants:
87
+ Description: 'Do not define a constant in rake file, because they are sometimes `load`ed, instead of `require`d which can lead to warnings about redefining constants.'
88
+ Include:
89
+ - '**/*.rake'
90
+ - 'Rakefile'
91
+
92
+ Gusto/RegexpBypass:
93
+ Description: 'Ensures regular expressions use \A and \z anchors instead of ^ and $ for security validation.'
94
+ Exclude:
95
+ - '**/spec/**/*'
96
+ Safe: false
97
+
98
+ Gusto/RspecDateTimeMock:
99
+ Description: "Don't mock Date/Time/DateTime directly. Use Rails Testing Time Helpers (eg `freeze_time` and `travel_to`) instead."
100
+ Include:
101
+ - '**/spec/**/*'
102
+ Safe: false
103
+
104
+ Gusto/SidekiqParams:
105
+ Description: 'Sidekiq perform methods cannot take keyword arguments.'
106
+
107
+ Gusto/ToplevelConstants:
108
+ Description: 'Prevents top-level constants from being defined outside of initializers.'
109
+ Include:
110
+ - '**/*.rb'
111
+ Exclude:
112
+ - '**/bin/*'
113
+ - 'bin/*'
114
+ - 'config/**/*'
115
+ - 'lib/*.rb'
116
+ - 'packs/**/{db/seeds,lib,config/initializers}/**/*'
117
+ - 'script/**/*'
118
+ - 'spec/rails_helper.rb'
119
+ - '**/spec/support/**/*'
120
+ - '**/*/spec_helper.rb'
121
+ - 'spec/support/**/*.rb'
122
+
123
+ Gusto/UnreferencedLet:
124
+ Description: 'Removes a lazy let whose name is never referenced (its block never runs).'
125
+ Enabled: true
126
+ Include:
127
+ - '**/spec/**/*_spec.rb'
128
+ SafeAutoCorrect: false
129
+
130
+ Gusto/UsePaintNotColorize:
131
+ Description: 'Use Paint instead of colorize for terminal colors.'
132
+ SafeAutoCorrect: false
133
+
134
+ Gusto/VcrRecordings:
135
+ Description: 'VCR should be set to not record in tests. Use vcr: {record: :none}.'
@@ -0,0 +1,7 @@
1
+ #
2
+ # This file should be inherited alongside default.yml for projects using Sidekiq
3
+ #
4
+ # After you add a rule, sort this file with `bundle exec rubocop-gusto sort config/sidekiq.yml`
5
+
6
+ Sidekiq/PerformAsyncStub:
7
+ Enabled: true
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+ require_relative "../../gusto"
5
+ require_relative "../../gusto/version"
6
+
7
+ Dir.glob("#{__dir__}/*.rb").each { |f| require f unless f == __FILE__ }
@@ -3,7 +3,17 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
- # Do not use Bootsnap to load files. Use `require` instead.
6
+ # Prefer `YAML.load_file`/`JSON.load_file` over reading a file and then
7
+ # parsing its contents. Bootsnap caches the parsed result of `load_file`,
8
+ # so this improves load time.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # YAML.load(File.read("config.yml"))
13
+ # File.open("config.yml") { |f| YAML.load(f) }
14
+ #
15
+ # # good
16
+ # YAML.load_file("config.yml")
7
17
  class BootsnapLoadFile < Base
8
18
  PROHIBITED_CONSTANTS = Set[:YAML, :JSON].freeze
9
19
  RESTRICT_ON_SEND = %i(load).freeze
@@ -3,6 +3,16 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
+ # Disallow referencing the `Datadog` constant directly. Calls should go
7
+ # through an approved wrapper library so instrumentation stays consistent
8
+ # and swappable.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # Datadog::Tracing.active_trace
13
+ #
14
+ # # good
15
+ # Observability.active_trace
6
16
  class DatadogConstant < Base
7
17
  MSG = "Do not call Datadog directly, use an appropriate wrapper library."
8
18
  NAMESPACE = "Datadog"
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Gusto
6
+ # Flags constants that are scoped through `described_class`, e.g.
7
+ # `described_class::Worker`.
8
+ #
9
+ # `described_class` is an RSpec helper method resolved at runtime, so
10
+ # Sorbet's static analysis treats `described_class::Worker` as a dynamic
11
+ # constant reference and cannot resolve it (`Dynamic constant references
12
+ # are unsupported`, https://srb.help/5001). Reference the constant by its
13
+ # fully-qualified name instead. A bare `described_class` (with no `::`
14
+ # constant lookup) is an ordinary method call and is left alone.
15
+ #
16
+ # Autocorrection replaces `described_class` with the constant that the
17
+ # enclosing example group describes. It is marked unsafe
18
+ # (`SafeAutoCorrect: false`) because the rewrite relies on the described
19
+ # constant being a statically-written name; review the result before
20
+ # committing. In particular, a constant defined on an *ancestor* of the
21
+ # described class is qualified against the described class itself, which
22
+ # is correct at runtime but which Sorbet cannot resolve through the
23
+ # inheritance chain -- re-point those to the defining ancestor by hand.
24
+ #
25
+ # @example
26
+ # # bad
27
+ # RSpec.describe Payments::Processor do
28
+ # describe described_class::Worker do
29
+ # end
30
+ # end
31
+ #
32
+ # # good
33
+ # RSpec.describe Payments::Processor do
34
+ # describe Payments::Processor::Worker do
35
+ # end
36
+ # end
37
+ #
38
+ # # good - `RSpec.describe self` resolves to the enclosing namespace
39
+ # module Payments
40
+ # RSpec.describe self do
41
+ # it { expect(Payments::TIMEOUT).to eq(5) }
42
+ # end
43
+ # end
44
+ #
45
+ # # good - a bare `described_class` is not a constant reference
46
+ # RSpec.describe Payments::Processor do
47
+ # subject { described_class.new }
48
+ # end
49
+ class DescribedClassConstantReference < Base
50
+ extend AutoCorrector
51
+
52
+ MSG = "Use the fully-qualified constant name instead of scoping it through " \
53
+ "`described_class`, which Sorbet cannot resolve statically."
54
+
55
+ # A constant whose scope is a no-receiver `described_class`, e.g.
56
+ # `described_class::Worker`.
57
+ # @!method const_scoped_on_described_class?(node)
58
+ def_node_matcher :const_scoped_on_described_class?, <<~PATTERN
59
+ (const (send nil? :described_class) _)
60
+ PATTERN
61
+
62
+ # An example group, capturing its first argument: a constant
63
+ # (`RSpec.describe Foo do`, `context Foo do`), `self`
64
+ # (`RSpec.describe self do`), and so on.
65
+ # @!method example_group_described_argument(node)
66
+ def_node_matcher :example_group_described_argument, <<~PATTERN
67
+ (block
68
+ (send {(const nil? :RSpec) nil?}
69
+ {:describe :xdescribe :fdescribe :context :xcontext :fcontext :feature :example_group}
70
+ $_ ...)
71
+ ...)
72
+ PATTERN
73
+
74
+ # Whether a node routes through a no-receiver `described_class`.
75
+ # @!method scoped_through_described_class?(node)
76
+ def_node_search :scoped_through_described_class?, <<~PATTERN
77
+ (send nil? :described_class)
78
+ PATTERN
79
+
80
+ def on_const(node)
81
+ return unless const_scoped_on_described_class?(node)
82
+
83
+ scope = node.children[0]
84
+ add_offense(scope) do |corrector|
85
+ replacement = described_class_replacement(node)
86
+ corrector.replace(scope, replacement) if replacement
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ # The fully-qualified name (as a String) that `described_class` resolves
93
+ # to lexically, from the nearest enclosing example group, or nil if it
94
+ # cannot be determined statically.
95
+ #
96
+ # - `describe SomeClass` resolves to that constant's written name.
97
+ # - `describe self` resolves to the enclosing module/class namespace.
98
+ # - `describe described_class::X` qualifies the describe argument itself
99
+ # against the outer group; a reference in such a group's *body* resolves
100
+ # at runtime to the scoped (statically unknown) class, so we decline to
101
+ # autocorrect it. Once the enclosing `described_class::X` is rewritten,
102
+ # a later pass resolves the body reference correctly.
103
+ # - Any other describe argument (e.g. a string) is skipped, and the
104
+ # search continues at the next enclosing example group.
105
+ def described_class_replacement(node)
106
+ node.each_ancestor(:block) do |block_node|
107
+ described_argument = example_group_described_argument(block_node)
108
+ next if described_argument.nil?
109
+
110
+ if described_argument.self_type?
111
+ namespace = enclosing_namespace(block_node)
112
+ return namespace if namespace
113
+ elsif described_argument.const_type?
114
+ return described_argument.source unless scoped_through_described_class?(described_argument)
115
+ return nil unless reference_within_described_constant?(described_argument, node)
116
+ end
117
+ end
118
+ nil
119
+ end
120
+
121
+ # The fully-qualified name of the module/class lexically enclosing the
122
+ # example group, which is what `self` refers to in `RSpec.describe self`.
123
+ def enclosing_namespace(block_node)
124
+ names = block_node.each_ancestor(:class, :module).map { |mod| mod.children.first.source }
125
+ return if names.empty?
126
+
127
+ names.reverse.join("::")
128
+ end
129
+
130
+ # Whether the offending constant is the described constant itself (the
131
+ # describe argument) rather than a reference inside the group's body.
132
+ def reference_within_described_constant?(described_constant, node)
133
+ node.equal?(described_constant) ||
134
+ described_constant.each_descendant(:const).any? { |const_node| const_node.equal?(node) }
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -3,13 +3,16 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
- # Flags installation of discouraged gems (e.g., timecop) in Gemfiles and gemspecs.
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.
7
9
  #
8
- # Configuration:
9
- # Gems:
10
- # timecop: "Use Rails' time helpers (e.g., freeze_time, travel_to) instead of Timecop."
10
+ # @example Gems: { timecop: "Use Rails' time helpers instead of Timecop." }
11
+ # # bad
12
+ # gem "timecop"
11
13
  #
12
- # This cop is intended to be enabled in Rails projects via config/rails.yml.
14
+ # # good
15
+ # # Use Rails' time helpers (freeze_time, travel_to) instead.
13
16
  class DiscouragedGem < Base
14
17
  MSG = "Avoid using the '%{gem}' gem. %{advice}"
15
18
 
@@ -3,6 +3,20 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
+ # Disallow `execute` (raw SQL) in migrations. Run raw SQL from a backfill
7
+ # rake task, or pass SQL options to `add_column`/`change_column` instead,
8
+ # so migrations stay reversible and schema-focused.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # def up
13
+ # execute("UPDATE users SET active = true")
14
+ # end
15
+ #
16
+ # # good
17
+ # def up
18
+ # add_column :users, :active, :boolean, default: true
19
+ # end
6
20
  class ExecuteMigration < Base
7
21
  MSG = "Do not use `execute` to run raw SQL in a migration. Run the query from a backfill rake task or pass the SQL options to the `add_column`/`change_column` method."
8
22
  RESTRICT_ON_SEND = [:execute].freeze
@@ -3,6 +3,24 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
+ # Disallow defining classes or modules in factory directories. They break
7
+ # Rails autoloading/reloading; define shared helpers outside the factories.
8
+ #
9
+ # @example
10
+ # # bad
11
+ # # spec/factories/users.rb
12
+ # class UserHelper
13
+ # end
14
+ #
15
+ # FactoryBot.define do
16
+ # factory :user
17
+ # end
18
+ #
19
+ # # good
20
+ # # spec/factories/users.rb
21
+ # FactoryBot.define do
22
+ # factory :user
23
+ # end
6
24
  class FactoryClassesOrModules < Base
7
25
  MSG = "Do not define modules or classes in factory directories - they break reloading"
8
26
 
@@ -4,6 +4,15 @@
4
4
  module RuboCop
5
5
  module Cop
6
6
  module Gusto
7
+ # Disallow new Paperclip / Attachable attachments. New attachments should
8
+ # use ActiveStorage instead.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # has_attached_file :avatar
13
+ #
14
+ # # good
15
+ # has_one_attached :avatar
7
16
  class PaperclipOrAttachable < Base
8
17
  MSG = "No more new paperclip or Attachable are allowed. New attachments should use ActiveStorage instead"
9
18
  RESTRICT_ON_SEND = %i(has_attached_file has_pdf_attachment has_attachment).freeze
@@ -1,11 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # This cop enforces that polymorphic relations have a corresponding validation
4
- # for their type field with an inclusion validation. This is required in order for Tapioca
5
- # to generate correct Sorbet types
6
3
  module RuboCop
7
4
  module Cop
8
5
  module Gusto
6
+ # Require polymorphic relations to validate their `*_type` field with an
7
+ # inclusion validation (or `polymorphic_methods_for`). This is needed for
8
+ # Tapioca to generate correct Sorbet types.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # belongs_to :subscription_detail, polymorphic: true
13
+ #
14
+ # # good
15
+ # VALID_TYPES = T.let([Foo.polymorphic_name, Bar.polymorphic_name].freeze, T::Array[String])
16
+ # belongs_to :subscription_detail, polymorphic: true
17
+ # validates :subscription_detail_type, presence: true, inclusion: { in: VALID_TYPES }
18
+ #
19
+ # # also good
20
+ # include PolymorphicCallable
21
+ # VALID_TYPES = T.let([Foo.polymorphic_name, Bar.polymorphic_name].freeze, T::Array[String])
22
+ # belongs_to :subscription_detail, polymorphic: true
23
+ # polymorphic_methods_for :subscription_detail, VALID_TYPES
9
24
  class PolymorphicTypeValidation < Base
10
25
  RESTRICT_ON_SEND = %i(belongs_to validates polymorphic_methods_for).freeze
11
26
 
@@ -3,6 +3,18 @@
3
3
  module RuboCop
4
4
  module Cop
5
5
  module Gusto
6
+ # Disallow keyword arguments on Sidekiq `perform` methods. Sidekiq
7
+ # serializes job arguments as JSON and replays them positionally, so
8
+ # keyword arguments are not preserved.
9
+ #
10
+ # @example
11
+ # # bad
12
+ # def perform(user_id:, force: false)
13
+ # end
14
+ #
15
+ # # good
16
+ # def perform(user_id, force = false)
17
+ # end
6
18
  class SidekiqParams < Base
7
19
  MSG = "Sidekiq perform methods cannot take keyword arguments"
8
20
 
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rubocop-rspec"
5
+
6
+ module RuboCop
7
+ module Cop
8
+ module Gusto
9
+ # Flags lazy `let` declarations whose name is never referenced. A lazy `let(:name) { ... }`
10
+ # is only evaluated when `name` is called, so an unreferenced one is dead code -- its block
11
+ # never runs -- and is deleted.
12
+ #
13
+ # Eager `let!` is intentionally out of scope: it runs its block before every example for its
14
+ # side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is
15
+ # handled here.
16
+ #
17
+ # Detection is file-scoped: a `let` referenced only from another file (through a shared
18
+ # example or an included test harness) cannot be seen, so the cop stays conservative and
19
+ # prefers false negatives over false positives:
20
+ # - a name defined more than once in the file by `let`/`let!`/`subject` (an override /
21
+ # `super` chain, including a `subject` that overrides a `let` of the same name) is never
22
+ # flagged;
23
+ # - a `let` declared lexically inside a `shared_examples` / `shared_examples_for` /
24
+ # `shared_context` block is skipped (its consumers live in other files);
25
+ # - every `let` in a file that uses `it_behaves_like` / `it_should_behave_like` /
26
+ # `include_examples` / `include_context` is skipped, because an included shared block may
27
+ # reference the binding by a name we cannot follow statically;
28
+ # - any `let` whose name is also defined as a `let`/`subject` in a `spec/support/**` helper is
29
+ # skipped, because it is almost certainly overriding a contract an included harness consumes;
30
+ # - `let(:cop_config)` is skipped: it is a rubocop-rspec contract consumed by the `:config`
31
+ # shared context, not by a reference in the spec file; and
32
+ # - every `let` in a file that reflectively dispatches through a name we cannot resolve
33
+ # statically (e.g. `send("expected_#{type}")`) is skipped, since any `let` could be the
34
+ # target.
35
+ # A name counts as referenced if it is called bare (`foo`), appears as a symbol (`:foo`)
36
+ # anywhere but the let's own name argument, or appears as an identifier-shaped token inside
37
+ # any string/heredoc literal -- covering dynamic dispatch, `:foo` entries in data tables the
38
+ # spec later dispatches on, and bindings named only inside raw SQL/GraphQL text.
39
+ #
40
+ # Because a bare `:foo` symbol anywhere counts as a reference, commonly-named lets
41
+ # (`let(:user)`, `let(:company)`, `let(:id)`) are essentially never flagged -- `create(:user)`,
42
+ # `:name` hash keys, and the like saturate the file. This conservative bias means the cop
43
+ # realistically only deletes distinctively-named dead lets; it is not a complete dead-`let`
44
+ # finder.
45
+ #
46
+ # @example
47
+ # # bad (name never referenced -- deleted, the block never runs)
48
+ # let(:unused) { create(:thing) }
49
+ #
50
+ # # good
51
+ # let(:thing) { create(:thing) }
52
+ # it { expect(thing).to be_present }
53
+ #
54
+ class UnreferencedLet < ::RuboCop::Cop::RSpec::Base
55
+ extend AutoCorrector
56
+ include RangeHelp
57
+
58
+ DEFINITION_METHODS = Set[:let, :let!, :subject].freeze
59
+ # `let`s consumed by a test framework rather than by a reference in the spec file. The
60
+ # rubocop-rspec `:config` shared context reads `cop_config`, so it is live even though the
61
+ # spec never names it.
62
+ FRAMEWORK_RESERVED_NAMES = %i(cop_config).freeze
63
+ # Reflective dispatch methods whose target is the first argument. When that argument is not
64
+ # a statically-resolvable name (a `sym` or plain `str`) -- e.g. `send("expected_#{type}")` --
65
+ # the called name cannot be known, so the whole file is left untouched.
66
+ DYNAMIC_DISPATCH_METHODS = %i(send public_send __send__ try try! method public_method respond_to?).freeze
67
+ FRAMEWORK_LET_PATTERN = /\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
68
+ # Identifier-shaped tokens inside a string/heredoc literal. A `let` whose name appears only
69
+ # inside string text -- e.g. a binding or column referenced in raw SQL/GraphQL the spec
70
+ # later executes -- counts as referenced, so it is not deleted.
71
+ IDENTIFIER_IN_STRING = /[A-Za-z_]\w*[!?]?/
72
+ MSG = "Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
73
+ RESTRICT_ON_SEND = %i(let).freeze
74
+ # The glob and the pathspec encode the SAME set of files two ways: `Dir.glob` (fallback) and
75
+ # a regexp filter over `git ls-files` output. Keep them in sync if either changes.
76
+ SUPPORT_FILES_GLOB = "**/spec/support/**/*.rb"
77
+ SUPPORT_FILES_PATHSPEC = %r{(?:\A|/)spec/support/.+\.rb\z}
78
+
79
+ # The name symbol of any definition (`let`/`let!`/`subject`) in any block form -- used to
80
+ # count how many times a name is defined, so override / `super` chains (including a
81
+ # `subject` that overrides a `let` of the same name) are never flagged.
82
+ # @!method definition_name(node)
83
+ def_node_matcher :definition_name, <<~PATTERN
84
+ (any_block (send nil? %DEFINITION_METHODS (sym $_) ...) ...)
85
+ PATTERN
86
+
87
+ class << self
88
+ # Names defined as `let`/`subject` anywhere under `spec/support/**`. Computed once per
89
+ # process (lazily, after boot) and shared across every file the cop inspects.
90
+ def framework_let_names
91
+ @framework_let_names ||= scan_framework_let_names(support_file_paths)
92
+ end
93
+
94
+ # Enumerate `spec/support/**/*.rb`. Prefer `git ls-files` (reads the git index, skipping
95
+ # untracked trees like `node_modules`): a leading-`**` `Dir.glob` walks the entire
96
+ # repository and costs seconds, while reading the index costs tens of milliseconds. Fall
97
+ # back to `Dir.glob` when not in a git work tree or `git` is unavailable.
98
+ #
99
+ # Tradeoff: an untracked (brand-new, uncommitted) `spec/support/*.rb` override is invisible
100
+ # to `git ls-files`. In that narrow window its contract names are not exempted; once
101
+ # committed it is seen like any other support file.
102
+ def support_file_paths
103
+ git_tracked_support_files || ::Dir.glob(SUPPORT_FILES_GLOB)
104
+ end
105
+
106
+ def git_tracked_support_files
107
+ output, status = ::Open3.capture2("git", "ls-files", "-z")
108
+ return nil unless status.success?
109
+
110
+ output.split("\x0").grep(SUPPORT_FILES_PATHSPEC)
111
+ rescue ::SystemCallError
112
+ nil
113
+ end
114
+
115
+ def scan_framework_let_names(paths)
116
+ paths.each_with_object(Set.new) do |path, names|
117
+ extract_let_names(read_source(path), names)
118
+ end
119
+ end
120
+
121
+ def extract_let_names(source, names)
122
+ source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym }
123
+ names
124
+ end
125
+
126
+ def read_source(path)
127
+ return "" unless ::File.file?(path)
128
+
129
+ ::File.read(path)
130
+ end
131
+ end
132
+
133
+ def on_send(node)
134
+ return unless node.receiver.nil?
135
+
136
+ name_argument = node.first_argument
137
+ return unless name_argument&.sym_type?
138
+
139
+ block = node.block_node
140
+ return unless block
141
+
142
+ name = name_argument.value
143
+ return if exempt_from_deletion?(name, block)
144
+
145
+ add_offense(node.loc.selector, message: format(MSG, name:)) do |corrector|
146
+ corrector.remove(removal_range(block))
147
+ end
148
+ end
149
+
150
+ private
151
+
152
+ # A lazy `let` is exempt from deletion whenever file-scoped analysis cannot prove its name
153
+ # is dead: its name is a framework-reserved contract (e.g. `cop_config`), the file
154
+ # dispatches through a name we cannot resolve statically, it consumes shared examples, the
155
+ # `let` is lexically inside a shared-example definition, its name is a `spec/support/**`
156
+ # framework contract, it is overridden by another definition of the same name, or it is
157
+ # referenced somewhere in the file.
158
+ def exempt_from_deletion?(name, block)
159
+ FRAMEWORK_RESERVED_NAMES.include?(name) ||
160
+ dynamic_dispatch? ||
161
+ consumes_shared_examples? ||
162
+ within_shared_definition?(block) ||
163
+ self.class.framework_let_names.include?(name) ||
164
+ overridden?(name) ||
165
+ referenced?(name)
166
+ end
167
+
168
+ # Delete the `let` block, plus:
169
+ # - an immediately-preceding `sig { ... }` (so a Sorbet signature is not left dangling),
170
+ # - explanatory comment lines attached directly above it (so they are not orphaned), and
171
+ # - a single trailing blank line where removal would otherwise leave a stray/duplicate
172
+ # blank -- unless the line above is a `let`/`subject`, where that blank is the required
173
+ # separator after the now-final let and must stay.
174
+ def removal_range(node)
175
+ lines = processed_source.lines
176
+ start_line = node.source_range.first_line
177
+ end_line = node.source_range.last_line
178
+
179
+ sig = preceding_sig(node)
180
+ start_line = sig.source_range.first_line if sig
181
+
182
+ start_line -= 1 while start_line > 1 && absorbable_comment?(lines[start_line - 2])
183
+
184
+ if end_line < lines.size && blank_line?(lines[end_line]) &&
185
+ !(start_line > 1 && let_or_subject_line?(lines[start_line - 2]))
186
+ end_line += 1
187
+ end
188
+
189
+ buffer = processed_source.buffer
190
+ range_by_whole_lines(buffer.line_range(start_line).join(buffer.line_range(end_line)), include_final_newline: true)
191
+ end
192
+
193
+ def absorbable_comment?(source_line)
194
+ stripped = source_line.strip
195
+ stripped.start_with?("#") && !stripped.start_with?("# rubocop:")
196
+ end
197
+
198
+ def blank_line?(source_line)
199
+ source_line.strip.empty?
200
+ end
201
+
202
+ def let_or_subject_line?(source_line)
203
+ source_line.match?(/\A\s*(?:let!?|subject)\b/)
204
+ end
205
+
206
+ def preceding_sig(node)
207
+ sibling = node.left_sibling
208
+ return unless sibling.is_a?(::RuboCop::AST::BlockNode)
209
+ return unless sibling.method?(:sig)
210
+
211
+ sibling
212
+ end
213
+
214
+ def within_shared_definition?(node)
215
+ node.each_ancestor(:any_block).any? { |ancestor| shared_group?(ancestor) }
216
+ end
217
+
218
+ def consumes_shared_examples?
219
+ return @consumes_shared_examples unless @consumes_shared_examples.nil?
220
+
221
+ @consumes_shared_examples = processed_source.ast.each_node(:call).any? { |send_node| include?(send_node) }
222
+ end
223
+
224
+ # True when the file reflectively dispatches through a name we cannot resolve statically --
225
+ # `send`/`public_send`/`method`/etc. called with anything other than a `sym` or plain `str`
226
+ # first argument (most commonly an interpolated string, `send("expected_#{type}")`). In
227
+ # that case any `let` in the file could be the dispatch target, so none are deleted.
228
+ def dynamic_dispatch?
229
+ return @dynamic_dispatch unless @dynamic_dispatch.nil?
230
+
231
+ @dynamic_dispatch = processed_source.ast.each_node(:call).any? do |send_node|
232
+ next false unless DYNAMIC_DISPATCH_METHODS.include?(send_node.method_name)
233
+
234
+ target = send_node.first_argument
235
+ target && !target.sym_type? && !target.str_type?
236
+ end
237
+ end
238
+
239
+ def overridden?(name)
240
+ definitions_by_name.fetch(name, 0) > 1
241
+ end
242
+
243
+ def definitions_by_name
244
+ @definitions_by_name ||= processed_source.ast.each_node(:any_block).each_with_object(Hash.new(0)) do |node, counts|
245
+ name = definition_name(node)
246
+ counts[name] += 1 if name
247
+ end
248
+ end
249
+
250
+ def referenced?(name)
251
+ referenced_names.include?(name)
252
+ end
253
+
254
+ # A name is "referenced" if it is called as a bare method (`foo`), appears as a symbol
255
+ # literal (`:foo`) other than the let/subject's own name argument, or appears as an
256
+ # identifier-shaped token inside any string/heredoc literal. The symbol and string cases
257
+ # cover indirect invocation -- `send(:foo)` / `send("foo")`, a `:foo`/`"foo"` listed in a
258
+ # data table the spec later dispatches on, or a binding named only inside raw SQL/GraphQL
259
+ # text the spec executes -- which file-scoped analysis cannot otherwise follow. (Tokenizing
260
+ # string bodies, rather than matching the whole string, keeps a `let` referenced only from
261
+ # inside a multi-word heredoc from being deleted.) Interpolated-string *dispatch* is handled
262
+ # separately by `dynamic_dispatch?`, which exempts the whole file.
263
+ def referenced_names
264
+ @referenced_names ||= processed_source.ast.each_node(:sym, :str, :call).each_with_object(Set.new) do |node, names|
265
+ if node.sym_type?
266
+ names << node.value unless definition_name_argument?(node)
267
+ elsif node.str_type?
268
+ # A string with invalid encoding (e.g. a deliberate bad-UTF-8 test fixture) cannot
269
+ # contain an identifier-shaped reference and would raise on `scan`, so skip it.
270
+ node.value.scan(IDENTIFIER_IN_STRING) { |token| names << token.to_sym } if node.value.valid_encoding?
271
+ elsif node.receiver.nil? && node.arguments.empty?
272
+ names << node.method_name
273
+ end
274
+ end
275
+ end
276
+
277
+ def definition_name_argument?(sym_node)
278
+ parent = sym_node.parent
279
+ parent.send_type? && parent.receiver.nil? && DEFINITION_METHODS.include?(parent.method_name)
280
+ end
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sidekiq
6
+ # Checks that `perform_async` calls to enqueue Sidekiq jobs are not stubbed
7
+ #
8
+ # @example
9
+ # # bad
10
+ # allow(Foo).to receive(:perform_async)
11
+ # expect(Foo).to receive(:perform_async)
12
+ # expect(Foo).not_to receive(:perform_async)
13
+ #
14
+ # # good (still invokes the real method)
15
+ # allow(Foo).to receive(:perform_async).and_call_original
16
+ # expect(Foo).to receive(:perform_async).with(arg).and_call_original
17
+ # allow(Foo).to receive(:perform_async).and_wrap_original { |m, *args| m.call(*args) }
18
+ #
19
+ # # good (checking enqueued jobs)
20
+ # expect { subject }.to change(Foo.jobs, :count).by(n)
21
+ # expect { subject }.not_to change(Foo.jobs, :count)
22
+ # expect(Foo.jobs.count).to eq(n)
23
+ #
24
+ # # good (only checks previously pre-stubbed objects)
25
+ # expect(Foo).to have_received(:perform_async)
26
+ #
27
+ # @safety
28
+ # Autocorrect is unsafe: it appends `.and_call_original` on positive `receive` only, which runs
29
+ # the real `perform_async` during the example (may enqueue jobs, hit external code, or
30
+ # change expectations vs a pure stub). There is no autocorrect for `not_to` / `to_not receive`,
31
+ # since `.and_call_original` would not apply to a negative expectation. Autocorrect is also
32
+ # suppressed when the expectation uses a block, since appending `.and_call_original` would
33
+ # rebind the block to the wrong method.
34
+ class PerformAsyncStub < Base
35
+ extend AutoCorrector
36
+
37
+ MSG = "Prefer checking enqueued jobs over stubbing `perform_async`."
38
+ MSG_RECEIVE = "Prefer checking enqueued jobs over stubbing `perform_async` or add `.and_call_original`."
39
+ RESTRICT_ON_SEND = %i(receive).freeze
40
+
41
+ # TODO: this should match on perform_async, not on receive, requires pattern update
42
+ # @!method stub_perform_async?(node)
43
+ def_node_matcher :stub_perform_async?, <<~PATTERN
44
+ (send nil? :receive (sym :perform_async))
45
+ PATTERN
46
+
47
+ def on_send(node)
48
+ return unless stub_perform_async?(node)
49
+
50
+ negative_expectation = false
51
+ calls_original = false
52
+
53
+ current = node.parent
54
+ while current&.call_type?
55
+ negative_expectation = true if current.method?(:not_to) || current.method?(:to_not)
56
+ calls_original = true if current.method?(:and_call_original) || current.method?(:and_wrap_original)
57
+
58
+ current = current.parent
59
+ end
60
+
61
+ return add_offense(node) if negative_expectation
62
+ return if calls_original # already have .and_call_original, not an offense
63
+
64
+ tail = message_expectation_chain_tail(node)
65
+ return add_offense(node, message: MSG_RECEIVE) if tail.parent&.block_type?
66
+
67
+ add_offense(node, message: MSG_RECEIVE) do |corrector|
68
+ corrector.insert_after(tail, ".and_call_original")
69
+ end
70
+ end
71
+
72
+ alias_method :on_csend, :on_send
73
+
74
+ private
75
+
76
+ def message_expectation_chain_tail(node)
77
+ tail = node
78
+ loop do
79
+ parent = tail.parent
80
+ break unless parent&.call_type?
81
+ break unless parent.receiver.equal?(tail)
82
+
83
+ tail = parent
84
+ end
85
+ tail
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -10,6 +10,8 @@ module RuboCop
10
10
  include Thor::Actions
11
11
 
12
12
  PLUGINS = %w(rubocop-gusto rubocop-rspec rubocop-performance rubocop-rake rubocop-rails).freeze
13
+ SIDEKIQ_GEM_PATTERN = /\A\s*gem\s+['"]sidekiq['"]/
14
+ SIDEKIQ_LOCKFILE_PATTERN = /\A\s+sidekiq\s+\(/
13
15
 
14
16
  class_option :rubocop_yml, type: :string, default: ".rubocop.yml"
15
17
 
@@ -34,13 +36,8 @@ module RuboCop
34
36
  config = ConfigYml.load_file(options[:rubocop_yml])
35
37
  end
36
38
 
37
- if rails?
38
- config.add_inherit_gem("rubocop-gusto", "config/default.yml", "config/rails.yml")
39
- config.add_plugin(PLUGINS)
40
- else
41
- config.add_inherit_gem("rubocop-gusto", "config/default.yml")
42
- config.add_plugin(PLUGINS - %w(rubocop-rails))
43
- end
39
+ config.add_inherit_gem("rubocop-gusto", *inherit_gem_configs)
40
+ config.add_plugin(rails? ? PLUGINS : PLUGINS - %w(rubocop-rails))
44
41
 
45
42
  config.sort!
46
43
  config.write(options[:rubocop_yml])
@@ -51,9 +48,25 @@ module RuboCop
51
48
 
52
49
  private
53
50
 
51
+ def inherit_gem_configs
52
+ configs = ["config/default.yml"]
53
+ configs << "config/rails.yml" if rails?
54
+ configs << "config/sidekiq.yml" if sidekiq?
55
+ configs
56
+ end
57
+
54
58
  def rails?
55
59
  File.exist?("config/application.rb")
56
60
  end
61
+
62
+ def sidekiq?
63
+ gem_referenced?("Gemfile", SIDEKIQ_GEM_PATTERN) ||
64
+ gem_referenced?("Gemfile.lock", SIDEKIQ_LOCKFILE_PATTERN)
65
+ end
66
+
67
+ def gem_referenced?(path, pattern)
68
+ File.exist?(path) && File.foreach(path).any? { |line| line.match?(pattern) }
69
+ end
57
70
  end
58
71
  end
59
72
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Gusto
5
- VERSION = "10.10.0"
5
+ VERSION = "11.1.0"
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: 10.10.0
4
+ version: 11.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineering
@@ -119,12 +119,16 @@ files:
119
119
  - LICENSE
120
120
  - README.md
121
121
  - config/default.yml
122
+ - config/gusto_cops.yml
122
123
  - config/rails.yml
124
+ - config/sidekiq.yml
123
125
  - exe/gusto-rubocop
124
126
  - exe/rubocop-gusto
125
127
  - lib/rubocop-gusto.rb
128
+ - lib/rubocop/cop/gusto/all.rb
126
129
  - lib/rubocop/cop/gusto/bootsnap_load_file.rb
127
130
  - lib/rubocop/cop/gusto/datadog_constant.rb
131
+ - lib/rubocop/cop/gusto/described_class_constant_reference.rb
128
132
  - lib/rubocop/cop/gusto/discouraged_gem.rb
129
133
  - lib/rubocop/cop/gusto/execute_migration.rb
130
134
  - lib/rubocop/cop/gusto/factory_classes_or_modules.rb
@@ -143,12 +147,14 @@ files:
143
147
  - lib/rubocop/cop/gusto/rspec_date_time_mock.rb
144
148
  - lib/rubocop/cop/gusto/sidekiq_params.rb
145
149
  - lib/rubocop/cop/gusto/toplevel_constants.rb
150
+ - lib/rubocop/cop/gusto/unreferenced_let.rb
146
151
  - lib/rubocop/cop/gusto/use_paint_not_colorize.rb
147
152
  - lib/rubocop/cop/gusto/vcr_recordings.rb
148
153
  - lib/rubocop/cop/internal_affairs/assignment_first.rb
149
154
  - lib/rubocop/cop/internal_affairs/require_restrict_on_send.rb
150
155
  - lib/rubocop/cop/rack/lowercase_header_keys.rb
151
156
  - lib/rubocop/cop/rspec/scattered_let.rb
157
+ - lib/rubocop/cop/sidekiq/perform_async_stub.rb
152
158
  - lib/rubocop/gusto.rb
153
159
  - lib/rubocop/gusto/cli.rb
154
160
  - lib/rubocop/gusto/config_yml.rb
@@ -169,7 +175,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
169
175
  requirements:
170
176
  - - ">="
171
177
  - !ruby/object:Gem::Version
172
- version: '3.2'
178
+ version: '3.3'
173
179
  required_rubygems_version: !ruby/object:Gem::Requirement
174
180
  requirements:
175
181
  - - ">="