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,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module CallbackChecker
|
|
6
|
+
# This cop enforces using named methods instead of procs or strings
|
|
7
|
+
# for callback conditionals (if: and unless:).
|
|
8
|
+
#
|
|
9
|
+
# Procs and strings are harder to debug, test, and override in subclasses.
|
|
10
|
+
# Named methods provide better readability and maintainability.
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# # bad
|
|
14
|
+
# before_save :do_thing, if: -> { status == 'active' && !deleted? }
|
|
15
|
+
# after_create :notify, unless: proc { Rails.env.test? }
|
|
16
|
+
# before_validation :check, if: "status == 'active'"
|
|
17
|
+
#
|
|
18
|
+
# # good
|
|
19
|
+
# before_save :do_thing, if: :active_and_present?
|
|
20
|
+
# after_create :notify, unless: :test_environment?
|
|
21
|
+
# before_validation :check, if: :active?
|
|
22
|
+
#
|
|
23
|
+
# private
|
|
24
|
+
#
|
|
25
|
+
# def active_and_present?
|
|
26
|
+
# status == 'active' && !deleted?
|
|
27
|
+
# end
|
|
28
|
+
class ConditionalStyle < Base
|
|
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
|
+
|
|
32
|
+
CALLBACK_METHODS = %i[
|
|
33
|
+
before_validation after_validation
|
|
34
|
+
before_save after_save around_save
|
|
35
|
+
before_create after_create around_create
|
|
36
|
+
before_update after_update around_update
|
|
37
|
+
before_destroy after_destroy around_destroy
|
|
38
|
+
after_commit after_rollback
|
|
39
|
+
after_touch
|
|
40
|
+
after_create_commit after_update_commit after_destroy_commit after_save_commit
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
CONDITIONAL_KEYS = %i[if unless].freeze
|
|
44
|
+
|
|
45
|
+
def on_send(node)
|
|
46
|
+
return unless callback_method?(node)
|
|
47
|
+
|
|
48
|
+
check_callback_conditionals(node)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def callback_method?(node)
|
|
54
|
+
CALLBACK_METHODS.include?(node.method_name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def check_callback_conditionals(node)
|
|
58
|
+
# Look for hash arguments that contain if: or unless: keys
|
|
59
|
+
node.arguments.each do |arg|
|
|
60
|
+
next unless arg.hash_type?
|
|
61
|
+
|
|
62
|
+
arg.pairs.each do |pair|
|
|
63
|
+
check_conditional_pair(pair)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_conditional_pair(pair)
|
|
69
|
+
return unless conditional_key?(pair.key)
|
|
70
|
+
|
|
71
|
+
value = pair.value
|
|
72
|
+
|
|
73
|
+
if value.lambda? || value.block_type?
|
|
74
|
+
add_offense(value, message: format(MSG, type: 'proc/lambda'))
|
|
75
|
+
elsif value.str_type?
|
|
76
|
+
add_offense(value, message: format(MSG, type: 'string'))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def conditional_key?(key)
|
|
81
|
+
return false unless key.sym_type?
|
|
82
|
+
|
|
83
|
+
CONDITIONAL_KEYS.include?(key.value)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module CallbackChecker
|
|
6
|
+
# This cop checks for side effects (API calls, mailers, background jobs, or modifying other records)
|
|
7
|
+
# in Active Record callbacks that execute within a database transaction.
|
|
8
|
+
#
|
|
9
|
+
# @example
|
|
10
|
+
# # bad
|
|
11
|
+
# after_create { UserMailer.welcome(self).deliver_now }
|
|
12
|
+
# after_save :notify_external_api
|
|
13
|
+
#
|
|
14
|
+
# # good
|
|
15
|
+
# after_commit :notify_external_api, on: :create
|
|
16
|
+
class NoSideEffectsInCallbacks < Base
|
|
17
|
+
MSG = "Avoid side effects (API calls, mailers, background jobs, or modifying other records) " \
|
|
18
|
+
"in %<callback>s. Use `after_commit` instead."
|
|
19
|
+
|
|
20
|
+
SIDE_EFFECT_SENSITIVE_CALLBACKS = %i[
|
|
21
|
+
before_validation before_save after_save
|
|
22
|
+
before_create after_create
|
|
23
|
+
before_update after_update
|
|
24
|
+
before_destroy after_destroy
|
|
25
|
+
around_save around_create around_update around_destroy
|
|
26
|
+
after_touch
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
SELF_PERSISTENCE_METHODS = %i[
|
|
30
|
+
save save! update update! update_attribute update_attributes
|
|
31
|
+
update_attributes! update_column update_columns
|
|
32
|
+
toggle! increment! decrement! touch
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
# Added common HTTP clients and SDKs
|
|
36
|
+
def_node_matcher :external_library_call?, <<~PATTERN
|
|
37
|
+
(send (const {nil? cbase} {:RestClient :Faraday :HTTParty :Net :HTTP :Excon :Typhoeus :Stripe :Aws :Intercom :Sidekiq :ActionCable}) ...)
|
|
38
|
+
PATTERN
|
|
39
|
+
|
|
40
|
+
# Match calls to any constant (e.g., NewsletterSDK, CustomSDK, etc.)
|
|
41
|
+
def_node_matcher :constant_method_call?, <<~PATTERN
|
|
42
|
+
(send (const {nil? cbase} _) _ ...)
|
|
43
|
+
PATTERN
|
|
44
|
+
|
|
45
|
+
# Added synchronous delivery and ActiveJob/Sidekiq variants
|
|
46
|
+
def_node_matcher :async_delivery?, <<~PATTERN
|
|
47
|
+
(send ... {:deliver_now :deliver_now! :deliver_later :deliver_later! :perform_later :perform_async :perform_at :perform_in :broadcast_later})
|
|
48
|
+
PATTERN
|
|
49
|
+
|
|
50
|
+
# Catches persistence on other objects: constants, local vars, or method calls (like associations)
|
|
51
|
+
# Explicitly excludes calls without receivers (implicit self) and explicit self calls
|
|
52
|
+
def_node_matcher :side_effect_persistence?, <<~PATTERN
|
|
53
|
+
(send !{nil? (self)} {:save :save! :update :update! :update_columns :destroy :destroy! :create :create! :toggle! :touch} ...)
|
|
54
|
+
PATTERN
|
|
55
|
+
|
|
56
|
+
def on_send(node)
|
|
57
|
+
return unless side_effect_sensitive_callback?(node)
|
|
58
|
+
|
|
59
|
+
check_callback_block(node) if node.block_literal?
|
|
60
|
+
check_callback_arguments(node)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def side_effect_sensitive_callback?(node)
|
|
66
|
+
SIDE_EFFECT_SENSITIVE_CALLBACKS.include?(node.method_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_callback_block(node)
|
|
70
|
+
check_method_contents(node.block_node.body, node.method_name)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_callback_arguments(node)
|
|
74
|
+
node.arguments.each do |arg|
|
|
75
|
+
process_callback_argument(node, arg)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def process_callback_argument(node, arg)
|
|
80
|
+
if arg.sym_type?
|
|
81
|
+
check_symbol_callback(node, arg.value)
|
|
82
|
+
elsif callback_proc?(arg)
|
|
83
|
+
check_method_contents(arg.body, node.method_name)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def callback_proc?(arg)
|
|
88
|
+
arg.block_type? || arg.lambda? || arg.proc?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def check_symbol_callback(node, method_name)
|
|
92
|
+
# Find the class/module containing the callback
|
|
93
|
+
scope = node.each_ancestor(:class, :module).first
|
|
94
|
+
return unless scope
|
|
95
|
+
|
|
96
|
+
# Search for the method definition anywhere in the class
|
|
97
|
+
method_def = scope.each_descendant(:def).find { |d| d.method_name == method_name }
|
|
98
|
+
return unless method_def
|
|
99
|
+
|
|
100
|
+
check_method_contents(method_def.body, node.method_name)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def check_method_contents(node, callback_name)
|
|
104
|
+
return unless node
|
|
105
|
+
|
|
106
|
+
# Collect all send nodes first to avoid duplicate reporting in chains
|
|
107
|
+
node.each_node(:send) do |send_node|
|
|
108
|
+
next unless side_effect_call?(send_node)
|
|
109
|
+
next if part_of_reported_chain?(send_node)
|
|
110
|
+
|
|
111
|
+
add_offense(send_node, message: format(MSG, callback: callback_name))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def side_effect_call?(send_node)
|
|
116
|
+
external_library_call?(send_node) ||
|
|
117
|
+
async_delivery?(send_node) ||
|
|
118
|
+
side_effect_persistence?(send_node) ||
|
|
119
|
+
suspicious_constant_call?(send_node)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if this node is part of a method chain where a parent would be reported
|
|
123
|
+
def part_of_reported_chain?(send_node)
|
|
124
|
+
parent = send_node.parent
|
|
125
|
+
return false unless parent&.send_type?
|
|
126
|
+
|
|
127
|
+
# If parent is also a side effect, we'll report the parent instead
|
|
128
|
+
# This handles cases like UserMailer.welcome(self).deliver_now
|
|
129
|
+
# We want to report the .deliver_now, not the .welcome
|
|
130
|
+
return false if async_delivery?(send_node) # Always report delivery methods
|
|
131
|
+
|
|
132
|
+
# If this is a receiver of a delivery method, don't report it
|
|
133
|
+
parent.receiver == send_node && async_delivery?(parent)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def suspicious_constant_call?(send_node)
|
|
137
|
+
return false unless constant_method_call?(send_node)
|
|
138
|
+
return false if safe_constant_new_call?(send_node)
|
|
139
|
+
|
|
140
|
+
# Exclude known safe Rails constants and common utilities
|
|
141
|
+
receiver = send_node.receiver
|
|
142
|
+
return false unless receiver&.const_type?
|
|
143
|
+
|
|
144
|
+
const_name = receiver.const_name
|
|
145
|
+
safe_constants = %w[
|
|
146
|
+
Rails ActiveRecord ActiveSupport ActiveModel ActionController
|
|
147
|
+
ActionView ActionMailer ApplicationRecord
|
|
148
|
+
File Dir Pathname URI JSON YAML CSV
|
|
149
|
+
Time Date DateTime
|
|
150
|
+
Math Random SecureRandom
|
|
151
|
+
Logger
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
# If it's a known safe constant, it's not suspicious
|
|
155
|
+
return false if safe_constants.any? { |safe| const_name.start_with?(safe) }
|
|
156
|
+
|
|
157
|
+
# If it's calling a method that looks like a side effect, flag it
|
|
158
|
+
# This will catch things like NewsletterSDK.subscribe, CustomAPI.call, etc.
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Check if this is a safe .new call on a constant (e.g., OtherRecord.new)
|
|
163
|
+
def safe_constant_new_call?(send_node)
|
|
164
|
+
send_node.method?(:new)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
require_relative "callback_checker/no_side_effects_in_callbacks"
|
|
2
|
+
require_relative "callback_checker/avoid_self_persistence"
|
|
3
|
+
require_relative "callback_checker/attribute_assignment_only"
|
|
4
|
+
require_relative "callback_checker/callback_method_length"
|
|
5
|
+
require_relative "callback_checker/conditional_style"
|
metadata
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: rubocop-callback_checker
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- rahsheen
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-13 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rubocop
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rubocop-ast
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop-rails
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '2.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '2.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rubocop-rspec
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.0'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2.0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rubocop-rake
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.7'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.7'
|
|
111
|
+
description: A RuboCop extension focused on avoiding callback hell in Rails.
|
|
112
|
+
email:
|
|
113
|
+
- rahsheen.porter@gmail.com
|
|
114
|
+
executables:
|
|
115
|
+
- rubocop-callback-checker
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- ".rspec"
|
|
120
|
+
- ".rubocop.yml"
|
|
121
|
+
- CHANGELOG.md
|
|
122
|
+
- CODE_OF_CONDUCT.md
|
|
123
|
+
- LICENSE.txt
|
|
124
|
+
- README.md
|
|
125
|
+
- Rakefile
|
|
126
|
+
- config/default.yml
|
|
127
|
+
- exe/rubocop-callback-checker
|
|
128
|
+
- lib/callback_checker/cli.rb
|
|
129
|
+
- lib/callback_checker/prism_analyzer.rb
|
|
130
|
+
- lib/rubocop/callback_checker.rb
|
|
131
|
+
- lib/rubocop/callback_checker/version.rb
|
|
132
|
+
- lib/rubocop/cop/callback_checker/attribute_assignment_only.rb
|
|
133
|
+
- lib/rubocop/cop/callback_checker/avoid_self_persistence.rb
|
|
134
|
+
- lib/rubocop/cop/callback_checker/callback_method_length.rb
|
|
135
|
+
- lib/rubocop/cop/callback_checker/conditional_style.rb
|
|
136
|
+
- lib/rubocop/cop/callback_checker/no_side_effects_in_callbacks.rb
|
|
137
|
+
- lib/rubocop/cop/cops.rb
|
|
138
|
+
- sig/rubocop/callback_checker.rbs
|
|
139
|
+
homepage: https://github.com/rahsheen/rubocop-callback_checker
|
|
140
|
+
licenses:
|
|
141
|
+
- MIT
|
|
142
|
+
metadata:
|
|
143
|
+
allowed_push_host: https://rubygems.org
|
|
144
|
+
homepage_uri: https://github.com/rahsheen/rubocop-callback_checker
|
|
145
|
+
source_code_uri: https://github.com/rahsheen/rubocop-callback_checker
|
|
146
|
+
changelog_uri: https://github.com/rahsheen/rubocop-callback_checker/blob/main/CHANGELOG.md
|
|
147
|
+
post_install_message:
|
|
148
|
+
rdoc_options: []
|
|
149
|
+
require_paths:
|
|
150
|
+
- lib
|
|
151
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - ">="
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: 3.1.0
|
|
156
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
157
|
+
requirements:
|
|
158
|
+
- - ">="
|
|
159
|
+
- !ruby/object:Gem::Version
|
|
160
|
+
version: '0'
|
|
161
|
+
requirements: []
|
|
162
|
+
rubygems_version: 3.5.9
|
|
163
|
+
signing_key:
|
|
164
|
+
specification_version: 4
|
|
165
|
+
summary: rubocop
|
|
166
|
+
test_files: []
|