activerecord_callback_lens 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: a539b633bd3793bd684d7a93236783afbdb13b946400fd968c204281c3435e7f
4
+ data.tar.gz: 806014fbee749570c24bac4df333d85f96157d16e42ba5b880380682c729102d
5
+ SHA512:
6
+ metadata.gz: 44ab27417d490c5ab1e39b8d3f900f92aa94ece3334be91dd65a04113b77ad2c93a1b95a09b5ec12e90344bd9911f7239e2b9f6864c11cd1f673d4cd23ec792a
7
+ data.tar.gz: 384e76570141682a41e7fd5dd2402580999a52e24c8196271739017b83452d4ec5ccd94fcf1918ef8a04f1a8842ea1c49ed16a68653b665796d427fdff29be53
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eraxel.Dev
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 all
13
+ 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 THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,110 @@
1
+ # activerecord_callback_lens
2
+
3
+ **X-ray your ActiveRecord callbacks.**
4
+
5
+ Callbacks are easy to add and hard to reason about. `activerecord_callback_lens` statically analyzes the callbacks registered on your ActiveRecord models, parses their `if`/`unless` conditions into logical trees, and renders the result as a Mermaid diagram so you can see exactly what runs and why.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby >= 3.2
10
+ - ActiveRecord >= 7.0, < 9.0
11
+
12
+ ## Installation
13
+
14
+ Add to your `Gemfile`:
15
+
16
+ ```ruby
17
+ gem "activerecord_callback_lens"
18
+ ```
19
+
20
+ Or install directly:
21
+
22
+ ```sh
23
+ gem install activerecord_callback_lens
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ ### CLI
29
+
30
+ Point the `callback_lens` binary at any loaded ActiveRecord model:
31
+
32
+ ```sh
33
+ callback_lens analyze User
34
+ ```
35
+
36
+ This prints a Mermaid `graph TD` diagram to stdout showing every callback and its condition dependencies.
37
+
38
+ ```
39
+ graph TD
40
+ n0["before_save"]
41
+ n1["AndNode"]
42
+ n2["saved_change_to_title?"]
43
+ n3["status_completed?"]
44
+ n2 --> n1
45
+ n3 --> n1
46
+ n1 --> n0
47
+ ```
48
+
49
+ > **Note:** The model class must be loaded before analysis. In a Rails app, run the binary inside `rails runner` or require your environment first.
50
+
51
+ ### Rake task (Rails)
52
+
53
+ The gem ships a Railtie that automatically loads the `callback_lens` namespace into your Rails rake tasks:
54
+
55
+ ```sh
56
+ rake callback_lens:analyze MODEL=User
57
+ rake callback_lens:mermaid MODEL=User # alias
58
+ ```
59
+
60
+ ### Programmatic API
61
+
62
+ ```ruby
63
+ require "activerecord_callback_lens"
64
+
65
+ # 1. Collect raw callback definitions
66
+ definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(User)
67
+
68
+ # 2. Parse if/unless conditions into ConditionTrees
69
+ definitions = definitions.map { |d| ActiverecordCallbackLens::Parser::ConditionParser.parse(d) }
70
+
71
+ # 3. Build a dependency graph
72
+ graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
73
+
74
+ # 4. Render as Mermaid
75
+ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
76
+ ```
77
+
78
+ ## How it works
79
+
80
+ | Layer | Class | Responsibility |
81
+ |---|---|---|
82
+ | Collector | `CallbackCollector` | Reads ActiveRecord's internal `_save_callbacks`, `_create_callbacks`, `_update_callbacks`, `_destroy_callbacks`, and `_validation_callbacks` chains |
83
+ | Parser | `ConditionParser` | Uses [Prism](https://github.com/ruby/prism) to parse `Proc`/`Lambda` conditions into `AndNode`/`OrNode`/`NotNode`/`PredicateNode` trees; `Symbol` conditions become `MethodRefNode` stubs |
84
+ | Graph | `GraphBuilder` | Assembles a DAG of `CallbackNode`, `ConditionNode`, `PredicateNode`, and `MethodNode` values |
85
+ | Renderer | `MermaidRenderer` | Serializes the graph to a Mermaid `graph TD` string |
86
+
87
+ ## Condition tree nodes
88
+
89
+ | Node | Meaning |
90
+ |---|---|
91
+ | `AndNode` | All children must be true |
92
+ | `OrNode` | At least one child must be true |
93
+ | `NotNode` | Negates its child (from `unless:`) |
94
+ | `PredicateNode` | A leaf predicate call (e.g. `saved_change_to_title?`) |
95
+ | `MethodRefNode` | A Symbol condition (e.g. `:sync_required?`) — expanded in v0.2 |
96
+
97
+ ## Roadmap
98
+
99
+ | Version | Feature |
100
+ |---|---|
101
+ | **v0.1** | CallbackCollector, Prism condition parser, Mermaid renderer, CLI, Rake task |
102
+ | v0.2 | MethodResolver — recursive expansion of Symbol conditions |
103
+ | v0.3 | Graphviz / DOT renderer |
104
+ | v0.4 | HTML report (callback list, execution flow, embedded diagram) |
105
+ | v1.0 | Runtime tracer via `ActiveSupport::Notifications` |
106
+ | v2.0 | RBS analysis, cross-model dependency graph |
107
+
108
+ ## License
109
+
110
+ [MIT](LICENSE)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/activerecord_callback_lens/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "activerecord_callback_lens"
7
+ s.version = ActiverecordCallbackLens::VERSION
8
+ s.summary = "X-ray your ActiveRecord callbacks"
9
+ s.description = "Inspect, parse, and visualize the callbacks registered on your " \
10
+ "ActiveRecord models — including their if/unless conditions — as " \
11
+ "graphs and diagrams."
12
+ s.authors = ["Eraxel.Dev"]
13
+ s.email = ["masacode.vancouver@gmail.com"]
14
+ s.homepage = "https://github.com/eraxel/activerecord_callback_lens"
15
+ s.license = "MIT"
16
+
17
+ s.required_ruby_version = ">= 3.2"
18
+
19
+ s.bindir = "exe"
20
+ s.executables = ["callback_lens"]
21
+ s.require_paths = ["lib"]
22
+ s.files = Dir[
23
+ "lib/**/*.rb",
24
+ "lib/**/*.rake",
25
+ "exe/*",
26
+ "LICENSE",
27
+ "README.md",
28
+ "activerecord_callback_lens.gemspec"
29
+ ]
30
+
31
+ s.metadata = {
32
+ "homepage_uri" => s.homepage,
33
+ "source_code_uri" => s.homepage,
34
+ "rubygems_mfa_required" => "true"
35
+ }
36
+
37
+ s.add_dependency "activerecord", ">= 7.0", "< 9.0"
38
+ s.add_dependency "prism", "~> 1.0"
39
+ s.add_dependency "thor", "~> 1.0"
40
+ end
data/exe/callback_lens ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "activerecord_callback_lens"
5
+
6
+ ActiverecordCallbackLens::CLI::App.start(ARGV)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ require_relative "../collector/callback_collector"
6
+ require_relative "../parser/condition_parser"
7
+ require_relative "../graph/graph_builder"
8
+ require_relative "../renderer/mermaid_renderer"
9
+
10
+ module ActiverecordCallbackLens
11
+ module CLI
12
+ # Thor application exposing the callback_lens command-line interface.
13
+ #
14
+ # For v0.1 it provides a single command, +analyze+, which runs the full
15
+ # pipeline (collect -> parse -> build graph -> render) and prints a Mermaid
16
+ # diagram to stdout. An unknown model name is reported with a friendly
17
+ # message and a non-zero exit status rather than a Ruby backtrace.
18
+ class App < Thor
19
+ # Tells Thor to exit with a non-zero status when a command raises, so the
20
+ # +exit 1+ paths below propagate a failure code to the shell.
21
+ #
22
+ # @return [Boolean]
23
+ def self.exit_on_failure?
24
+ true
25
+ end
26
+
27
+ desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid diagram"
28
+ option :mermaid, type: :boolean, default: true, desc: "Output a Mermaid diagram to stdout"
29
+ # Runs the analysis pipeline for +model_name+ and prints the result.
30
+ #
31
+ # @param model_name [String] the ActiveRecord model class name
32
+ # @return [void]
33
+ def analyze(model_name)
34
+ model_class = resolve_model(model_name)
35
+ graph = build_graph(model_class)
36
+ puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
37
+ end
38
+
39
+ private
40
+
41
+ # Resolves a model class by name, printing a friendly error and exiting
42
+ # non-zero when the constant cannot be found.
43
+ #
44
+ # @param model_name [String]
45
+ # @return [Class]
46
+ def resolve_model(model_name)
47
+ Object.const_get(model_name)
48
+ rescue NameError
49
+ warn "Error: cannot find model class '#{model_name}'. Make sure it is loaded."
50
+ exit 1
51
+ end
52
+
53
+ # Collect -> Parse -> Build the dependency graph for a model class.
54
+ #
55
+ # @param model_class [Class]
56
+ # @return [Graph::Graph]
57
+ def build_graph(model_class)
58
+ definitions = Collector::CallbackCollector.collect(model_class)
59
+ definitions = definitions.map { |definition| Parser::ConditionParser.parse(definition) }
60
+ Graph::GraphBuilder.build(definitions)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "callback_definition"
4
+
5
+ module ActiverecordCallbackLens
6
+ module Collector
7
+ # Reads ActiveRecord's internal callback chains for a model class and returns
8
+ # an array of CallbackDefinition structs with raw condition data, ready for
9
+ # the Parser layer to populate +condition_tree+.
10
+ #
11
+ # Each of the five callback events (save, create, update, destroy, validation)
12
+ # is accessed through ActiveRecord's private chain accessor and every
13
+ # ActiveSupport::Callbacks::Callback in the chain is converted into one
14
+ # CallbackDefinition.
15
+ class CallbackCollector
16
+ # The callback events enumerated, in a stable order.
17
+ EVENTS = %i[save create update destroy validation].freeze
18
+
19
+ # Maps each event to ActiveRecord's internal callback chain accessor.
20
+ CHAIN_METHODS = {
21
+ save: :_save_callbacks,
22
+ create: :_create_callbacks,
23
+ update: :_update_callbacks,
24
+ destroy: :_destroy_callbacks,
25
+ validation: :_validation_callbacks
26
+ }.freeze
27
+
28
+ # Collect all callbacks registered on a model class.
29
+ #
30
+ # @param model_class [Class] an ActiveRecord::Base subclass
31
+ # @return [Array<CallbackDefinition>]
32
+ def self.collect(model_class)
33
+ new(model_class).collect
34
+ end
35
+
36
+ # @param model_class [Class] an ActiveRecord::Base subclass
37
+ def initialize(model_class)
38
+ @model_class = model_class
39
+ end
40
+
41
+ # @return [Array<CallbackDefinition>]
42
+ def collect
43
+ EVENTS.flat_map { |event| collect_event(event) }
44
+ end
45
+
46
+ private
47
+
48
+ # @param event [Symbol]
49
+ # @return [Array<CallbackDefinition>]
50
+ def collect_event(event)
51
+ chain = @model_class.send(CHAIN_METHODS[event])
52
+ chain.map { |callback| build_definition(callback, event) }
53
+ end
54
+
55
+ # @param callback [ActiveSupport::Callbacks::Callback]
56
+ # @param event [Symbol]
57
+ # @return [CallbackDefinition]
58
+ def build_definition(callback, event)
59
+ filter = callback.filter
60
+ CallbackDefinition.new(
61
+ model: @model_class,
62
+ event: event,
63
+ phase: callback.kind,
64
+ filter: filter,
65
+ raw_conditions: extract_conditions(callback),
66
+ condition_tree: nil,
67
+ source_location: resolve_location(filter)
68
+ )
69
+ end
70
+
71
+ # Extracts the raw if/unless condition arrays from a callback's internals.
72
+ #
73
+ # @param callback [ActiveSupport::Callbacks::Callback]
74
+ # @return [Hash{Symbol => Array}]
75
+ def extract_conditions(callback)
76
+ {
77
+ if: Array(callback.instance_variable_get(:@if)),
78
+ unless: Array(callback.instance_variable_get(:@unless))
79
+ }
80
+ end
81
+
82
+ # Resolves the source location for a filter when possible.
83
+ #
84
+ # Proc/Lambda filters expose +source_location+; Symbol filters are resolved
85
+ # later by the MethodResolver (v0.2), and String filters have no location.
86
+ #
87
+ # @param filter [Symbol, Proc, String]
88
+ # @return [Array(String, Integer), nil]
89
+ def resolve_location(filter)
90
+ case filter
91
+ when Proc then filter.source_location
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ module Collector
5
+ # Represents a single callback registered on an ActiveRecord model.
6
+ #
7
+ # event + phase together reconstruct the full callback name
8
+ # (e.g. :before + :save => before_save).
9
+ # raw_conditions holds the unprocessed if/unless arrays from AR internals.
10
+ # condition_tree is nil until the Parser populates it (nil when no conditions).
11
+ CallbackDefinition = Data.define(
12
+ :model, # Class — the ActiveRecord model class
13
+ :event, # Symbol — :save, :create, :update, :destroy, :validation
14
+ :phase, # Symbol — :before, :after, :around
15
+ :filter, # Symbol | Proc | String — the callback body
16
+ :raw_conditions, # { if: [...], unless: [...] } — raw arrays from AR internals
17
+ :condition_tree, # ConditionTree::Node | nil — parsed logical tree
18
+ :source_location # [String, Integer] | nil — file and line of the filter
19
+ )
20
+ end
21
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "nodes"
4
+ require_relative "../parser/condition_tree"
5
+
6
+ module ActiverecordCallbackLens
7
+ module Graph
8
+ # Converts an array of parsed CallbackDefinition objects into a Graph.
9
+ #
10
+ # Each definition becomes a CallbackNode. When a definition has a
11
+ # condition_tree, the tree is walked recursively and mapped onto graph nodes:
12
+ #
13
+ # AndNode / OrNode -> ConditionNode, then recurse into children
14
+ # NotNode -> no node; recurse into the child (negation is dropped
15
+ # for the v0.1 dependency view)
16
+ # PredicateNode -> PredicateNode graph node
17
+ # MethodRefNode -> MethodNode; recurse into expanded_tree when present
18
+ #
19
+ # Every condition/predicate/method node gets a +:requires+ edge pointing at
20
+ # its parent (the callback or enclosing combinator), so edges flow from a
21
+ # dependency up to the thing that depends on it.
22
+ class GraphBuilder
23
+ # @param definitions [Array<Collector::CallbackDefinition>]
24
+ # @return [Graph::Graph]
25
+ def self.build(definitions)
26
+ new(definitions).build
27
+ end
28
+
29
+ # @param definitions [Array<Collector::CallbackDefinition>]
30
+ def initialize(definitions)
31
+ @definitions = definitions
32
+ @nodes = []
33
+ @edges = []
34
+ @counter = 0
35
+ end
36
+
37
+ # @return [Graph::Graph]
38
+ def build
39
+ @definitions.each { |definition| add_callback(definition) }
40
+ Graph.new(nodes: @nodes, edges: @edges)
41
+ end
42
+
43
+ private
44
+
45
+ # Generates a stable, monotonically increasing node id ("n0", "n1", ...).
46
+ #
47
+ # @return [String]
48
+ def next_id
49
+ id = "n#{@counter}"
50
+ @counter += 1
51
+ id
52
+ end
53
+
54
+ # @param definition [Collector::CallbackDefinition]
55
+ # @return [void]
56
+ def add_callback(definition)
57
+ callback_node = CallbackNode.new(id: next_id, definition: definition)
58
+ @nodes << callback_node
59
+ return unless definition.condition_tree
60
+
61
+ add_tree(definition.condition_tree, parent_id: callback_node.id)
62
+ end
63
+
64
+ # Recursively maps a ConditionTree node onto graph nodes and edges.
65
+ #
66
+ # @param tree_node [Parser::ConditionTree::Node, nil]
67
+ # @param parent_id [String] id of the node this subtree depends on
68
+ # @return [void]
69
+ def add_tree(tree_node, parent_id:)
70
+ case tree_node
71
+ when Parser::ConditionTree::AndNode, Parser::ConditionTree::OrNode
72
+ add_combinator(tree_node, parent_id: parent_id)
73
+ when Parser::ConditionTree::NotNode
74
+ add_tree(tree_node.child, parent_id: parent_id)
75
+ when Parser::ConditionTree::PredicateNode
76
+ add_leaf(PredicateNode.new(id: next_id, predicate_name: tree_node.name), parent_id: parent_id)
77
+ when Parser::ConditionTree::MethodRefNode
78
+ add_method_ref(tree_node, parent_id: parent_id)
79
+ end
80
+ end
81
+
82
+ # @param tree_node [Parser::ConditionTree::AndNode, Parser::ConditionTree::OrNode]
83
+ # @param parent_id [String]
84
+ # @return [void]
85
+ def add_combinator(tree_node, parent_id:)
86
+ condition_node = ConditionNode.new(id: next_id, tree_node: tree_node)
87
+ @nodes << condition_node
88
+ @edges << Edge.new(from_id: condition_node.id, to_id: parent_id, label: :requires)
89
+ tree_node.children.each { |child| add_tree(child, parent_id: condition_node.id) }
90
+ end
91
+
92
+ # @param tree_node [Parser::ConditionTree::MethodRefNode]
93
+ # @param parent_id [String]
94
+ # @return [void]
95
+ def add_method_ref(tree_node, parent_id:)
96
+ method_node = MethodNode.new(id: next_id, method_name: tree_node.name)
97
+ @nodes << method_node
98
+ @edges << Edge.new(from_id: method_node.id, to_id: parent_id, label: :requires)
99
+ return unless tree_node.expanded_tree
100
+
101
+ add_tree(tree_node.expanded_tree, parent_id: method_node.id)
102
+ end
103
+
104
+ # Appends a leaf node and its edge to the enclosing parent.
105
+ #
106
+ # @param node [Graph::PredicateNode]
107
+ # @param parent_id [String]
108
+ # @return [void]
109
+ def add_leaf(node, parent_id:)
110
+ @nodes << node
111
+ @edges << Edge.new(from_id: node.id, to_id: parent_id, label: :requires)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ module Graph
5
+ # The graph layer turns a list of CallbackDefinition objects (with their
6
+ # parsed ConditionTrees) into a directed acyclic graph of typed nodes and
7
+ # labelled edges, ready for a renderer to emit.
8
+ #
9
+ # Every node carries a unique +id+ (assigned by the GraphBuilder) plus the
10
+ # domain object it represents. Edges connect a child node to its parent via
11
+ # +from_id+ / +to_id+ and carry a +label+ describing the relationship
12
+ # (+:requires+ for condition/predicate/method dependencies).
13
+ #
14
+ # All node and edge types are immutable Data values.
15
+
16
+ # One node per CallbackDefinition, e.g. before_save / after_commit.
17
+ # definition: Collector::CallbackDefinition
18
+ CallbackNode = Data.define(:id, :definition)
19
+
20
+ # One node per logical combinator (AndNode / OrNode) in a ConditionTree.
21
+ # tree_node: ConditionTree::AndNode | ConditionTree::OrNode
22
+ ConditionNode = Data.define(:id, :tree_node)
23
+
24
+ # One node per Symbol condition (e.g. :sync_required?). method_name: String
25
+ MethodNode = Data.define(:id, :method_name)
26
+
27
+ # One node per predicate method call (e.g. saved_change_to_title?).
28
+ # predicate_name: String
29
+ PredicateNode = Data.define(:id, :predicate_name)
30
+
31
+ # A directed edge from a child node to its parent node. label: Symbol
32
+ Edge = Data.define(:from_id, :to_id, :label)
33
+
34
+ # The assembled graph: a flat list of nodes and a flat list of edges.
35
+ # nodes: Array<node>, edges: Array<Edge>
36
+ Graph = Data.define(:nodes, :edges)
37
+ end
38
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require_relative "condition_tree"
6
+
7
+ module ActiverecordCallbackLens
8
+ module Parser
9
+ # Turns a CallbackDefinition's raw if/unless conditions into a structured
10
+ # ConditionTree.
11
+ #
12
+ # Proc/Lambda conditions are parsed by reading their source file with Prism,
13
+ # isolating the enclosing lambda/block node by line number, and recursively
14
+ # mapping the boolean AST (`&&`, `||`, `!`, predicate calls) onto ConditionTree
15
+ # nodes. Symbol conditions become MethodRefNodes whose expansion is deferred to
16
+ # the MethodResolver. Any other entry kind (e.g. ActiveModel's framework
17
+ # injected Conditionals::Value guard on after_* callbacks) is ignored.
18
+ #
19
+ # Parsing never raises on missing or unreadable source: a nil source_location
20
+ # or a missing file is skipped silently, and a Prism parse failure warns to
21
+ # $stderr while leaving the condition unresolved.
22
+ class ConditionParser
23
+ # @param definition [Collector::CallbackDefinition]
24
+ # @return [Collector::CallbackDefinition] a copy with condition_tree populated
25
+ def self.parse(definition)
26
+ new(definition).parse
27
+ end
28
+
29
+ # @param definition [Collector::CallbackDefinition]
30
+ def initialize(definition)
31
+ @definition = definition
32
+ end
33
+
34
+ # @return [Collector::CallbackDefinition] a copy with condition_tree set
35
+ def parse
36
+ @definition.with(condition_tree: build_tree)
37
+ end
38
+
39
+ private
40
+
41
+ # Assembles if_nodes and negated unless_nodes into a single tree.
42
+ #
43
+ # @return [ConditionTree::Node, nil] nil when there are no resolvable conditions
44
+ def build_tree
45
+ conditions = @definition.raw_conditions
46
+ if_nodes = Array(conditions[:if]).map { |c| parse_condition(c) }
47
+ unless_nodes = Array(conditions[:unless]).map { |c| negate(parse_condition(c)) }
48
+
49
+ combine((if_nodes + unless_nodes).compact)
50
+ end
51
+
52
+ # Reduces a flat list of resolved condition nodes to a single tree.
53
+ #
54
+ # @param nodes [Array<ConditionTree::Node>]
55
+ # @return [ConditionTree::Node, nil]
56
+ def combine(nodes)
57
+ case nodes.size
58
+ when 0 then nil
59
+ when 1 then nodes.first
60
+ else ConditionTree::AndNode.new(children: nodes)
61
+ end
62
+ end
63
+
64
+ # @param node [ConditionTree::Node, nil]
65
+ # @return [ConditionTree::NotNode, nil]
66
+ def negate(node)
67
+ return nil if node.nil?
68
+
69
+ ConditionTree::NotNode.new(child: node)
70
+ end
71
+
72
+ # Dispatches a single raw condition entry to the right handler.
73
+ #
74
+ # @param condition [Symbol, Proc, Object]
75
+ # @return [ConditionTree::Node, nil]
76
+ def parse_condition(condition)
77
+ case condition
78
+ when Symbol then ConditionTree::MethodRefNode.new(name: condition.to_s, expanded_tree: nil)
79
+ when Proc then parse_proc(condition)
80
+ end
81
+ end
82
+
83
+ # Parses a Proc/Lambda condition by reading and walking its source.
84
+ #
85
+ # @param proc_obj [Proc]
86
+ # @return [ConditionTree::Node, nil]
87
+ def parse_proc(proc_obj)
88
+ file, line = proc_obj.source_location
89
+ return nil unless file && File.exist?(file)
90
+
91
+ result = Prism.parse_file(file)
92
+ unless result.success?
93
+ warn("[ActiverecordCallbackLens] Failed to parse #{file}: #{result.errors.map(&:message).join(', ')}")
94
+ return nil
95
+ end
96
+
97
+ locator = LambdaLocator.new(target_line: line)
98
+ locator.visit(result.value)
99
+ walk(locator.node)
100
+ end
101
+
102
+ # Recursively maps a Prism boolean AST onto ConditionTree nodes.
103
+ #
104
+ # @param node [Prism::Node, nil]
105
+ # @return [ConditionTree::Node, nil]
106
+ def walk(node)
107
+ case node
108
+ in Prism::StatementsNode then walk(node.body.last)
109
+ in Prism::ParenthesesNode then walk(node.body)
110
+ in Prism::AndNode then combinator(ConditionTree::AndNode, node)
111
+ in Prism::OrNode then combinator(ConditionTree::OrNode, node)
112
+ in Prism::CallNode if node.name == :! then negate(walk(node.receiver))
113
+ in Prism::CallNode then ConditionTree::PredicateNode.new(name: node.name.to_s)
114
+ else nil
115
+ end
116
+ end
117
+
118
+ # Builds an And/Or combinator from a binary Prism node, walking both sides
119
+ # and dropping any unresolved (nil) operand.
120
+ #
121
+ # @param klass [Class] ConditionTree::AndNode or ConditionTree::OrNode
122
+ # @param node [Prism::AndNode, Prism::OrNode]
123
+ # @return [ConditionTree::Node]
124
+ def combinator(klass, node)
125
+ klass.new(children: [walk(node.left), walk(node.right)].compact)
126
+ end
127
+
128
+ # A Prism visitor that captures the innermost LambdaNode or BlockNode whose
129
+ # source range encloses a target line. Used to isolate a single proc's body
130
+ # from the surrounding file AST.
131
+ class LambdaLocator < Prism::Visitor
132
+ # @return [Prism::Node, nil] the body of the innermost matching lambda/block
133
+ attr_reader :node
134
+
135
+ # @param target_line [Integer] the line the proc's source_location reports
136
+ def initialize(target_line:)
137
+ @target_line = target_line
138
+ @node = nil
139
+ super()
140
+ end
141
+
142
+ # @param lambda_node [Prism::LambdaNode]
143
+ # @return [void]
144
+ def visit_lambda_node(lambda_node)
145
+ capture(lambda_node)
146
+ super
147
+ end
148
+
149
+ # @param block_node [Prism::BlockNode]
150
+ # @return [void]
151
+ def visit_block_node(block_node)
152
+ capture(block_node)
153
+ super
154
+ end
155
+
156
+ private
157
+
158
+ # Records the candidate's body when it encloses the target line. Because
159
+ # the visitor descends depth-first, the last (innermost) enclosing match
160
+ # wins, isolating nested blocks correctly.
161
+ #
162
+ # @param candidate [Prism::LambdaNode, Prism::BlockNode]
163
+ # @return [void]
164
+ def capture(candidate)
165
+ location = candidate.location
166
+ return unless @target_line.between?(location.start_line, location.end_line)
167
+
168
+ @node = candidate.body
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ module Parser
5
+ # The ConditionTree is the parsed, logical representation of a callback's
6
+ # if/unless conditions. Every node is an immutable Data type.
7
+ module ConditionTree
8
+ # Base node carrying an arbitrary set of children. Retained for
9
+ # forward-compatibility with generic tree traversals.
10
+ Node = Data.define(:children)
11
+
12
+ # Logical combinators.
13
+ AndNode = Data.define(:children) # children: Array<Node>
14
+ OrNode = Data.define(:children) # children: Array<Node>
15
+ NotNode = Data.define(:child) # child: Node
16
+
17
+ # Leaf: a single predicate method call, e.g. "saved_change_to_title?".
18
+ PredicateNode = Data.define(:name) # name: String
19
+
20
+ # Leaf: a symbol condition such as :sync_required?.
21
+ # expanded_tree is the result of MethodResolver, nil in v0.1 / when unresolved.
22
+ MethodRefNode = Data.define(:name, :expanded_tree) # name: String, expanded_tree: Node | nil
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ # Rails integration hook. When the gem is loaded inside a Rails application,
5
+ # this Railtie registers the gem's Rake tasks so that
6
+ # +rake callback_lens:analyze MODEL=User+ works without any manual +require+.
7
+ #
8
+ # The file is only loaded when +Rails::Railtie+ is defined (guarded by the
9
+ # top-level entry point), so it is a no-op outside of Rails.
10
+ class Railtie < Rails::Railtie
11
+ rake_tasks do
12
+ load File.expand_path("tasks/callback_lens.rake", __dir__)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../graph/nodes"
4
+
5
+ module ActiverecordCallbackLens
6
+ module Renderer
7
+ # Renders a Graph::Graph as a Mermaid `graph TD` (top-down) diagram string.
8
+ #
9
+ # The output is a header line followed by one declaration per node and one
10
+ # arrow per edge:
11
+ #
12
+ # graph TD
13
+ # n0["before_save"]
14
+ # n1["active?"]
15
+ # n1 --> n0
16
+ #
17
+ # Node labels are derived from the node type (see #node_label) and are
18
+ # escaped so that quotes in a predicate or method name cannot break the
19
+ # surrounding Mermaid label syntax.
20
+ class MermaidRenderer
21
+ # @param graph [Graph::Graph]
22
+ # @return [String]
23
+ def self.render(graph)
24
+ new(graph).render
25
+ end
26
+
27
+ # @param graph [Graph::Graph]
28
+ def initialize(graph)
29
+ @graph = graph
30
+ end
31
+
32
+ # @return [String]
33
+ def render
34
+ lines = ["graph TD"]
35
+ lines.concat(node_declarations)
36
+ lines.concat(edge_declarations)
37
+ lines.join("\n")
38
+ end
39
+
40
+ private
41
+
42
+ # @return [Array<String>]
43
+ def node_declarations
44
+ @graph.nodes.map { |node| " #{node.id}[\"#{escape(node_label(node))}\"]" }
45
+ end
46
+
47
+ # @return [Array<String>]
48
+ def edge_declarations
49
+ @graph.edges.map { |edge| " #{edge.from_id} --> #{edge.to_id}" }
50
+ end
51
+
52
+ # Derives the human-readable label for a graph node from its type.
53
+ #
54
+ # @param node [Graph::CallbackNode, Graph::PredicateNode, Graph::MethodNode, Graph::ConditionNode]
55
+ # @return [String]
56
+ def node_label(node)
57
+ case node
58
+ when Graph::CallbackNode then "#{node.definition.phase}_#{node.definition.event}"
59
+ when Graph::PredicateNode then node.predicate_name
60
+ when Graph::MethodNode then node.method_name
61
+ when Graph::ConditionNode then node.tree_node.class.name.split("::").last
62
+ else node.id
63
+ end
64
+ end
65
+
66
+ # Escapes characters that would otherwise break a `["..."]` Mermaid label.
67
+ # Double quotes become the Mermaid HTML entity and the escape character
68
+ # itself is normalised so the label stays a single, valid token.
69
+ #
70
+ # @param label [String]
71
+ # @return [String]
72
+ def escape(label)
73
+ label.to_s.gsub("\"", "&quot;")
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "callback_lens_helpers"
4
+
5
+ namespace :callback_lens do
6
+ desc "Print callback Mermaid diagram for MODEL (e.g. rake callback_lens:analyze MODEL=User)"
7
+ task analyze: :environment do
8
+ model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
9
+ puts CallbackLensRakeHelpers.render_mermaid(model_class)
10
+ end
11
+
12
+ desc "Alias for callback_lens:analyze — print Mermaid diagram for MODEL"
13
+ task mermaid: :environment do
14
+ model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
15
+ puts CallbackLensRakeHelpers.render_mermaid(model_class)
16
+ end
17
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activerecord_callback_lens"
4
+
5
+ # Helpers backing the callback_lens Rake tasks. Extracted into a plain Ruby
6
+ # module (rather than task-local methods) so the logic is namespaced and unit
7
+ # testable without loading Rake or invoking a task.
8
+ module CallbackLensRakeHelpers
9
+ module_function
10
+
11
+ # Resolves an ActiveRecord model class from its name, raising a descriptive
12
+ # error when MODEL is missing or the constant cannot be found.
13
+ #
14
+ # @param name [String, nil] the value of the MODEL environment variable
15
+ # @return [Class]
16
+ # @raise [RuntimeError] when name is nil/empty or the class is not loaded
17
+ def resolve_model!(name)
18
+ raise "MODEL is required. Usage: rake callback_lens:analyze MODEL=User" if name.nil? || name.empty?
19
+
20
+ Object.const_get(name)
21
+ rescue NameError
22
+ raise "Cannot find model class '#{name}'. Make sure it is loaded."
23
+ end
24
+
25
+ # Runs the full pipeline (collect -> parse -> build -> render) for a model.
26
+ #
27
+ # @param model_class [Class]
28
+ # @return [String] the Mermaid diagram
29
+ def render_mermaid(model_class)
30
+ definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
31
+ definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
32
+ graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
33
+ ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "activerecord_callback_lens/version"
4
+ require "activerecord_callback_lens/collector/callback_definition"
5
+ require "activerecord_callback_lens/collector/callback_collector"
6
+ require "activerecord_callback_lens/parser/condition_tree"
7
+ require "activerecord_callback_lens/parser/condition_parser"
8
+ require "activerecord_callback_lens/graph/nodes"
9
+ require "activerecord_callback_lens/graph/graph_builder"
10
+ require "activerecord_callback_lens/renderer/mermaid_renderer"
11
+ require "activerecord_callback_lens/cli/cli"
12
+ require "activerecord_callback_lens/railtie" if defined?(Rails::Railtie)
13
+
14
+ # Top-level namespace for the gem. Subsequent tasks append their requires above.
15
+ module ActiverecordCallbackLens
16
+ end
metadata ADDED
@@ -0,0 +1,113 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: activerecord_callback_lens
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eraxel.Dev
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-05 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
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.0'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: prism
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.0'
47
+ - !ruby/object:Gem::Dependency
48
+ name: thor
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '1.0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ description: Inspect, parse, and visualize the callbacks registered on your ActiveRecord
62
+ models — including their if/unless conditions — as graphs and diagrams.
63
+ email:
64
+ - masacode.vancouver@gmail.com
65
+ executables:
66
+ - callback_lens
67
+ extensions: []
68
+ extra_rdoc_files: []
69
+ files:
70
+ - LICENSE
71
+ - README.md
72
+ - activerecord_callback_lens.gemspec
73
+ - exe/callback_lens
74
+ - lib/activerecord_callback_lens.rb
75
+ - lib/activerecord_callback_lens/cli/cli.rb
76
+ - lib/activerecord_callback_lens/collector/callback_collector.rb
77
+ - lib/activerecord_callback_lens/collector/callback_definition.rb
78
+ - lib/activerecord_callback_lens/graph/graph_builder.rb
79
+ - lib/activerecord_callback_lens/graph/nodes.rb
80
+ - lib/activerecord_callback_lens/parser/condition_parser.rb
81
+ - lib/activerecord_callback_lens/parser/condition_tree.rb
82
+ - lib/activerecord_callback_lens/railtie.rb
83
+ - lib/activerecord_callback_lens/renderer/mermaid_renderer.rb
84
+ - lib/activerecord_callback_lens/tasks/callback_lens.rake
85
+ - lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb
86
+ - lib/activerecord_callback_lens/version.rb
87
+ homepage: https://github.com/eraxel/activerecord_callback_lens
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/eraxel/activerecord_callback_lens
92
+ source_code_uri: https://github.com/eraxel/activerecord_callback_lens
93
+ rubygems_mfa_required: 'true'
94
+ post_install_message:
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '3.2'
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 3.4.19
110
+ signing_key:
111
+ specification_version: 4
112
+ summary: X-ray your ActiveRecord callbacks
113
+ test_files: []