activerecord_callback_lens 0.2.1 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32dbc737ca1362eab8b3a931bd7b2a6ccba9801be70b512d6e47e73d365634c1
4
- data.tar.gz: a8500d0321565cacec20b3145d315d804215ef616f8619407f5c0e72c4fb199d
3
+ metadata.gz: d449ee130b618f98ee114f459fbb178a5b7cded7465853e2b1eb5c2c39893061
4
+ data.tar.gz: 0b64c3820a229bb381b597faecc9936e29d3a053cb174beccbe557d1eb86f368
5
5
  SHA512:
6
- metadata.gz: f7b3ff8e64d67c4f875bc01dc9adbafe80786cabb5db56718805cb2503aa96acb06e38ac4df17041a9171c0a728a8c3acf7c4e3e766f38e66ac716b6bd29576d
7
- data.tar.gz: 59f152922559ae9811d339ba8e44bd7de7c3a9198a0c97cb397763e8ccb2bf442e58367d84d0bc61acd8250607d2b5f9fa60f6ca951f5ace8281a425a3fefc21
6
+ metadata.gz: eb0f92c15fb5e7c8ed1bcdd1a1bb25f2a70e2c9b7b898d88b6614369ea24a7aeb2c8627e37b192a404259b9fa3143582d27e8a45671fd8ce52b0b8e052db1e01
7
+ data.tar.gz: 1f01ab230f4ac8b50a604df94a33eb95c63e0f8bfdc1eaba1a4997bf069e0e4272365b556a5e5fffa6af687d1017d5b10bdd9937a8944e2d532005c37b7a0804
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **X-ray your ActiveRecord callbacks.**
4
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.
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, Graphviz DOT graph, or a self-contained HTML report so you can see exactly what runs and why.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -76,6 +76,60 @@ rake callback_lens:mermaid MODEL=User EXPAND=true
76
76
  Only the exact value `EXPAND=true` (case-insensitive) enables expansion; any
77
77
  other value (`EXPAND=1`, `EXPAND=yes`, empty, or absent) leaves it off.
78
78
 
79
+ #### Graphviz DOT output
80
+
81
+ Pass `--graphviz` (CLI) or use `callback_lens:graphviz` (Rake) to output a
82
+ [Graphviz DOT](https://graphviz.org/) diagram to stdout. Pipe it to `dot` to
83
+ generate an image:
84
+
85
+ ```bash
86
+ # CLI — print DOT to stdout
87
+ callback_lens analyze User --graphviz
88
+
89
+ # CLI — combine with Mermaid output
90
+ callback_lens analyze User --mermaid --graphviz
91
+
92
+ # CLI — pipe to dot for a PNG
93
+ callback_lens analyze User --graphviz | dot -Tpng -o callbacks.png
94
+
95
+ # Rake
96
+ rake callback_lens:graphviz MODEL=User
97
+ rake callback_lens:graphviz MODEL=User EXPAND=true
98
+ ```
99
+
100
+ Requires [Graphviz](https://graphviz.org/download/) only when piping to `dot`.
101
+
102
+ #### HTML report
103
+
104
+ Pass `--html FILE` (CLI) or use `callback_lens:html` (Rake) to generate a
105
+ self-contained HTML report. The file embeds a Mermaid diagram (via CDN), an
106
+ inline Graphviz SVG (when `dot` is installed), a callback list table, an
107
+ execution-order flow, and a nested dependency tree for every condition — no
108
+ external dependencies at view time.
109
+
110
+ ```bash
111
+ # CLI — write report to a file
112
+ callback_lens analyze User --html report.html
113
+
114
+ # CLI — combine with stdout output
115
+ callback_lens analyze User --mermaid --html report.html
116
+
117
+ # Rake — default output filename: callback_lens_report.html
118
+ rake callback_lens:html MODEL=User
119
+
120
+ # Rake — custom output path
121
+ rake callback_lens:html MODEL=User OUT=tmp/user_callbacks.html
122
+
123
+ # Rake — with recursive method expansion
124
+ rake callback_lens:html MODEL=User OUT=tmp/user_callbacks.html EXPAND=true
125
+ ```
126
+
127
+ | ENV variable | Required | Default | Description |
128
+ |---|---|---|---|
129
+ | `MODEL` | Yes | — | ActiveRecord model class name |
130
+ | `OUT` | No | `callback_lens_report.html` | Output path for the HTML file |
131
+ | `EXPAND` | No | `false` | Set to `true` to recursively expand method conditions |
132
+
79
133
  ### Programmatic API
80
134
 
81
135
  ```ruby
@@ -100,8 +154,12 @@ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
100
154
  |---|---|---|
101
155
  | Collector | `CallbackCollector` | Reads ActiveRecord's internal `_save_callbacks`, `_create_callbacks`, `_update_callbacks`, `_destroy_callbacks`, and `_validation_callbacks` chains |
102
156
  | 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 |
157
+ | Resolver | `MethodResolver` | Recursively expands `MethodRefNode` symbols into full `ConditionTree` sub-trees (up to 5 levels deep) with cycle detection |
103
158
  | Graph | `GraphBuilder` | Assembles a DAG of `CallbackNode`, `ConditionNode`, `PredicateNode`, and `MethodNode` values |
159
+ | Analyzer | `ExecutionOrderAnalyzer` | Sorts definitions into canonical Rails execution order (create or update path) |
104
160
  | Renderer | `MermaidRenderer` | Serializes the graph to a Mermaid `graph TD` string |
161
+ | Renderer | `GraphvizRenderer` | Serializes the graph to a Graphviz DOT string; `#to_svg` shells out to `dot` |
162
+ | Renderer | `HtmlRenderer` | Produces a self-contained HTML report embedding Mermaid, SVG, callback table, execution flow, and dependency tree |
105
163
 
106
164
  ## Condition tree nodes
107
165
 
@@ -118,9 +176,9 @@ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
118
176
  | Version | Feature |
119
177
  |---|---|
120
178
  | v0.1 | CallbackCollector, Prism condition parser, Mermaid renderer, CLI, Rake task |
121
- | **v0.2** | MethodResolver — recursive expansion of Symbol conditions (`--expand` / `EXPAND=true`) |
122
- | v0.3 | Graphviz / DOT renderer |
123
- | v0.4 | HTML report (callback list, execution flow, embedded diagram) |
179
+ | v0.2 | MethodResolver — recursive expansion of Symbol conditions (`--expand` / `EXPAND=true`) |
180
+ | v0.3 | Graphviz / DOT renderer (`--graphviz` / `callback_lens:graphviz`) |
181
+ | **v0.4** | HTML report callback list table, execution-order flow, dependency tree, embedded Mermaid and Graphviz SVG (`--html` / `callback_lens:html`) |
124
182
  | v1.0 | Runtime tracer via `ActiveSupport::Notifications` |
125
183
  | v2.0 | RBS analysis, cross-model dependency graph |
126
184
 
@@ -7,15 +7,20 @@ require_relative "../parser/condition_parser"
7
7
  require_relative "../resolver/method_resolver"
8
8
  require_relative "../graph/graph_builder"
9
9
  require_relative "../renderer/mermaid_renderer"
10
+ require_relative "../renderer/graphviz_renderer"
11
+ require_relative "../renderer/html_renderer"
10
12
 
11
13
  module ActiverecordCallbackLens
12
14
  module CLI
13
15
  # Thor application exposing the callback_lens command-line interface.
14
16
  #
15
- # For v0.1 it provides a single command, +analyze+, which runs the full
16
- # pipeline (collect -> parse -> build graph -> render) and prints a Mermaid
17
- # diagram to stdout. An unknown model name is reported with a friendly
18
- # message and a non-zero exit status rather than a Ruby backtrace.
17
+ # It provides a single command, +analyze+, which runs the full pipeline
18
+ # (collect -> parse -> build graph -> render) and prints the result to
19
+ # stdout. Output format is selectable per invocation: a Mermaid diagram
20
+ # (+--mermaid+, on by default) and/or a Graphviz DOT graph (+--graphviz+);
21
+ # the two flags are independent and may be combined. An unknown model name
22
+ # is reported with a friendly message and a non-zero exit status rather than
23
+ # a Ruby backtrace.
19
24
  class App < Thor
20
25
  # Tells Thor to exit with a non-zero status when a command raises, so the
21
26
  # +exit 1+ paths below propagate a failure code to the shell.
@@ -25,22 +30,41 @@ module ActiverecordCallbackLens
25
30
  true
26
31
  end
27
32
 
28
- desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid diagram"
33
+ desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid and/or Graphviz diagram"
29
34
  option :mermaid, type: :boolean, default: true, desc: "Output a Mermaid diagram to stdout"
35
+ option :graphviz, type: :boolean, default: false,
36
+ desc: "Output DOT graph via Graphviz to stdout"
30
37
  option :expand, type: :boolean, default: false,
31
38
  desc: "Expand method conditions recursively (up to depth 5)"
39
+ option :html, type: :string, desc: "Write HTML report to FILE"
32
40
  # Runs the analysis pipeline for +model_name+ and prints the result.
33
41
  #
42
+ # When +--html FILE+ is given, a self-contained HTML report is written to
43
+ # FILE (in addition to any stdout output selected by the other flags) and a
44
+ # confirmation line is printed.
45
+ #
34
46
  # @param model_name [String] the ActiveRecord model class name
35
47
  # @return [void]
36
48
  def analyze(model_name)
37
49
  model_class = resolve_model(model_name)
38
- graph = build_graph(model_class, expand: options[:expand])
39
- puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
50
+ definitions, graph = build_pipeline(model_class, expand: options[:expand])
51
+ render_outputs(graph, definitions)
40
52
  end
41
53
 
42
54
  private
43
55
 
56
+ # Emits each output selected by the analyze options: Mermaid and/or Graphviz
57
+ # to stdout, and an HTML report to a file when +--html+ is given.
58
+ #
59
+ # @param graph [Graph::Graph]
60
+ # @param definitions [Array<Collector::CallbackDefinition>]
61
+ # @return [void]
62
+ def render_outputs(graph, definitions)
63
+ puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
64
+ puts Renderer::GraphvizRenderer.render(graph) if options[:graphviz]
65
+ write_html(graph, definitions, options[:html]) if options[:html]
66
+ end
67
+
44
68
  # Resolves a model class by name, printing a friendly error and exiting
45
69
  # non-zero when the constant cannot be found.
46
70
  #
@@ -60,16 +84,32 @@ module ActiverecordCallbackLens
60
84
  # MethodRefNodes resolved into ConditionTree sub-trees via MethodResolver.
61
85
  # When false, the pipeline is identical to v0.1 output.
62
86
  #
87
+ # Returns both the parsed definitions and the assembled graph so renderers
88
+ # that need the definitions directly (e.g. the HTML report) can access them
89
+ # without re-running the pipeline.
90
+ #
63
91
  # @param model_class [Class]
64
92
  # @param expand [Boolean]
65
- # @return [Graph::Graph]
66
- def build_graph(model_class, expand: false)
93
+ # @return [Array(Array<Collector::CallbackDefinition>, Graph::Graph)]
94
+ def build_pipeline(model_class, expand: false)
67
95
  definitions = Collector::CallbackCollector.collect(model_class)
68
96
  definitions = definitions.map { |definition| Parser::ConditionParser.parse(definition) }
69
97
  if expand
70
98
  definitions = definitions.map { |definition| Resolver::MethodResolver.expand(definition, model_class) }
71
99
  end
72
- Graph::GraphBuilder.build(definitions)
100
+ [definitions, Graph::GraphBuilder.build(definitions)]
101
+ end
102
+
103
+ # Writes the HTML report to +path+ and prints a confirmation line.
104
+ #
105
+ # @param graph [Graph::Graph]
106
+ # @param definitions [Array<Collector::CallbackDefinition>]
107
+ # @param path [String]
108
+ # @return [void]
109
+ def write_html(graph, definitions, path)
110
+ html = Renderer::HtmlRenderer.render(graph, definitions: definitions)
111
+ File.write(path, html)
112
+ puts "HTML report written to #{path}"
73
113
  end
74
114
  end
75
115
  end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiverecordCallbackLens
4
+ # Sorts a list of Collector::CallbackDefinition objects into the canonical
5
+ # order in which Rails executes callbacks on a record save.
6
+ #
7
+ # Two orderings are supported, selected via the +operation+ keyword:
8
+ #
9
+ # * +:create+ (the default) follows the create path
10
+ # (before_create / after_create around the INSERT).
11
+ # * +:update+ follows the update path
12
+ # (before_update / after_update around the UPDATE).
13
+ #
14
+ # A definition's position is derived from its +phase_event+ pair (for example
15
+ # +before_save+). Callbacks whose pair is not part of the canonical order are
16
+ # placed at the end (sort index 999) while preserving their relative order,
17
+ # because Ruby's Array#sort_by is stable for equal keys.
18
+ class ExecutionOrderAnalyzer
19
+ # Canonical create-path order. Mirrors spec section 7.1 (create path).
20
+ SAVE_ORDER = %i[
21
+ before_validation after_validation
22
+ before_save
23
+ before_create
24
+ after_create
25
+ after_save
26
+ after_commit
27
+ ].freeze
28
+
29
+ # Canonical update-path order. Mirrors spec section 7.1 (update path).
30
+ UPDATE_ORDER = %i[
31
+ before_validation after_validation
32
+ before_save
33
+ before_update
34
+ after_update
35
+ after_save
36
+ after_commit
37
+ ].freeze
38
+
39
+ # Sort index assigned to callbacks whose phase_event pair is not part of the
40
+ # canonical order, so they sort after every recognised callback.
41
+ UNRECOGNISED_INDEX = 999
42
+
43
+ # Sorts +definitions+ into canonical execution order.
44
+ #
45
+ # @param definitions [Array<Collector::CallbackDefinition>]
46
+ # @param operation [Symbol] :create (default) or :update
47
+ # @return [Array<Collector::CallbackDefinition>] a new sorted array
48
+ def self.sort(definitions, operation: :create)
49
+ order = operation == :update ? UPDATE_ORDER : SAVE_ORDER
50
+ definitions.sort_by do |definition|
51
+ key = :"#{definition.phase}_#{definition.event}"
52
+ order.index(key) || UNRECOGNISED_INDEX
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,95 @@
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 Graphviz DOT language string.
8
+ #
9
+ # The output is a `digraph` block with a left-to-right rank direction, one
10
+ # declaration per node and one arrow per edge:
11
+ #
12
+ # digraph callback_lens {
13
+ # rankdir=LR;
14
+ # n0 [label="before_save"];
15
+ # n1 [label="active?"];
16
+ # n1 -> n0;
17
+ # }
18
+ #
19
+ # Node labels are derived from the node type (see #node_label) using the same
20
+ # four-case logic as MermaidRenderer. Double quotes in a label are escaped as
21
+ # `\"` so they cannot break the surrounding DOT label syntax.
22
+ class GraphvizRenderer
23
+ # @param graph [Graph::Graph]
24
+ # @return [String] DOT language string
25
+ def self.render(graph)
26
+ new(graph).render
27
+ end
28
+
29
+ # @param graph [Graph::Graph]
30
+ def initialize(graph)
31
+ @graph = graph
32
+ end
33
+
34
+ # @return [String] DOT language string
35
+ def render
36
+ lines = ["digraph callback_lens {", " rankdir=LR;"]
37
+ lines.concat(node_declarations)
38
+ lines.concat(edge_declarations)
39
+ lines << "}"
40
+ lines.join("\n")
41
+ end
42
+
43
+ # Shells out to the `dot` binary and returns the rendered SVG string.
44
+ # Returns nil when Graphviz is not installed (the `dot` binary is absent
45
+ # from PATH) rather than raising.
46
+ #
47
+ # @return [String, nil]
48
+ def to_svg
49
+ IO.popen(["dot", "-Tsvg"], "r+") do |io|
50
+ io.write(render)
51
+ io.close_write
52
+ io.read
53
+ end
54
+ rescue Errno::ENOENT
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ # @return [Array<String>]
61
+ def node_declarations
62
+ @graph.nodes.map { |node| " #{node.id} [label=\"#{escape(node_label(node))}\"];" }
63
+ end
64
+
65
+ # @return [Array<String>]
66
+ def edge_declarations
67
+ @graph.edges.map { |edge| " #{edge.from_id} -> #{edge.to_id};" }
68
+ end
69
+
70
+ # Derives the human-readable label for a graph node from its type.
71
+ #
72
+ # @param node [Graph::CallbackNode, Graph::PredicateNode, Graph::MethodNode, Graph::ConditionNode]
73
+ # @return [String]
74
+ def node_label(node)
75
+ case node
76
+ when Graph::CallbackNode then "#{node.definition.phase}_#{node.definition.event}"
77
+ when Graph::PredicateNode then node.predicate_name
78
+ when Graph::MethodNode then node.method_name
79
+ when Graph::ConditionNode then node.tree_node.class.name.split("::").last
80
+ else node.id
81
+ end
82
+ end
83
+
84
+ # Escapes characters that would otherwise break a `label="..."` DOT label.
85
+ # Double quotes become a backslash-escaped quote so the label stays a
86
+ # single, valid DOT token. (DOT uses `\"`, unlike Mermaid's `&quot;`.)
87
+ #
88
+ # @param label [String]
89
+ # @return [String]
90
+ def escape(label)
91
+ label.to_s.gsub("\"", "\\\"")
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ require_relative "mermaid_renderer"
6
+ require_relative "graphviz_renderer"
7
+ require_relative "../execution_order_analyzer"
8
+ require_relative "../parser/condition_tree"
9
+
10
+ module ActiverecordCallbackLens
11
+ module Renderer
12
+ # Renders a single ConditionTree node (and its descendants) as nested +<ul>+
13
+ # markup, and exposes the flat list of leaf condition names. Extracted from
14
+ # HtmlRenderer so the recursive tree handling is a single, testable
15
+ # responsibility separate from page assembly.
16
+ #
17
+ # All names are HTML escaped via CGI.escapeHTML so values containing +<+,
18
+ # +>+ or +&+ cannot break the surrounding markup.
19
+ class ConditionTreeHtml
20
+ Tree = Parser::ConditionTree
21
+
22
+ # @param node [Parser::ConditionTree::Node]
23
+ def initialize(node)
24
+ @node = node
25
+ end
26
+
27
+ # @return [String] nested <ul> markup for the node
28
+ def to_html
29
+ render(@node)
30
+ end
31
+
32
+ # @return [Array<String>] leaf names (predicate + method refs), depth-first
33
+ def names
34
+ leaf_names(@node)
35
+ end
36
+
37
+ private
38
+
39
+ def render(node)
40
+ case node
41
+ when Tree::AndNode then combinator("AND", node.children)
42
+ when Tree::OrNode then combinator("OR", node.children)
43
+ when Tree::NotNode then combinator("NOT", [node.child])
44
+ when Tree::PredicateNode then "<ul><li>#{escape(node.name)}</li></ul>"
45
+ when Tree::MethodRefNode then method_ref(node)
46
+ else ""
47
+ end
48
+ end
49
+
50
+ def combinator(label, children)
51
+ items = children.map { |child| "<li>#{render(child)}</li>" }
52
+ "<ul><li>#{escape(label)}</li>#{items.join}</ul>"
53
+ end
54
+
55
+ def method_ref(node)
56
+ inner = node.expanded_tree.nil? ? "" : render(node.expanded_tree)
57
+ "<ul><li>#{escape(node.name)}#{inner}</li></ul>"
58
+ end
59
+
60
+ def leaf_names(node)
61
+ case node
62
+ when Tree::AndNode, Tree::OrNode
63
+ node.children.flat_map { |child| leaf_names(child) }
64
+ when Tree::NotNode
65
+ leaf_names(node.child)
66
+ when Tree::PredicateNode, Tree::MethodRefNode
67
+ [node.name]
68
+ else
69
+ []
70
+ end
71
+ end
72
+
73
+ def escape(value)
74
+ CGI.escapeHTML(value.to_s)
75
+ end
76
+ end
77
+
78
+ # Renders a self-contained HTML report for a model's callbacks.
79
+ #
80
+ # The document embeds five sections (spec section 8.3):
81
+ #
82
+ # 1. Callback List — a table with columns Phase, Event, Filter, Conditions.
83
+ # 2. Execution Flow — an ordered list sorted by ExecutionOrderAnalyzer
84
+ # (the +:create+ path).
85
+ # 3. Dependency Tree — a nested +<ul>+ built from each definition's
86
+ # ConditionTree (via ConditionTreeHtml).
87
+ # 4. Mermaid Diagram — a +<pre class="mermaid">+ block populated by
88
+ # MermaidRenderer and rendered client-side via the Mermaid CDN script.
89
+ # 5. Graphviz SVG — the inline +<svg>+ produced by GraphvizRenderer#to_svg
90
+ # when the +dot+ binary is available; the section is omitted otherwise.
91
+ #
92
+ # All user-derived text is HTML escaped with CGI.escapeHTML.
93
+ class HtmlRenderer
94
+ # The Mermaid.js bundle loaded from a CDN so the report stays a single,
95
+ # dependency-free HTML file.
96
+ MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"
97
+
98
+ # @param graph [Graph::Graph] the assembled dependency graph
99
+ # @param definitions [Array<Collector::CallbackDefinition>] parsed definitions
100
+ # @return [String] a complete HTML document
101
+ def self.render(graph, definitions:)
102
+ new(graph, definitions).render
103
+ end
104
+
105
+ # @param graph [Graph::Graph]
106
+ # @param definitions [Array<Collector::CallbackDefinition>]
107
+ def initialize(graph, definitions)
108
+ @graph = graph
109
+ @definitions = definitions
110
+ end
111
+
112
+ # Assembles the full HTML document.
113
+ #
114
+ # @return [String]
115
+ def render
116
+ <<~HTML
117
+ <!DOCTYPE html>
118
+ <html>
119
+ <head><meta charset="utf-8"><title>Callback Lens</title></head>
120
+ <body>
121
+ #{callback_table}
122
+ #{execution_flow}
123
+ #{dependency_trees}
124
+ #{mermaid_section}
125
+ #{svg_section}
126
+ <script src="#{MERMAID_CDN}"></script>
127
+ <script>mermaid.initialize({startOnLoad:true});</script>
128
+ </body>
129
+ </html>
130
+ HTML
131
+ end
132
+
133
+ private
134
+
135
+ # Section 1: a table with one row per definition.
136
+ def callback_table
137
+ rows = @definitions.map do |definition|
138
+ "<tr>" \
139
+ "<td>#{escape(definition.phase)}</td>" \
140
+ "<td>#{escape(definition.event)}</td>" \
141
+ "<td>#{escape(filter_label(definition))}</td>" \
142
+ "<td>#{escape(conditions_label(definition))}</td>" \
143
+ "</tr>"
144
+ end
145
+ <<~HTML.chomp
146
+ <h2>Callback List</h2>
147
+ <table>
148
+ <thead><tr><th>Phase</th><th>Event</th><th>Filter</th><th>Conditions</th></tr></thead>
149
+ <tbody>
150
+ #{rows.join("\n")}
151
+ </tbody>
152
+ </table>
153
+ HTML
154
+ end
155
+
156
+ # Section 2: callbacks in canonical create-path execution order.
157
+ def execution_flow
158
+ ordered = ExecutionOrderAnalyzer.sort(@definitions, operation: :create)
159
+ items = ordered.map { |d| "<li>#{escape("#{d.phase}_#{d.event}")}</li>" }
160
+ <<~HTML.chomp
161
+ <h2>Execution Flow</h2>
162
+ <ol>
163
+ #{items.join("\n")}
164
+ </ol>
165
+ HTML
166
+ end
167
+
168
+ # Section 3: a nested dependency tree per definition that carries a
169
+ # condition_tree. Definitions without conditions are skipped.
170
+ def dependency_trees
171
+ trees = @definitions.filter_map do |definition|
172
+ next if definition.condition_tree.nil?
173
+
174
+ "<li>#{escape("#{definition.phase}_#{definition.event}")}" \
175
+ "#{ConditionTreeHtml.new(definition.condition_tree).to_html}</li>"
176
+ end
177
+ <<~HTML.chomp
178
+ <h2>Dependency Tree</h2>
179
+ <ul>
180
+ #{trees.join("\n")}
181
+ </ul>
182
+ HTML
183
+ end
184
+
185
+ # Section 4: the Mermaid source wrapped in a client-rendered block.
186
+ def mermaid_section
187
+ <<~HTML.chomp
188
+ <h2>Mermaid Diagram</h2>
189
+ <pre class="mermaid">#{escape(MermaidRenderer.render(@graph))}</pre>
190
+ HTML
191
+ end
192
+
193
+ # Section 5: the inline Graphviz SVG, or an empty string when +dot+ is not
194
+ # installed (GraphvizRenderer#to_svg returns nil).
195
+ def svg_section
196
+ svg = GraphvizRenderer.new(@graph).to_svg
197
+ return "" if svg.nil?
198
+
199
+ <<~HTML.chomp
200
+ <h2>Graphviz SVG</h2>
201
+ <div class="graphviz">#{svg}</div>
202
+ HTML
203
+ end
204
+
205
+ # A human-readable label for a definition's filter (Symbol, String, or Proc).
206
+ def filter_label(definition)
207
+ filter = definition.filter
208
+ filter.is_a?(Proc) ? "(proc)" : filter.to_s
209
+ end
210
+
211
+ # A comma-joined label of a definition's condition leaf names, or "".
212
+ def conditions_label(definition)
213
+ tree = definition.condition_tree
214
+ return "" if tree.nil?
215
+
216
+ ConditionTreeHtml.new(tree).names.join(", ")
217
+ end
218
+
219
+ # HTML-escapes a value so it is safe to embed as element text.
220
+ def escape(value)
221
+ CGI.escapeHTML(value.to_s)
222
+ end
223
+ end
224
+ end
225
+ end
@@ -17,4 +17,22 @@ namespace :callback_lens do
17
17
  expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
18
18
  puts CallbackLensRakeHelpers.render_mermaid(model_class, expand: expand)
19
19
  end
20
+
21
+ desc "Write Graphviz DOT to STDOUT for MODEL " \
22
+ "(e.g. rake callback_lens:graphviz MODEL=User EXPAND=true)"
23
+ task graphviz: :environment do
24
+ model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
25
+ expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
26
+ puts CallbackLensRakeHelpers.render_graphviz(model_class, expand: expand)
27
+ end
28
+
29
+ desc "Write a self-contained HTML report to OUT for MODEL " \
30
+ "(e.g. rake callback_lens:html MODEL=User OUT=report.html EXPAND=true)"
31
+ task html: :environment do
32
+ model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
33
+ expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
34
+ out = ENV.fetch("OUT", "callback_lens_report.html")
35
+ File.write(out, CallbackLensRakeHelpers.render_html(model_class, expand: expand))
36
+ puts "Report written to #{out}"
37
+ end
20
38
  end
@@ -46,6 +46,48 @@ module CallbackLensRakeHelpers
46
46
  ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
47
47
  end
48
48
 
49
+ # Runs the full pipeline (collect -> parse -> [expand] -> build -> render) for
50
+ # a model and returns a Graphviz DOT language string. Mirrors +render_mermaid+
51
+ # but uses the GraphvizRenderer; +expand+ is threaded through identically so
52
+ # the +EXPAND=true+ rake convention applies to the graphviz task too.
53
+ #
54
+ # @param model_class [Class]
55
+ # @param expand [Boolean]
56
+ # @return [String] DOT language string
57
+ def render_graphviz(model_class, expand: false)
58
+ definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
59
+ definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
60
+ if expand
61
+ definitions = definitions.map do |definition|
62
+ ActiverecordCallbackLens::Resolver::MethodResolver.expand(definition, model_class)
63
+ end
64
+ end
65
+ graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
66
+ ActiverecordCallbackLens::Renderer::GraphvizRenderer.render(graph)
67
+ end
68
+
69
+ # Runs the full pipeline (collect -> parse -> [expand] -> build) for a model and
70
+ # returns a self-contained HTML report via HtmlRenderer. Mirrors
71
+ # +render_mermaid+ / +render_graphviz+ but passes both the parsed definitions
72
+ # and the assembled graph to the HtmlRenderer, which needs the definitions to
73
+ # build the callback table, execution flow, and dependency tree. +expand+ is
74
+ # threaded through identically so the +EXPAND=true+ rake convention applies.
75
+ #
76
+ # @param model_class [Class]
77
+ # @param expand [Boolean]
78
+ # @return [String] a complete HTML document
79
+ def render_html(model_class, expand: false)
80
+ definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
81
+ definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
82
+ if expand
83
+ definitions = definitions.map do |definition|
84
+ ActiverecordCallbackLens::Resolver::MethodResolver.expand(definition, model_class)
85
+ end
86
+ end
87
+ graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
88
+ ActiverecordCallbackLens::Renderer::HtmlRenderer.render(graph, definitions: definitions)
89
+ end
90
+
49
91
  # Parses the EXPAND environment variable using the strict truthy rule: only the
50
92
  # exact string "true" (case-insensitive, surrounding whitespace stripped)
51
93
  # enables expansion. Any other value ("1", "yes", "", nil) leaves it off.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiverecordCallbackLens
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -9,7 +9,10 @@ require "activerecord_callback_lens/parser/condition_parser"
9
9
  require "activerecord_callback_lens/resolver/method_resolver"
10
10
  require "activerecord_callback_lens/graph/nodes"
11
11
  require "activerecord_callback_lens/graph/graph_builder"
12
+ require "activerecord_callback_lens/execution_order_analyzer"
12
13
  require "activerecord_callback_lens/renderer/mermaid_renderer"
14
+ require "activerecord_callback_lens/renderer/graphviz_renderer"
15
+ require "activerecord_callback_lens/renderer/html_renderer"
13
16
  require "activerecord_callback_lens/cli/cli"
14
17
  require "activerecord_callback_lens/railtie" if defined?(Rails::Railtie)
15
18
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord_callback_lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eraxel.Dev
@@ -75,12 +75,15 @@ files:
75
75
  - lib/activerecord_callback_lens/cli/cli.rb
76
76
  - lib/activerecord_callback_lens/collector/callback_collector.rb
77
77
  - lib/activerecord_callback_lens/collector/callback_definition.rb
78
+ - lib/activerecord_callback_lens/execution_order_analyzer.rb
78
79
  - lib/activerecord_callback_lens/graph/graph_builder.rb
79
80
  - lib/activerecord_callback_lens/graph/nodes.rb
80
81
  - lib/activerecord_callback_lens/parser/ast_walker.rb
81
82
  - lib/activerecord_callback_lens/parser/condition_parser.rb
82
83
  - lib/activerecord_callback_lens/parser/condition_tree.rb
83
84
  - lib/activerecord_callback_lens/railtie.rb
85
+ - lib/activerecord_callback_lens/renderer/graphviz_renderer.rb
86
+ - lib/activerecord_callback_lens/renderer/html_renderer.rb
84
87
  - lib/activerecord_callback_lens/renderer/mermaid_renderer.rb
85
88
  - lib/activerecord_callback_lens/resolver/method_resolver.rb
86
89
  - lib/activerecord_callback_lens/tasks/callback_lens.rake