rubocop-callback_checker 0.1.1 → 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: 0e884e5e21fa07e119f9f4b418e405be6611d2d5c0b696da5bdded094dd31455
4
- data.tar.gz: a86502267af368a6d0922a46984190fb68b9f1e7a6523516b9695ce63c23f5b8
3
+ metadata.gz: b18d1882a23fe18befff3bb4fe0760099a3903fee3af4e960fe76ea4fdf8e099
4
+ data.tar.gz: 641a5f598be2759bb728a30e4cc409f747c932849327f4ea7b0be1f6439f5e3c
5
5
  SHA512:
6
- metadata.gz: 6b893a0fe683cee9c9d8d8e636a10198b6fcd16c23358598c6983cb800cc7d68424a40dc334e9947853db463f8ceca043c6786b42b46a172ef1379802055a7de
7
- data.tar.gz: 33aea6ae0240e55f7dfb902079679d704059ae2e00fa04f2a7b0968e1a73fa3a8ea3e51ef0bfa5d7d550d38bac804113aec35cc0254fac8a7737291a0ee73a13
6
+ metadata.gz: 762044ec4f25d593b2244bf72530c13f21841472a9ead75fd05a245f83755b580338edf96d10dae9497685d6a5595fe97deb5dab9f31c75f3ab173cbc910f621
7
+ data.tar.gz: bc79ee42e225d79eff6afbc277139b137f8c03231958dacb3d8921d4b59c3bcac26f3e43239b357dfc41f7e18b07aa54c468b90ed058cd5206b6514394612b96
data/.rubocop.yml CHANGED
@@ -1,2 +1,61 @@
1
- require:
1
+ plugins:
2
2
  - rubocop-callback_checker
3
+
4
+ AllCops:
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'
56
+
57
+ Layout/LineLength:
58
+ Max: 160
59
+
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
-
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module CallbackChecker
5
- VERSION = '0.1.1'
5
+ VERSION = '0.1.2'
6
6
  end
7
7
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'callback_checker/version'
4
+ require_relative 'callback_checker/plugin'
4
5
  require 'pathname'
5
6
 
6
7
  module RuboCop
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require_relative 'callback_checker/no_side_effects_in_callbacks'
2
4
  require_relative 'callback_checker/avoid_self_persistence'
3
5
  require_relative 'callback_checker/attribute_assignment_only'
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.1
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-14 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
@@ -111,8 +125,7 @@ dependencies:
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,11 +137,9 @@ 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
130
140
  - lib/rubocop-callback_checker.rb
131
141
  - lib/rubocop/callback_checker.rb
142
+ - lib/rubocop/callback_checker/plugin.rb
132
143
  - lib/rubocop/callback_checker/version.rb
133
144
  - lib/rubocop/cop/callback_checker/attribute_assignment_only.rb
134
145
  - lib/rubocop/cop/callback_checker/avoid_self_persistence.rb
@@ -146,6 +157,8 @@ metadata:
146
157
  homepage_uri: https://github.com/rahsheen/rubocop-callback_checker
147
158
  source_code_uri: https://github.com/rahsheen/rubocop-callback_checker
148
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'
149
162
  post_install_message:
150
163
  rdoc_options: []
151
164
  require_paths:
@@ -164,5 +177,5 @@ requirements: []
164
177
  rubygems_version: 3.5.9
165
178
  signing_key:
166
179
  specification_version: 4
167
- summary: rubocop
180
+ summary: RuboCop extension for checking ActiveRecord callbacks
168
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.positive? ? 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,246 +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
- @callback_methods[statement.name] = statement if statement.is_a?(Prism::DefNode)
82
- end
83
- end
84
-
85
- def callback_declaration?(node)
86
- return false unless node.receiver.nil?
87
- return false unless node.name
88
-
89
- callback_name = node.name
90
- SIDE_EFFECT_SENSITIVE_CALLBACKS.include?(callback_name) ||
91
- SAFE_CALLBACKS.include?(callback_name)
92
- end
93
-
94
- def check_callback(node)
95
- callback_name = node.name
96
-
97
- # Skip safe callbacks
98
- return if SAFE_CALLBACKS.include?(callback_name)
99
-
100
- if node.block
101
- # Block form: before_save do ... end
102
- check_block_callback(node, callback_name)
103
- elsif node.arguments
104
- # Symbol form: before_save :method_name
105
- check_symbol_callback(node, callback_name)
106
- end
107
- end
108
-
109
- def check_block_callback(node, callback_name)
110
- previous_callback = @current_callback_type
111
- @current_callback_type = callback_name
112
-
113
- visit(node.block)
114
-
115
- @current_callback_type = previous_callback
116
- end
117
-
118
- def check_symbol_callback(node, callback_name)
119
- return unless node.arguments&.arguments
120
-
121
- node.arguments.arguments.each do |arg|
122
- next unless arg.is_a?(Prism::SymbolNode)
123
-
124
- method_name = arg.value
125
- method_def = @callback_methods[method_name.to_sym]
126
-
127
- next unless method_def
128
-
129
- check_method_for_side_effects(method_def, callback_name)
130
- end
131
- end
132
-
133
- def check_method_for_side_effects(method_node, callback_name)
134
- previous_callback = @current_callback_type
135
- @current_callback_type = callback_name
136
-
137
- visit(method_node.body) if method_node.body
138
-
139
- @current_callback_type = previous_callback
140
- end
141
-
142
- def side_effect_call?(node)
143
- return false unless node.is_a?(Prism::CallNode)
144
-
145
- # Check for suspicious constant calls (RestClient.get, etc.)
146
- if node.receiver.is_a?(Prism::ConstantReadNode)
147
- constant_name = node.receiver.name.to_s
148
- return true if SUSPICIOUS_CONSTANTS.include?(constant_name)
149
-
150
- # Check for any constant that isn't a known safe pattern
151
- # This catches things like NewsletterSDK, CustomAPI, etc.
152
- return true if constant_appears_to_be_external_service?(constant_name)
153
- end
154
-
155
- # Check for side effect methods
156
- method_name = node.name
157
- return true if SIDE_EFFECT_METHODS.include?(method_name)
158
-
159
- # Check for mailer patterns (anything ending with Mailer)
160
- if node.receiver.is_a?(Prism::ConstantReadNode)
161
- constant_name = node.receiver.name.to_s
162
- return true if constant_name.end_with?('Mailer')
163
- end
164
-
165
- # Check for method chains that end with deliver_now
166
- return true if method_name == :deliver_now && node.receiver.is_a?(Prism::CallNode)
167
-
168
- # Check for calls on associations or other objects (not self)
169
- return true if node.receiver && !self_reference?(node.receiver) && persistence_method?(method_name)
170
-
171
- # Check for save/update on self or implicit self
172
- if (node.receiver.nil? || self_reference?(node.receiver)) && %i[save save! update update!].include?(method_name)
173
- return true
174
- end
175
-
176
- false
177
- end
178
-
179
- def constant_appears_to_be_external_service?(constant_name)
180
- # Heuristic: if it's all caps or ends with SDK, API, Client, Service
181
- # it's probably an external service
182
- return true if constant_name.end_with?('SDK', 'API', 'Client', 'Service')
183
- return true if constant_name == constant_name.upcase && constant_name.length > 1
184
-
185
- false
186
- end
187
-
188
- def self_reference?(node)
189
- node.is_a?(Prism::SelfNode)
190
- end
191
-
192
- def persistence_method?(method_name)
193
- %i[
194
- save save! update update! destroy destroy! create create!
195
- delete delete_all destroy_all update_all update_columns touch
196
- ].include?(method_name)
197
- end
198
-
199
- def add_offense(node, callback_type)
200
- location = node.location
201
- start_line = location.start_line
202
- start_column = location.start_column
203
- end_line = location.end_line
204
- end_column = location.end_column
205
-
206
- # Extract the source code for this node
207
- source_range = location.start_offset...location.end_offset
208
- code = @source[source_range]
209
-
210
- @offenses << {
211
- message: "Avoid side effects (API calls, mailers, background jobs, or modifying other records) in #{callback_type}. Use `after_commit` instead.",
212
- location: {
213
- start_line: start_line,
214
- start_column: start_column,
215
- end_line: end_line,
216
- end_column: end_column
217
- },
218
- code: code,
219
- callback_type: callback_type
220
- }
221
- end
222
-
223
- class << self
224
- def analyze_file(path)
225
- source = File.read(path)
226
- analyze_source(source, path)
227
- end
228
-
229
- def analyze_source(source, path = nil)
230
- result = Prism.parse(source)
231
-
232
- if result.errors.any?
233
- warn "Parse errors in #{path}:" if path
234
- result.errors.each do |error|
235
- warn " #{error.message}"
236
- end
237
- return []
238
- end
239
-
240
- analyzer = new(source)
241
- analyzer.visit(result.value)
242
- analyzer.offenses
243
- end
244
- end
245
- end
246
- end