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,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"
@@ -0,0 +1,6 @@
1
+ module Rubocop
2
+ module CallbackChecker
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
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: []