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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rubocop
4
+ module CallbackChecker
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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