rubocop-callback_checker 0.1.0 → 0.1.2

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: 27a8d5e0a0b8d6c92ccabe6a928f106fc80a3d438ed07002686f07bf5c3c07c9
4
- data.tar.gz: 1bad8a7bb9dcf33f5758655bbdfc208f9849dc75cde2b534dbaedef18e7e31ad
3
+ metadata.gz: b18d1882a23fe18befff3bb4fe0760099a3903fee3af4e960fe76ea4fdf8e099
4
+ data.tar.gz: 641a5f598be2759bb728a30e4cc409f747c932849327f4ea7b0be1f6439f5e3c
5
5
  SHA512:
6
- metadata.gz: 222120c9a2df85dc84fd57709c17b52b494f9e05b21a9ae64fc7f94d058595f6aebb3a9a821daad78707de021ff796c7039c73710760fd1c410f43e04a07e04f
7
- data.tar.gz: 29893d82a5f907f2d7cf88781864963e169f6c0e22b1bbc0f5615c0101773aa12f6a53a7e6dc5363c61a6849680090aa4edecd33e3edbe01af3130b47fc63bf6
6
+ metadata.gz: 762044ec4f25d593b2244bf72530c13f21841472a9ead75fd05a245f83755b580338edf96d10dae9497685d6a5595fe97deb5dab9f31c75f3ab173cbc910f621
7
+ data.tar.gz: bc79ee42e225d79eff6afbc277139b137f8c03231958dacb3d8921d4b59c3bcac26f3e43239b357dfc41f7e18b07aa54c468b90ed058cd5206b6514394612b96
data/.rubocop.yml CHANGED
@@ -1,15 +1,61 @@
1
- require:
2
- - rubocop-rspec
3
-
4
1
  plugins:
5
- - rubocop-rake
2
+ - rubocop-callback_checker
6
3
 
7
4
  AllCops:
8
- TargetRubyVersion: 3.1
9
5
  NewCops: enable
6
+ SuggestExtensions: false
7
+
8
+ # The entry point file must be named rubocop-callback_checker.rb to match the gem name
9
+ Naming/FileName:
10
+ Exclude:
11
+ - 'lib/rubocop-callback_checker.rb'
12
+
13
+ # Allow longer blocks in spec files
14
+ Metrics/BlockLength:
15
+ Exclude:
16
+ - 'spec/**/*_spec.rb'
17
+ - 'rubocop-callback_checker.gemspec'
18
+
19
+ # Allow some cops to have longer methods due to RuboCop's pattern matching requirements
20
+ Metrics/ClassLength:
21
+ Exclude:
22
+ - 'lib/rubocop/cop/**/*'
23
+ - 'lib/callback_checker/prism_analyzer.rb'
24
+
25
+ Metrics/MethodLength:
26
+ Exclude:
27
+ - 'lib/rubocop/cop/**/*'
28
+ - 'lib/callback_checker/cli.rb'
29
+ - 'lib/callback_checker/prism_analyzer.rb'
30
+
31
+ Metrics/AbcSize:
32
+ Exclude:
33
+ - 'lib/rubocop/cop/**/*'
34
+ - 'lib/callback_checker/prism_analyzer.rb'
35
+
36
+ Metrics/CyclomaticComplexity:
37
+ Exclude:
38
+ - 'lib/rubocop/cop/**/*'
39
+ - 'lib/callback_checker/prism_analyzer.rb'
40
+
41
+ Metrics/PerceivedComplexity:
42
+ Exclude:
43
+ - 'lib/rubocop/cop/**/*'
44
+ - 'lib/callback_checker/prism_analyzer.rb'
45
+
46
+ Style/Documentation:
47
+ Enabled: false
48
+
49
+ Lint/MissingSuper:
50
+ Exclude:
51
+ - 'lib/callback_checker/prism_analyzer.rb'
52
+
53
+ Lint/UselessMethodDefinition:
54
+ Exclude:
55
+ - 'lib/callback_checker/prism_analyzer.rb'
10
56
 
11
- Style/StringLiterals:
12
- EnforcedStyle: double_quotes
57
+ Layout/LineLength:
58
+ Max: 160
13
59
 
14
- Style/StringLiteralsInInterpolation:
15
- EnforcedStyle: double_quotes
60
+ Gemspec/DevelopmentDependencies:
61
+ Enabled: false
data/README.md CHANGED
@@ -12,14 +12,12 @@ Install the gem and add to the application's Gemfile by executing:
12
12
 
13
13
  ```bash
14
14
  bundle add rubocop-callback_checker
15
-
16
15
  ```
17
16
 
18
17
  If bundler is not being used to manage dependencies, install the gem by executing:
19
18
 
20
19
  ```bash
21
20
  gem install rubocop-callback_checker
22
-
23
21
  ```
24
22
 
25
23
  ---
@@ -29,16 +27,38 @@ gem install rubocop-callback_checker
29
27
  In your `.rubocop.yml`, add the following:
30
28
 
31
29
  ```yaml
32
- require:
30
+ # Modern method (RuboCop 1.72+)
31
+ plugins:
33
32
  - rubocop-callback_checker
34
33
 
34
+ # Alternative method (older RuboCop versions)
35
+ require:
36
+ - rubocop-callback_checker
35
37
  ```
36
38
 
37
39
  You can then run RuboCop as usual:
38
40
 
39
41
  ```bash
40
42
  bundle exec rubocop
43
+ ```
44
+
45
+ The gem will automatically load all callback checker cops with their default configurations.
46
+
47
+ ---
41
48
 
49
+ ## Configuration
50
+
51
+ All cops are enabled by default with reasonable settings. You can customize them in your `.rubocop.yml`:
52
+
53
+ ```yaml
54
+ plugins:
55
+ - rubocop-callback_checker
56
+
57
+ CallbackChecker/CallbackMethodLength:
58
+ Max: 10 # Default is 5
59
+
60
+ CallbackChecker/NoSideEffectsInCallbacks:
61
+ Enabled: false # Disable if needed
42
62
  ```
43
63
 
44
64
  ---
@@ -54,13 +74,53 @@ If a side effect (like sending an email) triggers in an `after_save` but the tra
54
74
  * **Bad:** Calling `UserMailer.welcome.deliver_now` in `after_create`.
55
75
  * **Good:** Use `after_commit` or `after_create_commit`.
56
76
 
57
- ### 2. `CallbackChecker/NoSelfPersistence`
77
+ **Example:**
78
+
79
+ ```ruby
80
+ # bad
81
+ class User < ApplicationRecord
82
+ after_create { UserMailer.welcome(self).deliver_now }
83
+ end
84
+
85
+ # good
86
+ class User < ApplicationRecord
87
+ after_create_commit { UserMailer.welcome(self).deliver_now }
88
+ end
89
+ ```
90
+
91
+ ---
92
+
93
+ ### 2. `CallbackChecker/AvoidSelfPersistence`
58
94
 
59
95
  **Goal:** Prevent infinite recursion and "Stack Level Too Deep" errors.
60
96
 
61
97
  * **Bad:** Calling `self.save` or `update(status: 'active')` inside a `before_save`.
62
98
  * **Good:** Assign attributes directly: `self.status = 'active'`.
63
99
 
100
+ **Example:**
101
+
102
+ ```ruby
103
+ # bad
104
+ class User < ApplicationRecord
105
+ before_save :activate
106
+
107
+ def activate
108
+ self.update(status: 'active') # triggers before_save again!
109
+ end
110
+ end
111
+
112
+ # good
113
+ class User < ApplicationRecord
114
+ before_save :activate
115
+
116
+ def activate
117
+ self.status = 'active' # will be saved automatically
118
+ end
119
+ end
120
+ ```
121
+
122
+ ---
123
+
64
124
  ### 3. `CallbackChecker/AttributeAssignmentOnly`
65
125
 
66
126
  **Goal:** Reduce unnecessary database I/O.
@@ -70,21 +130,94 @@ Callbacks that run "before" persistence should only modify the object's memory s
70
130
  * **Bad:** `before_validation { update(attr: 'val') }`
71
131
  * **Good:** `before_validation { self.attr = 'val' }`
72
132
 
133
+ **Example:**
134
+
135
+ ```ruby
136
+ # bad
137
+ class User < ApplicationRecord
138
+ before_save :normalize_email
139
+
140
+ def normalize_email
141
+ update(email: email.downcase) # unnecessary extra query
142
+ end
143
+ end
144
+
145
+ # good
146
+ class User < ApplicationRecord
147
+ before_save :normalize_email
148
+
149
+ def normalize_email
150
+ self.email = email.downcase # just modifies in memory
151
+ end
152
+ end
153
+ ```
154
+
155
+ ---
156
+
73
157
  ### 4. `CallbackChecker/CallbackMethodLength`
74
158
 
75
159
  **Goal:** Prevent "Fat Models" and maintain testability.
76
160
 
77
161
  Callbacks should be "post-it notes," not "instruction manuals." If a callback method is too long, it should be moved to a Service Object.
78
162
 
79
- * **Default Max:** 10 lines.
163
+ * **Default Max:** 5 lines (configurable).
164
+
165
+ **Example:**
166
+
167
+ ```ruby
168
+ # bad
169
+ class User < ApplicationRecord
170
+ after_create :setup_account
171
+
172
+ def setup_account
173
+ # 20 lines of complex logic...
174
+ create_default_settings
175
+ send_welcome_email
176
+ notify_admin
177
+ create_billing_account
178
+ # ...
179
+ end
180
+ end
181
+
182
+ # good
183
+ class User < ApplicationRecord
184
+ after_create_commit :setup_account
185
+
186
+ def setup_account
187
+ AccountSetupService.new(self).call
188
+ end
189
+ end
190
+ ```
191
+
192
+ ---
80
193
 
81
- ### 5. `CallbackChecker/SymbolicConditionals`
194
+ ### 5. `CallbackChecker/ConditionalStyle`
82
195
 
83
196
  **Goal:** Improve readability and allow for easier debugging.
84
197
 
85
198
  * **Bad:** `before_save :do_thing, if: -> { status == 'active' && !deleted? }`
86
199
  * **Good:** `before_save :do_thing, if: :active_and_present?`
87
200
 
201
+ **Example:**
202
+
203
+ ```ruby
204
+ # bad
205
+ class User < ApplicationRecord
206
+ before_save :do_thing, if: -> { status == 'active' && !deleted? }
207
+ end
208
+
209
+ # good
210
+ class User < ApplicationRecord
211
+ before_save :do_thing, if: :active_and_present?
212
+
213
+ private
214
+
215
+ def active_and_present?
216
+ status == 'active' && !deleted?
217
+ end
218
+ end
219
+ ```
220
+
88
221
  ---
89
222
 
90
223
  ## Development
@@ -95,11 +228,10 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
95
228
 
96
229
  ## Contributing
97
230
 
98
- Bug reports and pull requests are welcome on GitHub at [https://github.com/](https://github.com/)[USERNAME]/rubocop-callback_checker.
231
+ Bug reports and pull requests are welcome on GitHub at https://github.com/rahsheen/rubocop-callback_checker.
99
232
 
100
233
  ## License
101
234
 
102
235
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
103
236
 
104
237
  ---
105
-
data/Rakefile CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec)
7
7
 
8
- require "rubocop/rake_task"
8
+ require 'rubocop/rake_task'
9
9
 
10
10
  RuboCop::RakeTask.new
11
11
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RuboCop
6
+ module CallbackChecker
7
+ # A plugin that integrates RuboCop CallbackChecker with RuboCop's plugin system.
8
+ class Plugin < LintRoller::Plugin
9
+ def about
10
+ LintRoller::About.new(
11
+ name: 'rubocop-callback_checker',
12
+ version: VERSION,
13
+ homepage: 'https://github.com/rahsheen/rubocop-callback_checker',
14
+ description: 'A RuboCop extension focused on avoiding callback hell in Rails.'
15
+ )
16
+ end
17
+
18
+ def supported?(context)
19
+ context.engine == :rubocop
20
+ end
21
+
22
+ def rules(_context)
23
+ LintRoller::Rules.new(
24
+ type: :path,
25
+ config_format: :rubocop,
26
+ value: Pathname.new(__dir__).join('../../../config/default.yml')
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Rubocop
3
+ module RuboCop
4
4
  module CallbackChecker
5
- VERSION = "0.1.0"
5
+ VERSION = '0.1.2'
6
6
  end
7
7
  end
@@ -1,21 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "callback_checker/version"
4
- require "pathname"
5
- require "yaml"
3
+ require_relative 'callback_checker/version'
4
+ require_relative 'callback_checker/plugin'
5
+ require 'pathname'
6
6
 
7
- # Load all the cops
8
- Dir[Pathname.new(__dir__).join("cop", "callback_checker", "**", "*.rb")].each { |file| require file }
9
-
10
- module Rubocop
7
+ module RuboCop
11
8
  module CallbackChecker
12
9
  class Error < StandardError; end
13
10
 
14
11
  PROJECT_ROOT = Pathname.new(__dir__).parent.parent.freeze
15
- CONFIG_DEFAULT = PROJECT_ROOT.join("config", "default.yml").freeze
12
+ CONFIG_DEFAULT = PROJECT_ROOT.join('config', 'default.yml').freeze
16
13
 
17
- def self.version
18
- VERSION
19
- end
14
+ # Inject the plugin's default configuration into RuboCop
15
+ ::RuboCop::ConfigLoader.inject_defaults!(CONFIG_DEFAULT.to_s)
20
16
  end
21
17
  end
@@ -69,13 +69,13 @@ module RuboCop
69
69
 
70
70
  def on_class(node)
71
71
  @current_callbacks = {}
72
-
72
+
73
73
  node.each_descendant(:send) do |send_node|
74
74
  next unless callback_method?(send_node)
75
-
75
+
76
76
  callback_name = send_node.method_name
77
77
  callback_args = send_node.arguments
78
-
78
+
79
79
  callback_args.each do |arg|
80
80
  if arg.sym_type?
81
81
  method_name = arg.value
@@ -84,18 +84,16 @@ module RuboCop
84
84
  check_block_for_persistence(arg, callback_name)
85
85
  end
86
86
  end
87
-
88
- if send_node.block_node
89
- check_block_for_persistence(send_node.block_node, callback_name)
90
- end
87
+
88
+ check_block_for_persistence(send_node.block_node, callback_name) if send_node.block_node
91
89
  end
92
-
90
+
93
91
  node.each_descendant(:def) do |def_node|
94
92
  method_name = def_node.method_name
95
93
  callback_name = @current_callbacks[method_name]
96
-
94
+
97
95
  next unless callback_name
98
-
96
+
99
97
  check_method_for_persistence(def_node, callback_name)
100
98
  end
101
99
  end
@@ -105,7 +103,7 @@ module RuboCop
105
103
  def check_block_for_persistence(block_node, callback_name)
106
104
  block_node.each_descendant(:send) do |send_node|
107
105
  next unless persistence_call?(send_node)
108
-
106
+
109
107
  add_offense_for_node(send_node, callback_name)
110
108
  end
111
109
  end
@@ -113,7 +111,7 @@ module RuboCop
113
111
  def check_method_for_persistence(method_node, callback_name)
114
112
  method_node.each_descendant(:send) do |send_node|
115
113
  next unless persistence_call?(send_node)
116
-
114
+
117
115
  add_offense_for_node(send_node, callback_name)
118
116
  end
119
117
  end
@@ -121,14 +119,14 @@ module RuboCop
121
119
  def add_offense_for_node(node, callback_name)
122
120
  method_name = node.method_name
123
121
  attribute = extract_attribute_name(node)
124
-
122
+
125
123
  message = format(
126
124
  MSG,
127
125
  attribute: attribute,
128
126
  method: method_name,
129
127
  callback: callback_name
130
128
  )
131
-
129
+
132
130
  add_offense(node, message: message)
133
131
  end
134
132
 
@@ -138,11 +136,11 @@ module RuboCop
138
136
  key = first_hash_key(node)
139
137
  return key.to_s if key
140
138
  end
141
-
139
+
142
140
  # Try to extract from symbol argument (e.g., update_column(:name, 'foo'))
143
141
  symbol_arg = first_symbol_arg(node)
144
142
  return symbol_arg.to_s if symbol_arg
145
-
143
+
146
144
  'attribute'
147
145
  end
148
146
  end
@@ -23,9 +23,9 @@ module RuboCop
23
23
  # self.status = 'active'
24
24
  # end
25
25
  class AvoidSelfPersistence < Base
26
- MSG = "Avoid calling `%<method>s` on self within `%<callback>s`. " \
27
- "This can trigger infinite loops or run callbacks multiple times. " \
28
- "Assign attributes directly instead: `self.attribute = value`."
26
+ MSG = 'Avoid calling `%<method>s` on self within `%<callback>s`. ' \
27
+ 'This can trigger infinite loops or run callbacks multiple times. ' \
28
+ 'Assign attributes directly instead: `self.attribute = value`.'
29
29
 
30
30
  CALLBACK_METHODS = %i[
31
31
  before_validation after_validation
@@ -84,7 +84,7 @@ module RuboCop
84
84
  elsif arg.block_pass_type?
85
85
  # Handle block pass like: after_save &:method_name
86
86
  # We can't easily analyze these, so skip
87
- return
87
+ nil
88
88
  elsif arg.lambda_or_proc?
89
89
  check_proc_callback(arg, node.method_name)
90
90
  end
@@ -38,8 +38,8 @@ module RuboCop
38
38
  # # No callback, call UserRegistrationService.new(user).call from controller
39
39
  # end
40
40
  class CallbackMethodLength < Base
41
- MSG = "Callback method `%<method>s` is too long (%<length>d lines). " \
42
- "Max allowed: %<max>d lines. Extract complex logic to a service object."
41
+ MSG = 'Callback method `%<method>s` is too long (%<length>d lines). ' \
42
+ 'Max allowed: %<max>d lines. Extract complex logic to a service object.'
43
43
 
44
44
  CALLBACK_METHODS = %i[
45
45
  before_validation after_validation
@@ -54,7 +54,7 @@ module RuboCop
54
54
 
55
55
  def on_send(node)
56
56
  return unless callback_method?(node)
57
-
57
+
58
58
  # Only check symbol arguments (method name references)
59
59
  node.arguments.each do |arg|
60
60
  check_callback_argument(node, arg) if arg.sym_type?
@@ -100,11 +100,11 @@ module RuboCop
100
100
  return 0 unless method_node.body
101
101
 
102
102
  body = method_node.body
103
-
103
+
104
104
  # Calculate line count
105
105
  first_line = body.first_line
106
106
  last_line = body.last_line
107
-
107
+
108
108
  # Count non-empty lines
109
109
  (first_line..last_line).count do |line_number|
110
110
  line = processed_source.lines[line_number - 1]
@@ -26,8 +26,8 @@ module RuboCop
26
26
  # status == 'active' && !deleted?
27
27
  # end
28
28
  class ConditionalStyle < Base
29
- MSG = "Use a named method instead of a %<type>s for callback conditionals. " \
30
- "Extract the logic to a private method with a descriptive name."
29
+ MSG = 'Use a named method instead of a %<type>s for callback conditionals. ' \
30
+ 'Extract the logic to a private method with a descriptive name.'
31
31
 
32
32
  CALLBACK_METHODS = %i[
33
33
  before_validation after_validation
@@ -44,7 +44,7 @@ module RuboCop
44
44
 
45
45
  def on_send(node)
46
46
  return unless callback_method?(node)
47
-
47
+
48
48
  check_callback_conditionals(node)
49
49
  end
50
50
 
@@ -79,7 +79,7 @@ module RuboCop
79
79
 
80
80
  def conditional_key?(key)
81
81
  return false unless key.sym_type?
82
-
82
+
83
83
  CONDITIONAL_KEYS.include?(key.value)
84
84
  end
85
85
  end
@@ -14,8 +14,8 @@ module RuboCop
14
14
  # # good
15
15
  # after_commit :notify_external_api, on: :create
16
16
  class NoSideEffectsInCallbacks < Base
17
- MSG = "Avoid side effects (API calls, mailers, background jobs, or modifying other records) " \
18
- "in %<callback>s. Use `after_commit` instead."
17
+ MSG = 'Avoid side effects (API calls, mailers, background jobs, or modifying other records) ' \
18
+ 'in %<callback>s. Use `after_commit` instead.'
19
19
 
20
20
  SIDE_EFFECT_SENSITIVE_CALLBACKS = %i[
21
21
  before_validation before_save after_save
@@ -123,12 +123,12 @@ module RuboCop
123
123
  def part_of_reported_chain?(send_node)
124
124
  parent = send_node.parent
125
125
  return false unless parent&.send_type?
126
-
126
+
127
127
  # If parent is also a side effect, we'll report the parent instead
128
128
  # This handles cases like UserMailer.welcome(self).deliver_now
129
129
  # We want to report the .deliver_now, not the .welcome
130
130
  return false if async_delivery?(send_node) # Always report delivery methods
131
-
131
+
132
132
  # If this is a receiver of a delivery method, don't report it
133
133
  parent.receiver == send_node && async_delivery?(parent)
134
134
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'callback_checker/no_side_effects_in_callbacks'
4
+ require_relative 'callback_checker/avoid_self_persistence'
5
+ require_relative 'callback_checker/attribute_assignment_only'
6
+ require_relative 'callback_checker/callback_method_length'
7
+ require_relative 'callback_checker/conditional_style'
@@ -1,5 +1,7 @@
1
- require_relative "callback_checker/no_side_effects_in_callbacks"
2
- require_relative "callback_checker/avoid_self_persistence"
3
- require_relative "callback_checker/attribute_assignment_only"
4
- require_relative "callback_checker/callback_method_length"
5
- require_relative "callback_checker/conditional_style"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'callback_checker/no_side_effects_in_callbacks'
4
+ require_relative 'callback_checker/avoid_self_persistence'
5
+ require_relative 'callback_checker/attribute_assignment_only'
6
+ require_relative 'callback_checker/callback_method_length'
7
+ require_relative 'callback_checker/conditional_style'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop'
4
+
5
+ require_relative 'rubocop/callback_checker'
6
+ require_relative 'rubocop/cop/callback_checker_cops'
metadata CHANGED
@@ -1,29 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-callback_checker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - rahsheen
8
8
  autorequire:
9
- bindir: exe
9
+ bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-13 00:00:00.000000000 Z
11
+ date: 2026-03-15 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: lint_roller
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: rubocop
15
29
  requirement: !ruby/object:Gem::Requirement
16
30
  requirements:
17
- - - "~>"
31
+ - - ">="
18
32
  - !ruby/object:Gem::Version
19
- version: '1.0'
33
+ version: 1.72.0
20
34
  type: :runtime
21
35
  prerelease: false
22
36
  version_requirements: !ruby/object:Gem::Requirement
23
37
  requirements:
24
- - - "~>"
38
+ - - ">="
25
39
  - !ruby/object:Gem::Version
26
- version: '1.0'
40
+ version: 1.72.0
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: rubocop-ast
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -81,38 +95,37 @@ dependencies:
81
95
  - !ruby/object:Gem::Version
82
96
  version: '2.0'
83
97
  - !ruby/object:Gem::Dependency
84
- name: rubocop-rspec
98
+ name: rubocop-rake
85
99
  requirement: !ruby/object:Gem::Requirement
86
100
  requirements:
87
101
  - - "~>"
88
102
  - !ruby/object:Gem::Version
89
- version: '2.0'
103
+ version: '0.7'
90
104
  type: :development
91
105
  prerelease: false
92
106
  version_requirements: !ruby/object:Gem::Requirement
93
107
  requirements:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
- version: '2.0'
110
+ version: '0.7'
97
111
  - !ruby/object:Gem::Dependency
98
- name: rubocop-rake
112
+ name: rubocop-rspec
99
113
  requirement: !ruby/object:Gem::Requirement
100
114
  requirements:
101
115
  - - "~>"
102
116
  - !ruby/object:Gem::Version
103
- version: '0.7'
117
+ version: '2.0'
104
118
  type: :development
105
119
  prerelease: false
106
120
  version_requirements: !ruby/object:Gem::Requirement
107
121
  requirements:
108
122
  - - "~>"
109
123
  - !ruby/object:Gem::Version
110
- version: '0.7'
124
+ version: '2.0'
111
125
  description: A RuboCop extension focused on avoiding callback hell in Rails.
112
126
  email:
113
127
  - rahsheen.porter@gmail.com
114
- executables:
115
- - rubocop-callback-checker
128
+ executables: []
116
129
  extensions: []
117
130
  extra_rdoc_files: []
118
131
  files:
@@ -124,16 +137,16 @@ files:
124
137
  - README.md
125
138
  - Rakefile
126
139
  - config/default.yml
127
- - exe/rubocop-callback-checker
128
- - lib/callback_checker/cli.rb
129
- - lib/callback_checker/prism_analyzer.rb
140
+ - lib/rubocop-callback_checker.rb
130
141
  - lib/rubocop/callback_checker.rb
142
+ - lib/rubocop/callback_checker/plugin.rb
131
143
  - lib/rubocop/callback_checker/version.rb
132
144
  - lib/rubocop/cop/callback_checker/attribute_assignment_only.rb
133
145
  - lib/rubocop/cop/callback_checker/avoid_self_persistence.rb
134
146
  - lib/rubocop/cop/callback_checker/callback_method_length.rb
135
147
  - lib/rubocop/cop/callback_checker/conditional_style.rb
136
148
  - lib/rubocop/cop/callback_checker/no_side_effects_in_callbacks.rb
149
+ - lib/rubocop/cop/callback_checker_cops.rb
137
150
  - lib/rubocop/cop/cops.rb
138
151
  - sig/rubocop/callback_checker.rbs
139
152
  homepage: https://github.com/rahsheen/rubocop-callback_checker
@@ -144,6 +157,8 @@ metadata:
144
157
  homepage_uri: https://github.com/rahsheen/rubocop-callback_checker
145
158
  source_code_uri: https://github.com/rahsheen/rubocop-callback_checker
146
159
  changelog_uri: https://github.com/rahsheen/rubocop-callback_checker/blob/main/CHANGELOG.md
160
+ default_lint_roller_plugin: RuboCop::CallbackChecker::Plugin
161
+ rubygems_mfa_required: 'true'
147
162
  post_install_message:
148
163
  rdoc_options: []
149
164
  require_paths:
@@ -162,5 +177,5 @@ requirements: []
162
177
  rubygems_version: 3.5.9
163
178
  signing_key:
164
179
  specification_version: 4
165
- summary: rubocop
180
+ summary: RuboCop extension for checking ActiveRecord callbacks
166
181
  test_files: []
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- require_relative '../lib/callback_checker/cli'
5
-
6
- exit CallbackChecker::CLI.run(ARGV)
@@ -1,104 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'prism_analyzer'
4
- require_relative '../rubocop/callback_checker/version'
5
- require 'optparse'
6
-
7
- module CallbackChecker
8
- VERSION = Rubocop::CallbackChecker::VERSION
9
-
10
- class CLI
11
- def self.run(argv)
12
- new(argv).run
13
- end
14
-
15
- def initialize(argv)
16
- @argv = argv
17
- @paths = []
18
- @options = {}
19
- end
20
-
21
- def run
22
- parse_options
23
-
24
- if @paths.empty?
25
- puts "Usage: rubocop-callback-checker [options] FILE..."
26
- puts "Try 'rubocop-callback-checker --help' for more information."
27
- return 1
28
- end
29
-
30
- files = collect_files(@paths)
31
-
32
- if files.empty?
33
- puts "No Ruby files found to analyze."
34
- return 1
35
- end
36
-
37
- total_offenses = 0
38
-
39
- files.each do |file|
40
- offenses = PrismAnalyzer.analyze_file(file)
41
-
42
- if offenses.any?
43
- total_offenses += offenses.size
44
- print_offenses(file, offenses)
45
- end
46
- end
47
-
48
- print_summary(files.size, total_offenses)
49
-
50
- total_offenses > 0 ? 1 : 0
51
- end
52
-
53
- private
54
-
55
- def parse_options
56
- OptionParser.new do |opts|
57
- opts.banner = "Usage: rubocop-callback-checker [options] FILE..."
58
-
59
- opts.on("-h", "--help", "Print this help") do
60
- puts opts
61
- exit 0
62
- end
63
-
64
- opts.on("-v", "--version", "Print version") do
65
- puts "rubocop-callback-checker version #{VERSION}"
66
- exit 0
67
- end
68
- end.parse!(@argv)
69
-
70
- @paths = @argv
71
- end
72
-
73
- def collect_files(paths)
74
- files = []
75
-
76
- paths.each do |path|
77
- if File.file?(path)
78
- files << path if path.end_with?('.rb')
79
- elsif File.directory?(path)
80
- files.concat(Dir.glob(File.join(path, '**', '*.rb')))
81
- else
82
- warn "Warning: #{path} is not a file or directory"
83
- end
84
- end
85
-
86
- files
87
- end
88
-
89
- def print_offenses(file, offenses)
90
- puts "\n#{file}"
91
-
92
- offenses.each do |offense|
93
- location = offense[:location]
94
- puts " #{location[:start_line]}:#{location[:start_column]}: #{offense[:message]}"
95
- puts " #{offense[:code]}"
96
- end
97
- end
98
-
99
- def print_summary(file_count, offense_count)
100
- puts "\n" + "=" * 80
101
- puts "#{file_count} file(s) inspected, #{offense_count} offense(s) detected"
102
- end
103
- end
104
- end
@@ -1,252 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'prism'
4
-
5
- module CallbackChecker
6
- class PrismAnalyzer < Prism::Visitor
7
- attr_reader :offenses
8
-
9
- SIDE_EFFECT_SENSITIVE_CALLBACKS = %i[
10
- before_validation before_save after_save
11
- before_create after_create
12
- before_update after_update
13
- before_destroy after_destroy
14
- around_save around_create around_update around_destroy
15
- ].freeze
16
-
17
- SAFE_CALLBACKS = %i[
18
- after_commit after_create_commit after_update_commit
19
- after_destroy_commit after_save_commit after_rollback
20
- ].freeze
21
-
22
- SUSPICIOUS_CONSTANTS = %w[
23
- RestClient Faraday HTTParty Net Sidekiq ActionCable
24
- ].freeze
25
-
26
- SIDE_EFFECT_METHODS = %i[
27
- deliver_later deliver_now perform_later broadcast_later
28
- save save! update update! destroy destroy! create create!
29
- delete delete_all destroy_all update_all update_columns touch
30
- ].freeze
31
-
32
- def initialize(source)
33
- @source = source
34
- @offenses = []
35
- @current_class = nil
36
- @callback_methods = {}
37
- @current_callback_type = nil
38
- end
39
-
40
- def visit_class_node(node)
41
- previous_class = @current_class
42
- previous_methods = @callback_methods.dup
43
-
44
- @current_class = node
45
- @callback_methods = {}
46
-
47
- # First pass: collect all method definitions
48
- collect_methods(node)
49
-
50
- # Second pass: check callbacks
51
- super
52
-
53
- @current_class = previous_class
54
- @callback_methods = previous_methods
55
- end
56
-
57
- def visit_call_node(node)
58
- if callback_declaration?(node)
59
- check_callback(node)
60
- elsif @current_callback_type && side_effect_call?(node)
61
- add_offense(node, @current_callback_type)
62
- end
63
-
64
- super
65
- end
66
-
67
- def visit_if_node(node)
68
- # Make sure we visit all branches of conditionals
69
- super
70
- end
71
-
72
- def visit_unless_node(node)
73
- # Make sure we visit all branches of unless statements
74
- super
75
- end
76
-
77
- private
78
-
79
- def collect_methods(class_node)
80
- class_node.body&.body&.each do |statement|
81
- if statement.is_a?(Prism::DefNode)
82
- @callback_methods[statement.name] = statement
83
- end
84
- end
85
- end
86
-
87
- def callback_declaration?(node)
88
- return false unless node.receiver.nil?
89
- return false unless node.name
90
-
91
- callback_name = node.name
92
- SIDE_EFFECT_SENSITIVE_CALLBACKS.include?(callback_name) ||
93
- SAFE_CALLBACKS.include?(callback_name)
94
- end
95
-
96
- def check_callback(node)
97
- callback_name = node.name
98
-
99
- # Skip safe callbacks
100
- return if SAFE_CALLBACKS.include?(callback_name)
101
-
102
- if node.block
103
- # Block form: before_save do ... end
104
- check_block_callback(node, callback_name)
105
- elsif node.arguments
106
- # Symbol form: before_save :method_name
107
- check_symbol_callback(node, callback_name)
108
- end
109
- end
110
-
111
- def check_block_callback(node, callback_name)
112
- previous_callback = @current_callback_type
113
- @current_callback_type = callback_name
114
-
115
- visit(node.block)
116
-
117
- @current_callback_type = previous_callback
118
- end
119
-
120
- def check_symbol_callback(node, callback_name)
121
- return unless node.arguments&.arguments
122
-
123
- node.arguments.arguments.each do |arg|
124
- next unless arg.is_a?(Prism::SymbolNode)
125
-
126
- method_name = arg.value
127
- method_def = @callback_methods[method_name.to_sym]
128
-
129
- next unless method_def
130
-
131
- check_method_for_side_effects(method_def, callback_name)
132
- end
133
- end
134
-
135
- def check_method_for_side_effects(method_node, callback_name)
136
- previous_callback = @current_callback_type
137
- @current_callback_type = callback_name
138
-
139
- visit(method_node.body) if method_node.body
140
-
141
- @current_callback_type = previous_callback
142
- end
143
-
144
- def side_effect_call?(node)
145
- return false unless node.is_a?(Prism::CallNode)
146
-
147
- # Check for suspicious constant calls (RestClient.get, etc.)
148
- if node.receiver.is_a?(Prism::ConstantReadNode)
149
- constant_name = node.receiver.name.to_s
150
- return true if SUSPICIOUS_CONSTANTS.include?(constant_name)
151
-
152
- # Check for any constant that isn't a known safe pattern
153
- # This catches things like NewsletterSDK, CustomAPI, etc.
154
- return true if constant_appears_to_be_external_service?(constant_name)
155
- end
156
-
157
- # Check for side effect methods
158
- method_name = node.name
159
- return true if SIDE_EFFECT_METHODS.include?(method_name)
160
-
161
- # Check for mailer patterns (anything ending with Mailer)
162
- if node.receiver.is_a?(Prism::ConstantReadNode)
163
- constant_name = node.receiver.name.to_s
164
- return true if constant_name.end_with?('Mailer')
165
- end
166
-
167
- # Check for method chains that end with deliver_now
168
- if method_name == :deliver_now && node.receiver.is_a?(Prism::CallNode)
169
- return true
170
- end
171
-
172
- # Check for calls on associations or other objects (not self)
173
- if node.receiver && !self_reference?(node.receiver)
174
- return true if persistence_method?(method_name)
175
- end
176
-
177
- # Check for save/update on self or implicit self
178
- if node.receiver.nil? || self_reference?(node.receiver)
179
- return true if %i[save save! update update!].include?(method_name)
180
- end
181
-
182
- false
183
- end
184
-
185
- def constant_appears_to_be_external_service?(constant_name)
186
- # Heuristic: if it's all caps or ends with SDK, API, Client, Service
187
- # it's probably an external service
188
- return true if constant_name.end_with?('SDK', 'API', 'Client', 'Service')
189
- return true if constant_name == constant_name.upcase && constant_name.length > 1
190
-
191
- false
192
- end
193
-
194
- def self_reference?(node)
195
- node.is_a?(Prism::SelfNode)
196
- end
197
-
198
- def persistence_method?(method_name)
199
- %i[
200
- save save! update update! destroy destroy! create create!
201
- delete delete_all destroy_all update_all update_columns touch
202
- ].include?(method_name)
203
- end
204
-
205
- def add_offense(node, callback_type)
206
- location = node.location
207
- start_line = location.start_line
208
- start_column = location.start_column
209
- end_line = location.end_line
210
- end_column = location.end_column
211
-
212
- # Extract the source code for this node
213
- source_range = location.start_offset...location.end_offset
214
- code = @source[source_range]
215
-
216
- @offenses << {
217
- message: "Avoid side effects (API calls, mailers, background jobs, or modifying other records) in #{callback_type}. Use `after_commit` instead.",
218
- location: {
219
- start_line: start_line,
220
- start_column: start_column,
221
- end_line: end_line,
222
- end_column: end_column
223
- },
224
- code: code,
225
- callback_type: callback_type
226
- }
227
- end
228
-
229
- class << self
230
- def analyze_file(path)
231
- source = File.read(path)
232
- analyze_source(source, path)
233
- end
234
-
235
- def analyze_source(source, path = nil)
236
- result = Prism.parse(source)
237
-
238
- if result.errors.any?
239
- warn "Parse errors in #{path}:" if path
240
- result.errors.each do |error|
241
- warn " #{error.message}"
242
- end
243
- return []
244
- end
245
-
246
- analyzer = new(source)
247
- analyzer.visit(result.value)
248
- analyzer.offenses
249
- end
250
- end
251
- end
252
- end