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 +4 -4
- data/.rubocop.yml +60 -1
- data/README.md +140 -8
- data/lib/rubocop/callback_checker/plugin.rb +31 -0
- data/lib/rubocop/callback_checker/version.rb +1 -1
- data/lib/rubocop/callback_checker.rb +1 -0
- data/lib/rubocop/cop/cops.rb +2 -0
- metadata +26 -13
- data/exe/rubocop-callback-checker +0 -6
- data/lib/callback_checker/cli.rb +0 -104
- data/lib/callback_checker/prism_analyzer.rb +0 -246
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,2 +1,61 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
@@ -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
|
data/lib/rubocop/cop/cops.rb
CHANGED
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
|
|
@@ -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:
|
|
180
|
+
summary: RuboCop extension for checking ActiveRecord callbacks
|
|
168
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.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
|