annotate_callbacks 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 972243c4b3851cdbe9e9856027d52e0525be83d010d5c7d8ccb9a84acf647afe
4
+ data.tar.gz: '08e595601483bc43aa3b2c177dfbe1873dbdce029593048d3d270362dfe1726c'
5
+ SHA512:
6
+ metadata.gz: e7bb6ae953a4458147133311a2ba92290f541e089e55decbd51fe1ca531dd9a157594045efd711e0d68a54e509444a3edbdfa869cb86d23508b4d526c205c3db
7
+ data.tar.gz: bce0d79758a6487d7507663bce8b398f5540d2fb7197af1127ee71d9520a75dd5ca6bb40e8b72309cbfcb984d76a808fe8f81315cde2fc47ec21e474519498c3
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 sloppybook
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # AnnotateCallbacks
2
+
3
+ Automatically annotate Rails model files with a comment block summarizing their ActiveRecord callbacks, including those inherited from concerns and parent classes.
4
+
5
+ ## Example Output
6
+
7
+ ```ruby
8
+ # == Callbacks ==
9
+ #
10
+ # before_validation :normalize_email
11
+ # before_save :encrypt_password
12
+ # before_save :track_changes if: :changed? [Trackable]
13
+ # after_create :send_welcome_email
14
+ # after_create (block: app/models/user.rb:18)
15
+ # after_save :update_cache if: :name_changed?
16
+ # before_destroy :check_admin
17
+ #
18
+ # == End Callbacks ==
19
+
20
+ class User < ApplicationRecord
21
+ include Trackable
22
+
23
+ before_validation :normalize_email
24
+ before_save :encrypt_password
25
+ after_create -> { NotificationService.ping(self) }
26
+ # ...
27
+ end
28
+ ```
29
+
30
+ - Callbacks from concerns are shown with `[ConcernName]`
31
+ - Block/lambda callbacks show source location: `(block: path:line)`
32
+ - Internal framework callbacks (`autosave_associated_records_for_*`, `dependent: :destroy` etc.) are automatically filtered out
33
+
34
+ ## Installation
35
+
36
+ Add to your Gemfile:
37
+
38
+ ```ruby
39
+ group :development do
40
+ gem "annotate_callbacks"
41
+ end
42
+ ```
43
+
44
+ ```bash
45
+ bundle install
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```bash
51
+ # Add callback annotations
52
+ bundle exec rake callbacks:annotate
53
+
54
+ # Remove callback annotations
55
+ bundle exec rake callbacks:remove
56
+ ```
57
+
58
+ ## How It Works
59
+
60
+ Uses `ApplicationRecord.descendants` and Rails runtime reflection (`_save_callbacks`, `_create_callbacks`, etc.) to detect all registered callbacks on each model class.
61
+
62
+ - Callbacks defined in concerns are included, with source module shown as `[ModuleName]`
63
+ - Block/lambda callbacks show relative source location: `(block: app/models/user.rb:10)`
64
+ - Symbol conditions (`if: :active?`) are shown; Proc conditions are omitted
65
+ - Internal framework callbacks are filtered out by name pattern and source module
66
+ - Abstract classes are skipped
67
+ - Files are written atomically (Tempfile + mv)
68
+ - `rake callbacks:annotate` requires Rails environment; `rake callbacks:remove` does not
69
+
70
+ ## Development
71
+
72
+ ```bash
73
+ git clone https://github.com/sloppybook/annotate_callbacks.git
74
+ cd annotate_callbacks
75
+ bundle install
76
+ bundle exec rspec
77
+ ```
78
+
79
+ ## License
80
+
81
+ [MIT License](LICENSE.txt)
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "fileutils"
5
+
6
+ module AnnotateCallbacks
7
+ class Annotator
8
+ ANNOTATION_START = "# == Callbacks =="
9
+ ANNOTATION_END = "# == End Callbacks =="
10
+ ANNOTATION_REGEX = /^#{Regexp.escape(ANNOTATION_START)}\n(.*?)^#{Regexp.escape(ANNOTATION_END)}\n\n?/m
11
+ MODEL_DIR = "app/models"
12
+
13
+ def annotate(model_class, file_path)
14
+ callbacks = Inspector.new(model_class).callbacks
15
+ return :skipped if callbacks.empty?
16
+
17
+ content = File.read(file_path, encoding: "UTF-8")
18
+ clean_content = content.sub(ANNOTATION_REGEX, "")
19
+
20
+ insert_pos = class_definition_line(clean_content)
21
+ return :skipped unless insert_pos
22
+
23
+ annotation = build_annotation(callbacks)
24
+ lines = clean_content.lines
25
+ insert_pos = skip_preceding_comments(lines, insert_pos)
26
+ lines.insert(insert_pos, annotation)
27
+ new_content = lines.join
28
+
29
+ return :unchanged if new_content == content
30
+
31
+ atomic_write(file_path, new_content)
32
+ :annotated
33
+ end
34
+
35
+ def remove_annotation(file_path)
36
+ content = File.read(file_path, encoding: "UTF-8")
37
+ return :skipped unless content.include?(ANNOTATION_START)
38
+
39
+ new_content = content.sub(ANNOTATION_REGEX, "")
40
+ return :unchanged if new_content == content
41
+
42
+ atomic_write(file_path, new_content)
43
+ :removed
44
+ end
45
+
46
+ def annotate_all
47
+ results = Hash.new { |h, k| h[k] = [] }
48
+
49
+ each_model do |model_class, file_path|
50
+ status = annotate(model_class, file_path)
51
+ results[status] << file_path
52
+ rescue StandardError => e
53
+ results[:errors] << { file: file_path, error: e.message }
54
+ end
55
+
56
+ results
57
+ end
58
+
59
+ def remove_all
60
+ results = Hash.new { |h, k| h[k] = [] }
61
+
62
+ Dir.glob(File.join(MODEL_DIR, "**", "*.rb")).sort.each do |file|
63
+ status = remove_annotation(file)
64
+ results[status] << file
65
+ rescue StandardError => e
66
+ results[:errors] << { file: file, error: e.message }
67
+ end
68
+
69
+ results
70
+ end
71
+
72
+ private
73
+
74
+ def each_model
75
+ ApplicationRecord.descendants.each do |klass|
76
+ next if klass.abstract_class?
77
+
78
+ file = source_file_for(klass)
79
+ next unless file
80
+
81
+ yield klass, file
82
+ end
83
+ end
84
+
85
+ def source_file_for(klass)
86
+ file = Object.const_source_location(klass.name)&.first if klass.name
87
+ return nil unless file
88
+
89
+ file.start_with?(project_model_dir) ? file : nil
90
+ end
91
+
92
+ def project_model_dir
93
+ @project_model_dir ||= File.expand_path(MODEL_DIR)
94
+ end
95
+
96
+ def class_definition_line(content)
97
+ content.lines.index { |line| line.match?(/\A\s*(class|module)\s+/) }
98
+ end
99
+
100
+ def skip_preceding_comments(lines, pos)
101
+ while pos > 0
102
+ prev = lines[pos - 1].strip
103
+ break unless prev.start_with?("#")
104
+ break if prev.match?(/^#.*(?:frozen_string_literal|encoding|warn_indent):/)
105
+ pos -= 1
106
+ end
107
+ pos
108
+ end
109
+
110
+ def atomic_write(file_path, content)
111
+ dir = File.dirname(file_path)
112
+ Tempfile.create(["annotate_callbacks", ".rb"], dir) do |tmp|
113
+ tmp.write(content)
114
+ tmp.flush
115
+ FileUtils.mv(tmp.path, file_path)
116
+ end
117
+ end
118
+
119
+ def build_annotation(callbacks)
120
+ max_type_len = callbacks.map { |c| c.type.length }.max
121
+ max_name_len = callbacks.map { |c| format_name(c).length }.max
122
+
123
+ lines = [ANNOTATION_START, "#"]
124
+ callbacks.each do |cb|
125
+ entry = "# %-#{max_type_len}s %-#{max_name_len}s" % [cb.type, format_name(cb)]
126
+ entry += " #{cb.options}" if cb.options
127
+ entry += " [#{cb.source}]" if cb.source
128
+ lines << entry.rstrip
129
+ end
130
+ lines << "#"
131
+ lines << ANNOTATION_END
132
+ lines.map { |l| "#{l}\n" }.join + "\n"
133
+ end
134
+
135
+ def format_name(cb)
136
+ cb.method_name.start_with?("(") ? cb.method_name : ":#{cb.method_name}"
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateCallbacks
4
+ class Inspector
5
+ CallbackEntry = Data.define(:type, :method_name, :options, :source)
6
+
7
+ CALLBACK_TYPES = %w[
8
+ initialize find touch validation
9
+ save create update destroy
10
+ commit rollback
11
+ ].freeze
12
+
13
+ INTERNAL_FILTER_PATTERNS = [
14
+ /\Aautosave_associated_records_for_/,
15
+ ].freeze
16
+
17
+ INTERNAL_BLOCK_PATHS = %w[
18
+ active_record/associations/builder/
19
+ active_record/timestamp
20
+ active_record/transactions
21
+ active_record/locking
22
+ ].freeze
23
+
24
+ INTERNAL_SOURCES = %w[
25
+ ActiveRecord::AutosaveAssociation
26
+ ActiveRecord::Timestamp
27
+ ActiveRecord::Transactions
28
+ ActiveRecord::Persistence
29
+ ActiveRecord::AttributeMethods::Dirty
30
+ ActiveRecord::Locking::Optimistic
31
+ ActiveRecord::CounterCache
32
+ ActiveRecord::Normalization
33
+ ActiveModel::Attributes::Normalization
34
+ ].freeze
35
+
36
+ def initialize(model_class)
37
+ @target = model_class
38
+ end
39
+
40
+ def callbacks
41
+ CALLBACK_TYPES
42
+ .flat_map { |type| extract_callbacks_for(type) }
43
+ .reject { |cb| internal?(cb) }
44
+ end
45
+
46
+ private
47
+
48
+ def extract_callbacks_for(type)
49
+ method_name = "_#{type}_callbacks"
50
+ return [] unless @target.respond_to?(method_name)
51
+
52
+ @target.send(method_name).filter_map { |cb| build_callback_info(cb, type) }
53
+ end
54
+
55
+ def build_callback_info(cb, type)
56
+ name = case cb.filter
57
+ when Symbol then cb.filter.to_s
58
+ when Proc then format_block(cb.filter)
59
+ else return nil
60
+ end
61
+
62
+ CallbackEntry.new(
63
+ type: "#{cb.kind}_#{type}",
64
+ method_name: name,
65
+ options: extract_options(cb),
66
+ source: detect_source(cb.filter)
67
+ )
68
+ end
69
+
70
+ def internal?(cb)
71
+ internal_by_name?(cb) || internal_by_block_path?(cb) || internal_by_source?(cb)
72
+ end
73
+
74
+ def internal_by_name?(cb)
75
+ INTERNAL_FILTER_PATTERNS.any? { |p| p.match?(cb.method_name) }
76
+ end
77
+
78
+ def internal_by_block_path?(cb)
79
+ return false unless cb.method_name.start_with?("(block:")
80
+
81
+ INTERNAL_BLOCK_PATHS.any? { |path| cb.method_name.include?(path) }
82
+ end
83
+
84
+ def internal_by_source?(cb)
85
+ cb.source && INTERNAL_SOURCES.any? { |s| cb.source.start_with?(s) }
86
+ end
87
+
88
+ def detect_source(filter)
89
+ return nil unless filter.is_a?(Symbol)
90
+ return nil unless @target.method_defined?(filter) || @target.private_method_defined?(filter)
91
+
92
+ owner = @target.instance_method(filter).owner
93
+ owner == @target ? nil : owner.name
94
+ rescue NameError
95
+ nil
96
+ end
97
+
98
+ def extract_options(cb)
99
+ if_conditions = extract_symbol_conditions(cb, :@if)
100
+ unless_conditions = extract_symbol_conditions(cb, :@unless)
101
+
102
+ parts = []
103
+ parts << "if: #{if_conditions.join(", ")}" if if_conditions.any?
104
+ parts << "unless: #{unless_conditions.join(", ")}" if unless_conditions.any?
105
+ parts.empty? ? nil : parts.join(", ")
106
+ end
107
+
108
+ def extract_symbol_conditions(cb, ivar)
109
+ (cb.instance_variable_get(ivar) || [])
110
+ .select { |c| c.is_a?(Symbol) }
111
+ .map { |c| ":#{c}" }
112
+ end
113
+
114
+ def format_block(proc_obj)
115
+ loc = proc_obj.source_location
116
+ return "(block)" unless loc
117
+
118
+ path = defined?(Rails) ? loc[0].sub("#{Rails.root}/", "") : loc[0]
119
+ "(block: #{path}:#{loc[1]})"
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateCallbacks
4
+ class Railtie < Rails::Railtie
5
+ rake_tasks do
6
+ namespace :callbacks do
7
+ desc "Annotate callbacks in model files"
8
+ task annotate: :environment do
9
+ Rails.application.eager_load!
10
+
11
+ results = Annotator.new.annotate_all
12
+
13
+ puts "Annotated: #{results[:annotated].length} file(s)"
14
+ results[:annotated].each { |f| puts " #{f}" }
15
+
16
+ if results[:errors].any?
17
+ puts "\nErrors: #{results[:errors].length}"
18
+ results[:errors].each { |e| puts " #{e[:file]}: #{e[:error]}" }
19
+ end
20
+ end
21
+
22
+ desc "Remove callback annotations from model files"
23
+ task :remove do
24
+ results = Annotator.new.remove_all
25
+
26
+ puts "Removed: #{results[:removed].length} file(s)"
27
+ results[:removed].each { |f| puts " #{f}" }
28
+
29
+ if results[:errors].any?
30
+ puts "\nErrors: #{results[:errors].length}"
31
+ results[:errors].each { |e| puts " #{e[:file]}: #{e[:error]}" }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnnotateCallbacks
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "annotate_callbacks/version"
4
+ require_relative "annotate_callbacks/inspector"
5
+ require_relative "annotate_callbacks/annotator"
6
+
7
+ module AnnotateCallbacks
8
+ end
9
+
10
+ require_relative "annotate_callbacks/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,109 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: annotate_callbacks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - sloppybook
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '7.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '7.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '7.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
+ description: Adds a comment block summarizing all ActiveRecord callbacks (including
70
+ those from concerns and parent classes) at the top of each model file using runtime
71
+ reflection.
72
+ email:
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - LICENSE.txt
78
+ - README.md
79
+ - lib/annotate_callbacks.rb
80
+ - lib/annotate_callbacks/annotator.rb
81
+ - lib/annotate_callbacks/inspector.rb
82
+ - lib/annotate_callbacks/railtie.rb
83
+ - lib/annotate_callbacks/version.rb
84
+ homepage: https://github.com/sloppybook/annotate_callbacks
85
+ licenses:
86
+ - MIT
87
+ metadata:
88
+ homepage_uri: https://github.com/sloppybook/annotate_callbacks
89
+ source_code_uri: https://github.com/sloppybook/annotate_callbacks
90
+ post_install_message:
91
+ rdoc_options: []
92
+ require_paths:
93
+ - lib
94
+ required_ruby_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: 3.2.0
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: '0'
104
+ requirements: []
105
+ rubygems_version: 3.4.10
106
+ signing_key:
107
+ specification_version: 4
108
+ summary: Annotate ActiveRecord callbacks as comments in model files
109
+ test_files: []