rubocop-callback_checker 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +15 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/Rakefile +12 -0
- data/config/default.yml +30 -0
- data/exe/rubocop-callback-checker +6 -0
- data/lib/callback_checker/cli.rb +104 -0
- data/lib/callback_checker/prism_analyzer.rb +252 -0
- data/lib/rubocop/callback_checker/version.rb +7 -0
- data/lib/rubocop/callback_checker.rb +21 -0
- data/lib/rubocop/cop/callback_checker/attribute_assignment_only.rb +151 -0
- data/lib/rubocop/cop/callback_checker/avoid_self_persistence.rb +136 -0
- data/lib/rubocop/cop/callback_checker/callback_method_length.rb +117 -0
- data/lib/rubocop/cop/callback_checker/conditional_style.rb +88 -0
- data/lib/rubocop/cop/callback_checker/no_side_effects_in_callbacks.rb +169 -0
- data/lib/rubocop/cop/cops.rb +5 -0
- data/sig/rubocop/callback_checker.rbs +6 -0
- metadata +166 -0
|
@@ -0,0 +1,252 @@
|
|
|
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
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "callback_checker/version"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
# Load all the cops
|
|
8
|
+
Dir[Pathname.new(__dir__).join("cop", "callback_checker", "**", "*.rb")].each { |file| require file }
|
|
9
|
+
|
|
10
|
+
module Rubocop
|
|
11
|
+
module CallbackChecker
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
PROJECT_ROOT = Pathname.new(__dir__).parent.parent.freeze
|
|
15
|
+
CONFIG_DEFAULT = PROJECT_ROOT.join("config", "default.yml").freeze
|
|
16
|
+
|
|
17
|
+
def self.version
|
|
18
|
+
VERSION
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module CallbackChecker
|
|
6
|
+
# This cop checks for persistence method calls on self within before_* callbacks
|
|
7
|
+
# (before_save, before_validation, before_create, before_update).
|
|
8
|
+
# It suggests using attribute assignment instead, since the object will be
|
|
9
|
+
# automatically persisted after the callback completes.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # bad
|
|
13
|
+
# class User < ApplicationRecord
|
|
14
|
+
# before_save :normalize_data
|
|
15
|
+
#
|
|
16
|
+
# def normalize_data
|
|
17
|
+
# update(name: name.strip)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# # good
|
|
22
|
+
# class User < ApplicationRecord
|
|
23
|
+
# before_save :normalize_data
|
|
24
|
+
#
|
|
25
|
+
# def normalize_data
|
|
26
|
+
# self.name = name.strip
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
class AttributeAssignmentOnly < Base
|
|
30
|
+
MSG = 'Use attribute assignment (`self.%<attribute>s = value`) instead of `%<method>s` in `%<callback>s`. ' \
|
|
31
|
+
'The object will be persisted automatically after the callback completes.'
|
|
32
|
+
|
|
33
|
+
PERSISTENCE_METHODS = %i[
|
|
34
|
+
update
|
|
35
|
+
update!
|
|
36
|
+
update_columns
|
|
37
|
+
update_column
|
|
38
|
+
update_attribute
|
|
39
|
+
update_attributes
|
|
40
|
+
update_attributes!
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
BEFORE_CALLBACKS = %i[
|
|
44
|
+
before_save
|
|
45
|
+
before_validation
|
|
46
|
+
before_create
|
|
47
|
+
before_update
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
def_node_matcher :callback_method?, <<~PATTERN
|
|
51
|
+
(send nil? {#{BEFORE_CALLBACKS.map(&:inspect).join(' ')}} ...)
|
|
52
|
+
PATTERN
|
|
53
|
+
|
|
54
|
+
def_node_matcher :persistence_call?, <<~PATTERN
|
|
55
|
+
(send {nil? (self)} {#{PERSISTENCE_METHODS.map(&:inspect).join(' ')}} ...)
|
|
56
|
+
PATTERN
|
|
57
|
+
|
|
58
|
+
def_node_matcher :hash_argument?, <<~PATTERN
|
|
59
|
+
(send {nil? (self)} _ (hash ...))
|
|
60
|
+
PATTERN
|
|
61
|
+
|
|
62
|
+
def_node_matcher :first_hash_key, <<~PATTERN
|
|
63
|
+
(send {nil? (self)} _ (hash (pair (sym $_) _) ...))
|
|
64
|
+
PATTERN
|
|
65
|
+
|
|
66
|
+
def_node_matcher :first_symbol_arg, <<~PATTERN
|
|
67
|
+
(send {nil? (self)} _ (sym $_) ...)
|
|
68
|
+
PATTERN
|
|
69
|
+
|
|
70
|
+
def on_class(node)
|
|
71
|
+
@current_callbacks = {}
|
|
72
|
+
|
|
73
|
+
node.each_descendant(:send) do |send_node|
|
|
74
|
+
next unless callback_method?(send_node)
|
|
75
|
+
|
|
76
|
+
callback_name = send_node.method_name
|
|
77
|
+
callback_args = send_node.arguments
|
|
78
|
+
|
|
79
|
+
callback_args.each do |arg|
|
|
80
|
+
if arg.sym_type?
|
|
81
|
+
method_name = arg.value
|
|
82
|
+
@current_callbacks[method_name] = callback_name
|
|
83
|
+
elsif arg.block_type? || arg.numblock_type?
|
|
84
|
+
check_block_for_persistence(arg, callback_name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
if send_node.block_node
|
|
89
|
+
check_block_for_persistence(send_node.block_node, callback_name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
node.each_descendant(:def) do |def_node|
|
|
94
|
+
method_name = def_node.method_name
|
|
95
|
+
callback_name = @current_callbacks[method_name]
|
|
96
|
+
|
|
97
|
+
next unless callback_name
|
|
98
|
+
|
|
99
|
+
check_method_for_persistence(def_node, callback_name)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def check_block_for_persistence(block_node, callback_name)
|
|
106
|
+
block_node.each_descendant(:send) do |send_node|
|
|
107
|
+
next unless persistence_call?(send_node)
|
|
108
|
+
|
|
109
|
+
add_offense_for_node(send_node, callback_name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_method_for_persistence(method_node, callback_name)
|
|
114
|
+
method_node.each_descendant(:send) do |send_node|
|
|
115
|
+
next unless persistence_call?(send_node)
|
|
116
|
+
|
|
117
|
+
add_offense_for_node(send_node, callback_name)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def add_offense_for_node(node, callback_name)
|
|
122
|
+
method_name = node.method_name
|
|
123
|
+
attribute = extract_attribute_name(node)
|
|
124
|
+
|
|
125
|
+
message = format(
|
|
126
|
+
MSG,
|
|
127
|
+
attribute: attribute,
|
|
128
|
+
method: method_name,
|
|
129
|
+
callback: callback_name
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
add_offense(node, message: message)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_attribute_name(node)
|
|
136
|
+
# Try to extract from hash argument (e.g., update(name: 'foo'))
|
|
137
|
+
if hash_argument?(node)
|
|
138
|
+
key = first_hash_key(node)
|
|
139
|
+
return key.to_s if key
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Try to extract from symbol argument (e.g., update_column(:name, 'foo'))
|
|
143
|
+
symbol_arg = first_symbol_arg(node)
|
|
144
|
+
return symbol_arg.to_s if symbol_arg
|
|
145
|
+
|
|
146
|
+
'attribute'
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module CallbackChecker
|
|
6
|
+
# This cop checks for persistence method calls on self within Active Record callbacks.
|
|
7
|
+
# Calling .save, .update, .toggle!, etc. on self inside a callback can trigger
|
|
8
|
+
# infinite loops or run the entire callback chain multiple times.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# # bad
|
|
12
|
+
# after_save :activate_user
|
|
13
|
+
# def activate_user
|
|
14
|
+
# self.save
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# # bad
|
|
18
|
+
# before_validation { update(status: 'active') }
|
|
19
|
+
#
|
|
20
|
+
# # good
|
|
21
|
+
# after_save :activate_user
|
|
22
|
+
# def activate_user
|
|
23
|
+
# self.status = 'active'
|
|
24
|
+
# end
|
|
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`."
|
|
29
|
+
|
|
30
|
+
CALLBACK_METHODS = %i[
|
|
31
|
+
before_validation after_validation
|
|
32
|
+
before_save after_save around_save
|
|
33
|
+
before_create after_create around_create
|
|
34
|
+
before_update after_update around_update
|
|
35
|
+
before_destroy after_destroy around_destroy
|
|
36
|
+
after_touch
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
SAFE_CALLBACKS = %i[
|
|
40
|
+
after_commit after_rollback
|
|
41
|
+
after_create_commit after_update_commit after_destroy_commit
|
|
42
|
+
after_save_commit
|
|
43
|
+
].freeze
|
|
44
|
+
|
|
45
|
+
PERSISTENCE_METHODS = %i[
|
|
46
|
+
save save! update update! update_attribute update_attributes
|
|
47
|
+
update_attributes! update_column update_columns
|
|
48
|
+
toggle! increment! decrement! touch
|
|
49
|
+
].freeze
|
|
50
|
+
|
|
51
|
+
def on_send(node)
|
|
52
|
+
return unless callback_method?(node)
|
|
53
|
+
return if safe_callback?(node)
|
|
54
|
+
|
|
55
|
+
check_callback_block(node) if node.block_node
|
|
56
|
+
check_callback_arguments(node)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def callback_method?(node)
|
|
62
|
+
CALLBACK_METHODS.include?(node.method_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def safe_callback?(node)
|
|
66
|
+
SAFE_CALLBACKS.include?(node.method_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_callback_block(node)
|
|
70
|
+
return unless node.block_node&.body
|
|
71
|
+
|
|
72
|
+
check_block_for_persistence(node.block_node.body, node.method_name)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def check_callback_arguments(node)
|
|
76
|
+
node.arguments.each do |arg|
|
|
77
|
+
process_callback_argument(node, arg)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def process_callback_argument(node, arg)
|
|
82
|
+
if arg.sym_type?
|
|
83
|
+
check_symbol_callback(node, arg.value)
|
|
84
|
+
elsif arg.block_pass_type?
|
|
85
|
+
# Handle block pass like: after_save &:method_name
|
|
86
|
+
# We can't easily analyze these, so skip
|
|
87
|
+
return
|
|
88
|
+
elsif arg.lambda_or_proc?
|
|
89
|
+
check_proc_callback(arg, node.method_name)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_proc_callback(arg, callback_name)
|
|
94
|
+
return unless arg.body
|
|
95
|
+
|
|
96
|
+
check_block_for_persistence(arg.body, callback_name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def check_symbol_callback(node, method_name)
|
|
100
|
+
scope = node.each_ancestor(:class, :module).first
|
|
101
|
+
return unless scope
|
|
102
|
+
|
|
103
|
+
method_def = scope.each_descendant(:def).find { |d| d.method_name == method_name }
|
|
104
|
+
return unless method_def
|
|
105
|
+
return unless method_def.body
|
|
106
|
+
|
|
107
|
+
check_block_for_persistence(method_def.body, node.method_name)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def check_block_for_persistence(node, callback_name)
|
|
111
|
+
node.each_node(:send) do |send_node|
|
|
112
|
+
check_for_self_persistence(send_node, callback_name)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_for_self_persistence(send_node, callback_name)
|
|
117
|
+
return unless PERSISTENCE_METHODS.include?(send_node.method_name)
|
|
118
|
+
return unless called_on_self?(send_node)
|
|
119
|
+
|
|
120
|
+
add_offense(
|
|
121
|
+
send_node,
|
|
122
|
+
message: format(MSG, method: send_node.method_name, callback: callback_name)
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def called_on_self?(send_node)
|
|
127
|
+
# No receiver means implicit self
|
|
128
|
+
return true if send_node.receiver.nil?
|
|
129
|
+
|
|
130
|
+
# Explicit self reference
|
|
131
|
+
send_node.receiver.self_type?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module CallbackChecker
|
|
6
|
+
# This cop enforces that callback methods should be short and simple.
|
|
7
|
+
# Long callback methods indicate that business logic is leaking into the model
|
|
8
|
+
# and should be extracted to a service object.
|
|
9
|
+
#
|
|
10
|
+
# @example Max: 5 (default)
|
|
11
|
+
# # bad
|
|
12
|
+
# class User < ApplicationRecord
|
|
13
|
+
# before_save :complex_setup
|
|
14
|
+
#
|
|
15
|
+
# def complex_setup
|
|
16
|
+
# self.name = name.strip
|
|
17
|
+
# self.email = email.downcase
|
|
18
|
+
# self.token = generate_secure_token
|
|
19
|
+
# self.status = calculate_status
|
|
20
|
+
# self.score = compute_score
|
|
21
|
+
# self.metadata = build_metadata
|
|
22
|
+
# self.tags = process_tags
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# # good
|
|
27
|
+
# class User < ApplicationRecord
|
|
28
|
+
# before_save :normalize_fields
|
|
29
|
+
#
|
|
30
|
+
# def normalize_fields
|
|
31
|
+
# self.name = name.strip
|
|
32
|
+
# self.email = email.downcase
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# # better - extract to service
|
|
37
|
+
# class User < ApplicationRecord
|
|
38
|
+
# # No callback, call UserRegistrationService.new(user).call from controller
|
|
39
|
+
# end
|
|
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."
|
|
43
|
+
|
|
44
|
+
CALLBACK_METHODS = %i[
|
|
45
|
+
before_validation after_validation
|
|
46
|
+
before_save after_save around_save
|
|
47
|
+
before_create after_create around_create
|
|
48
|
+
before_update after_update around_update
|
|
49
|
+
before_destroy after_destroy around_destroy
|
|
50
|
+
after_commit after_rollback
|
|
51
|
+
after_touch
|
|
52
|
+
after_create_commit after_update_commit after_destroy_commit after_save_commit
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
def on_send(node)
|
|
56
|
+
return unless callback_method?(node)
|
|
57
|
+
|
|
58
|
+
# Only check symbol arguments (method name references)
|
|
59
|
+
node.arguments.each do |arg|
|
|
60
|
+
check_callback_argument(node, arg) if arg.sym_type?
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def callback_method?(node)
|
|
67
|
+
CALLBACK_METHODS.include?(node.method_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_callback_argument(callback_node, arg)
|
|
71
|
+
method_name = arg.value
|
|
72
|
+
scope = callback_node.each_ancestor(:class, :module).first
|
|
73
|
+
return unless scope
|
|
74
|
+
|
|
75
|
+
method_def = find_method_definition(scope, method_name)
|
|
76
|
+
return unless method_def
|
|
77
|
+
|
|
78
|
+
check_method_length(method_def, method_name)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_method_definition(scope, method_name)
|
|
82
|
+
scope.each_descendant(:def).find { |def_node| def_node.method_name == method_name }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_method_length(method_node, method_name)
|
|
86
|
+
return unless method_node.body
|
|
87
|
+
|
|
88
|
+
length = method_body_length(method_node)
|
|
89
|
+
max_length = cop_config['Max'] || 5
|
|
90
|
+
|
|
91
|
+
return if length <= max_length
|
|
92
|
+
|
|
93
|
+
add_offense(
|
|
94
|
+
method_node,
|
|
95
|
+
message: format(MSG, method: method_name, length: length, max: max_length)
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def method_body_length(method_node)
|
|
100
|
+
return 0 unless method_node.body
|
|
101
|
+
|
|
102
|
+
body = method_node.body
|
|
103
|
+
|
|
104
|
+
# Calculate line count
|
|
105
|
+
first_line = body.first_line
|
|
106
|
+
last_line = body.last_line
|
|
107
|
+
|
|
108
|
+
# Count non-empty lines
|
|
109
|
+
(first_line..last_line).count do |line_number|
|
|
110
|
+
line = processed_source.lines[line_number - 1]
|
|
111
|
+
line && !line.strip.empty?
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|