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 +4 -4
- data/.rubocop.yml +55 -9
- data/README.md +140 -8
- data/Rakefile +3 -3
- data/lib/rubocop/callback_checker/plugin.rb +31 -0
- data/lib/rubocop/callback_checker/version.rb +2 -2
- data/lib/rubocop/callback_checker.rb +7 -11
- data/lib/rubocop/cop/callback_checker/attribute_assignment_only.rb +14 -16
- data/lib/rubocop/cop/callback_checker/avoid_self_persistence.rb +4 -4
- data/lib/rubocop/cop/callback_checker/callback_method_length.rb +5 -5
- data/lib/rubocop/cop/callback_checker/conditional_style.rb +4 -4
- data/lib/rubocop/cop/callback_checker/no_side_effects_in_callbacks.rb +4 -4
- data/lib/rubocop/cop/callback_checker_cops.rb +7 -0
- data/lib/rubocop/cop/cops.rb +7 -5
- data/lib/rubocop-callback_checker.rb +6 -0
- metadata +34 -19
- data/exe/rubocop-callback-checker +0 -6
- data/lib/callback_checker/cli.rb +0 -104
- data/lib/callback_checker/prism_analyzer.rb +0 -252
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b18d1882a23fe18befff3bb4fe0760099a3903fee3af4e960fe76ea4fdf8e099
|
|
4
|
+
data.tar.gz: 641a5f598be2759bb728a30e4cc409f747c932849327f4ea7b0be1f6439f5e3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
-
|
|
12
|
-
|
|
57
|
+
Layout/LineLength:
|
|
58
|
+
Max: 160
|
|
13
59
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:**
|
|
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/
|
|
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
|
|
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
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
require
|
|
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,21 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
4
|
-
|
|
5
|
-
require
|
|
3
|
+
require_relative 'callback_checker/version'
|
|
4
|
+
require_relative 'callback_checker/plugin'
|
|
5
|
+
require 'pathname'
|
|
6
6
|
|
|
7
|
-
|
|
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(
|
|
12
|
+
CONFIG_DEFAULT = PROJECT_ROOT.join('config', 'default.yml').freeze
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
|
|
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 =
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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 =
|
|
42
|
-
|
|
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 =
|
|
30
|
-
|
|
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 =
|
|
18
|
-
|
|
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'
|
data/lib/rubocop/cop/cops.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
require_relative
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
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'
|
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.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rahsheen
|
|
8
8
|
autorequire:
|
|
9
|
-
bindir:
|
|
9
|
+
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-03-
|
|
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:
|
|
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:
|
|
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-
|
|
98
|
+
name: rubocop-rake
|
|
85
99
|
requirement: !ruby/object:Gem::Requirement
|
|
86
100
|
requirements:
|
|
87
101
|
- - "~>"
|
|
88
102
|
- !ruby/object:Gem::Version
|
|
89
|
-
version: '
|
|
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: '
|
|
110
|
+
version: '0.7'
|
|
97
111
|
- !ruby/object:Gem::Dependency
|
|
98
|
-
name: rubocop-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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:
|
|
180
|
+
summary: RuboCop extension for checking ActiveRecord callbacks
|
|
166
181
|
test_files: []
|
data/lib/callback_checker/cli.rb
DELETED
|
@@ -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
|