activerecord_callback_lens 0.1.0 → 0.2.1
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 +4 -4
- data/README.md +21 -2
- data/activerecord_callback_lens.gemspec +2 -2
- data/lib/activerecord_callback_lens/cli/cli.rb +15 -3
- data/lib/activerecord_callback_lens/graph/graph_builder.rb +6 -0
- data/lib/activerecord_callback_lens/parser/ast_walker.rb +59 -0
- data/lib/activerecord_callback_lens/parser/condition_parser.rb +3 -30
- data/lib/activerecord_callback_lens/resolver/method_resolver.rb +250 -0
- data/lib/activerecord_callback_lens/tasks/callback_lens.rake +6 -3
- data/lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb +25 -2
- data/lib/activerecord_callback_lens/version.rb +1 -1
- data/lib/activerecord_callback_lens.rb +2 -0
- metadata +7 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 32dbc737ca1362eab8b3a931bd7b2a6ccba9801be70b512d6e47e73d365634c1
|
|
4
|
+
data.tar.gz: a8500d0321565cacec20b3145d315d804215ef616f8619407f5c0e72c4fb199d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f7b3ff8e64d67c4f875bc01dc9adbafe80786cabb5db56718805cb2503aa96acb06e38ac4df17041a9171c0a728a8c3acf7c4e3e766f38e66ac716b6bd29576d
|
|
7
|
+
data.tar.gz: 59f152922559ae9811d339ba8e44bd7de7c3a9198a0c97cb397763e8ccb2bf442e58367d84d0bc61acd8250607d2b5f9fa60f6ca951f5ace8281a425a3fefc21
|
data/README.md
CHANGED
|
@@ -57,6 +57,25 @@ rake callback_lens:analyze MODEL=User
|
|
|
57
57
|
rake callback_lens:mermaid MODEL=User # alias
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
#### Recursive method expansion
|
|
61
|
+
|
|
62
|
+
Pass `--expand` (CLI) or `EXPAND=true` (Rake) to recursively resolve symbol
|
|
63
|
+
callback conditions into their `ConditionTree` representations (up to 5
|
|
64
|
+
levels deep). Cycles and unresolvable methods are left unexpanded with a
|
|
65
|
+
warning printed to stderr.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# CLI
|
|
69
|
+
callback_lens analyze User --expand --mermaid
|
|
70
|
+
|
|
71
|
+
# Rake
|
|
72
|
+
rake callback_lens:analyze MODEL=User EXPAND=true
|
|
73
|
+
rake callback_lens:mermaid MODEL=User EXPAND=true
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Only the exact value `EXPAND=true` (case-insensitive) enables expansion; any
|
|
77
|
+
other value (`EXPAND=1`, `EXPAND=yes`, empty, or absent) leaves it off.
|
|
78
|
+
|
|
60
79
|
### Programmatic API
|
|
61
80
|
|
|
62
81
|
```ruby
|
|
@@ -98,8 +117,8 @@ puts ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
|
98
117
|
|
|
99
118
|
| Version | Feature |
|
|
100
119
|
|---|---|
|
|
101
|
-
|
|
|
102
|
-
| v0.2 | MethodResolver — recursive expansion of Symbol conditions |
|
|
120
|
+
| v0.1 | CallbackCollector, Prism condition parser, Mermaid renderer, CLI, Rake task |
|
|
121
|
+
| **v0.2** | MethodResolver — recursive expansion of Symbol conditions (`--expand` / `EXPAND=true`) |
|
|
103
122
|
| v0.3 | Graphviz / DOT renderer |
|
|
104
123
|
| v0.4 | HTML report (callback list, execution flow, embedded diagram) |
|
|
105
124
|
| v1.0 | Runtime tracer via `ActiveSupport::Notifications` |
|
|
@@ -10,8 +10,8 @@ Gem::Specification.new do |s|
|
|
|
10
10
|
"ActiveRecord models — including their if/unless conditions — as " \
|
|
11
11
|
"graphs and diagrams."
|
|
12
12
|
s.authors = ["Eraxel.Dev"]
|
|
13
|
-
s.email = ["
|
|
14
|
-
s.homepage = "https://github.com/eraxel/activerecord_callback_lens"
|
|
13
|
+
s.email = ["eraxel.dev@gmail.com"]
|
|
14
|
+
s.homepage = "https://github.com/eraxel-dev/activerecord_callback_lens"
|
|
15
15
|
s.license = "MIT"
|
|
16
16
|
|
|
17
17
|
s.required_ruby_version = ">= 3.2"
|
|
@@ -4,6 +4,7 @@ require "thor"
|
|
|
4
4
|
|
|
5
5
|
require_relative "../collector/callback_collector"
|
|
6
6
|
require_relative "../parser/condition_parser"
|
|
7
|
+
require_relative "../resolver/method_resolver"
|
|
7
8
|
require_relative "../graph/graph_builder"
|
|
8
9
|
require_relative "../renderer/mermaid_renderer"
|
|
9
10
|
|
|
@@ -26,13 +27,15 @@ module ActiverecordCallbackLens
|
|
|
26
27
|
|
|
27
28
|
desc "analyze MODEL", "Analyze callbacks for a model class and print a Mermaid diagram"
|
|
28
29
|
option :mermaid, type: :boolean, default: true, desc: "Output a Mermaid diagram to stdout"
|
|
30
|
+
option :expand, type: :boolean, default: false,
|
|
31
|
+
desc: "Expand method conditions recursively (up to depth 5)"
|
|
29
32
|
# Runs the analysis pipeline for +model_name+ and prints the result.
|
|
30
33
|
#
|
|
31
34
|
# @param model_name [String] the ActiveRecord model class name
|
|
32
35
|
# @return [void]
|
|
33
36
|
def analyze(model_name)
|
|
34
37
|
model_class = resolve_model(model_name)
|
|
35
|
-
graph = build_graph(model_class)
|
|
38
|
+
graph = build_graph(model_class, expand: options[:expand])
|
|
36
39
|
puts Renderer::MermaidRenderer.render(graph) if options[:mermaid]
|
|
37
40
|
end
|
|
38
41
|
|
|
@@ -50,13 +53,22 @@ module ActiverecordCallbackLens
|
|
|
50
53
|
exit 1
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
# Collect -> Parse -> Build the dependency graph for
|
|
56
|
+
# Collect -> Parse -> (optionally Expand) -> Build the dependency graph for
|
|
57
|
+
# a model class.
|
|
58
|
+
#
|
|
59
|
+
# When +expand+ is true, every parsed definition's condition_tree has its
|
|
60
|
+
# MethodRefNodes resolved into ConditionTree sub-trees via MethodResolver.
|
|
61
|
+
# When false, the pipeline is identical to v0.1 output.
|
|
54
62
|
#
|
|
55
63
|
# @param model_class [Class]
|
|
64
|
+
# @param expand [Boolean]
|
|
56
65
|
# @return [Graph::Graph]
|
|
57
|
-
def build_graph(model_class)
|
|
66
|
+
def build_graph(model_class, expand: false)
|
|
58
67
|
definitions = Collector::CallbackCollector.collect(model_class)
|
|
59
68
|
definitions = definitions.map { |definition| Parser::ConditionParser.parse(definition) }
|
|
69
|
+
if expand
|
|
70
|
+
definitions = definitions.map { |definition| Resolver::MethodResolver.expand(definition, model_class) }
|
|
71
|
+
end
|
|
60
72
|
Graph::GraphBuilder.build(definitions)
|
|
61
73
|
end
|
|
62
74
|
end
|
|
@@ -89,6 +89,12 @@ module ActiverecordCallbackLens
|
|
|
89
89
|
tree_node.children.each { |child| add_tree(child, parent_id: condition_node.id) }
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
+
# The +expanded_tree+ inspected here is populated upstream by
|
|
93
|
+
# Resolver::MethodResolver.expand; the GraphBuilder neither resolves nor
|
|
94
|
+
# mutates it. When the tree was not run through expand (the v0.1 path),
|
|
95
|
+
# +expanded_tree+ is nil and the MethodNode stays a leaf, so unexpanded
|
|
96
|
+
# output is byte-for-byte identical to v0.1.
|
|
97
|
+
#
|
|
92
98
|
# @param tree_node [Parser::ConditionTree::MethodRefNode]
|
|
93
99
|
# @param parent_id [String]
|
|
94
100
|
# @return [void]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "condition_tree"
|
|
6
|
+
|
|
7
|
+
module ActiverecordCallbackLens
|
|
8
|
+
module Parser
|
|
9
|
+
# Maps a Prism boolean AST (`&&`, `||`, `!`, predicate calls) onto the
|
|
10
|
+
# ConditionTree node types. Shared by ConditionParser (lambda bodies) and
|
|
11
|
+
# MethodResolver (method bodies) so the two stay in lockstep.
|
|
12
|
+
#
|
|
13
|
+
# The walker recognises the same boolean vocabulary the v0.1 ConditionParser
|
|
14
|
+
# established:
|
|
15
|
+
# - StatementsNode -> the last statement is the effective return value
|
|
16
|
+
# - ParenthesesNode -> transparent; walk the inner body
|
|
17
|
+
# - AndNode/OrNode -> AndNode/OrNode combinators (nil operands dropped)
|
|
18
|
+
# - CallNode `!` -> NotNode wrapping the negated receiver
|
|
19
|
+
# - CallNode -> PredicateNode for the called method name
|
|
20
|
+
# - anything else -> nil (unresolvable)
|
|
21
|
+
module AstWalker
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Recursively maps a Prism boolean AST onto ConditionTree nodes.
|
|
25
|
+
#
|
|
26
|
+
# @param node [Prism::Node, nil]
|
|
27
|
+
# @return [ConditionTree::Node, nil]
|
|
28
|
+
def walk(node)
|
|
29
|
+
case node
|
|
30
|
+
in Prism::StatementsNode then walk(node.body.last)
|
|
31
|
+
in Prism::ParenthesesNode then walk(node.body)
|
|
32
|
+
in Prism::AndNode then combinator(ConditionTree::AndNode, node)
|
|
33
|
+
in Prism::OrNode then combinator(ConditionTree::OrNode, node)
|
|
34
|
+
in Prism::CallNode if node.name == :! then negate(walk(node.receiver))
|
|
35
|
+
in Prism::CallNode then ConditionTree::PredicateNode.new(name: node.name.to_s)
|
|
36
|
+
else nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Builds an And/Or combinator from a binary Prism node, walking both sides
|
|
41
|
+
# and dropping any unresolved (nil) operand.
|
|
42
|
+
#
|
|
43
|
+
# @param klass [Class] ConditionTree::AndNode or ConditionTree::OrNode
|
|
44
|
+
# @param node [Prism::AndNode, Prism::OrNode]
|
|
45
|
+
# @return [ConditionTree::Node]
|
|
46
|
+
def combinator(klass, node)
|
|
47
|
+
klass.new(children: [walk(node.left), walk(node.right)].compact)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param node [ConditionTree::Node, nil]
|
|
51
|
+
# @return [ConditionTree::NotNode, nil]
|
|
52
|
+
def negate(node)
|
|
53
|
+
return nil if node.nil?
|
|
54
|
+
|
|
55
|
+
ConditionTree::NotNode.new(child: node)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
5
|
require_relative "condition_tree"
|
|
6
|
+
require_relative "ast_walker"
|
|
6
7
|
|
|
7
8
|
module ActiverecordCallbackLens
|
|
8
9
|
module Parser
|
|
@@ -64,9 +65,7 @@ module ActiverecordCallbackLens
|
|
|
64
65
|
# @param node [ConditionTree::Node, nil]
|
|
65
66
|
# @return [ConditionTree::NotNode, nil]
|
|
66
67
|
def negate(node)
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
ConditionTree::NotNode.new(child: node)
|
|
68
|
+
AstWalker.negate(node)
|
|
70
69
|
end
|
|
71
70
|
|
|
72
71
|
# Dispatches a single raw condition entry to the right handler.
|
|
@@ -96,33 +95,7 @@ module ActiverecordCallbackLens
|
|
|
96
95
|
|
|
97
96
|
locator = LambdaLocator.new(target_line: line)
|
|
98
97
|
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)
|
|
98
|
+
AstWalker.walk(locator.node)
|
|
126
99
|
end
|
|
127
100
|
|
|
128
101
|
# A Prism visitor that captures the innermost LambdaNode or BlockNode whose
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../parser/condition_tree"
|
|
6
|
+
require_relative "../parser/ast_walker"
|
|
7
|
+
|
|
8
|
+
module ActiverecordCallbackLens
|
|
9
|
+
module Resolver
|
|
10
|
+
# Resolves a callback's MethodRefNode symbol (e.g. :sync_required?) into an
|
|
11
|
+
# expanded ConditionTree.
|
|
12
|
+
#
|
|
13
|
+
# Given a model class and a method name, MethodResolver locates the method's
|
|
14
|
+
# source via `instance_method(name).source_location`, parses the file with
|
|
15
|
+
# Prism, isolates the matching DefNode, and maps its body onto a
|
|
16
|
+
# ConditionTree with the shared Parser::AstWalker. Any MethodRefNode found in
|
|
17
|
+
# that tree is then resolved recursively, so a predicate that delegates to
|
|
18
|
+
# other predicates expands into a full boolean tree.
|
|
19
|
+
#
|
|
20
|
+
# Two guards prevent runaway recursion:
|
|
21
|
+
# - a `visited` Set closes direct (A -> A) and indirect (A -> B -> A) cycles
|
|
22
|
+
# - a MAX_DEPTH cap bounds otherwise-acyclic but deep chains
|
|
23
|
+
#
|
|
24
|
+
# Known limitation: a DefNode body is a StatementsNode and the resolver treats
|
|
25
|
+
# the *last* statement as the effective return value (the same assumption
|
|
26
|
+
# ConditionParser makes for lambda bodies). Methods with early returns or
|
|
27
|
+
# guard clauses are therefore only partially understood and are left
|
|
28
|
+
# unexpanded where the heuristic does not apply.
|
|
29
|
+
class MethodResolver
|
|
30
|
+
# Hard cap on recursion depth. A chain of method refs deeper than this is
|
|
31
|
+
# left unexpanded rather than followed further.
|
|
32
|
+
MAX_DEPTH = 5
|
|
33
|
+
|
|
34
|
+
# @param model_class [Class] the ActiveRecord model the method is defined on
|
|
35
|
+
# @param method_name [Symbol] the predicate/method to resolve
|
|
36
|
+
# @return [Parser::ConditionTree::Node, nil] the expanded tree, or nil when
|
|
37
|
+
# the method cannot be located/parsed
|
|
38
|
+
def self.resolve(model_class, method_name)
|
|
39
|
+
new(model_class).resolve(method_name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Per-definition expansion entry point. Walks a CallbackDefinition's
|
|
43
|
+
# condition_tree, resolves every MethodRefNode against the model, and
|
|
44
|
+
# returns a new definition whose tree carries populated expanded_tree
|
|
45
|
+
# fields. Bridges the resolver core and the CLI/Rake integration.
|
|
46
|
+
#
|
|
47
|
+
# @param definition [Collector::CallbackDefinition]
|
|
48
|
+
# @param model_class [Class]
|
|
49
|
+
# @return [Collector::CallbackDefinition] with condition_tree fully expanded
|
|
50
|
+
def self.expand(definition, model_class)
|
|
51
|
+
new(model_class).expand(definition)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param model_class [Class]
|
|
55
|
+
def initialize(model_class)
|
|
56
|
+
@model_class = model_class
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Expands every MethodRefNode in the definition's condition_tree.
|
|
60
|
+
#
|
|
61
|
+
# A definition with no condition_tree (nil) is returned unchanged; so is a
|
|
62
|
+
# tree that contains no MethodRefNodes (expand_tree rebuilds it into a
|
|
63
|
+
# value-equal copy, leaving non-expansion output identical to v0.1).
|
|
64
|
+
#
|
|
65
|
+
# @param definition [Collector::CallbackDefinition]
|
|
66
|
+
# @return [Collector::CallbackDefinition]
|
|
67
|
+
def expand(definition)
|
|
68
|
+
return definition unless definition.condition_tree
|
|
69
|
+
|
|
70
|
+
expanded = expand_tree(definition.condition_tree)
|
|
71
|
+
definition.with(condition_tree: expanded)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Resolves a method name into an expanded ConditionTree.
|
|
75
|
+
#
|
|
76
|
+
# @param method_name [Symbol]
|
|
77
|
+
# @param depth [Integer] current recursion depth (0 at the entry point)
|
|
78
|
+
# @param visited [Set<Symbol>] method names already on the current path
|
|
79
|
+
# @return [Parser::ConditionTree::Node, nil]
|
|
80
|
+
def resolve(method_name, depth: 0, visited: Set.new)
|
|
81
|
+
if depth >= MAX_DEPTH
|
|
82
|
+
warn("[ActiverecordCallbackLens] Max resolution depth (#{MAX_DEPTH}) reached at #{method_name}")
|
|
83
|
+
return nil
|
|
84
|
+
end
|
|
85
|
+
return nil if visited.include?(method_name)
|
|
86
|
+
|
|
87
|
+
visited = visited.dup
|
|
88
|
+
visited.add(method_name)
|
|
89
|
+
|
|
90
|
+
node = locate_and_parse(method_name)
|
|
91
|
+
return nil if node.nil?
|
|
92
|
+
|
|
93
|
+
expand_refs(node, depth: depth + 1, visited: visited)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# Recursively rebuilds a condition_tree, populating expanded_tree on every
|
|
99
|
+
# MethodRefNode. And/Or/Not structure is preserved by rebuilding children
|
|
100
|
+
# through Data#with so callers' references are never mutated; nodes with no
|
|
101
|
+
# MethodRefNode beneath them rebuild into value-equal copies.
|
|
102
|
+
#
|
|
103
|
+
# Each MethodRefNode is resolved from a fresh depth-0 walk (resolve applies
|
|
104
|
+
# its own MAX_DEPTH/visited guards), so a ref's name (a String) is converted
|
|
105
|
+
# to a Symbol before being handed to resolve.
|
|
106
|
+
#
|
|
107
|
+
# @param node [Parser::ConditionTree::Node, nil]
|
|
108
|
+
# @return [Parser::ConditionTree::Node, nil]
|
|
109
|
+
def expand_tree(node)
|
|
110
|
+
case node
|
|
111
|
+
in Parser::ConditionTree::AndNode | Parser::ConditionTree::OrNode
|
|
112
|
+
node.with(children: node.children.map { |child| expand_tree(child) })
|
|
113
|
+
in Parser::ConditionTree::NotNode
|
|
114
|
+
node.with(child: expand_tree(node.child))
|
|
115
|
+
in Parser::ConditionTree::MethodRefNode
|
|
116
|
+
node.with(expanded_tree: resolve(node.name.to_sym))
|
|
117
|
+
else
|
|
118
|
+
node
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Locates the method's source and parses its body into a ConditionTree.
|
|
123
|
+
#
|
|
124
|
+
# @param method_name [Symbol]
|
|
125
|
+
# @return [Parser::ConditionTree::Node, nil] nil when the method has no Ruby
|
|
126
|
+
# source, the file is missing/unparseable, or no DefNode is found
|
|
127
|
+
def locate_and_parse(method_name)
|
|
128
|
+
location = source_location_for(method_name)
|
|
129
|
+
return nil if location.nil?
|
|
130
|
+
|
|
131
|
+
file, line = location
|
|
132
|
+
return nil unless file && File.exist?(file)
|
|
133
|
+
|
|
134
|
+
result = Prism.parse_file(file)
|
|
135
|
+
unless result.success?
|
|
136
|
+
warn("[ActiverecordCallbackLens] Failed to parse #{file}: #{result.errors.map(&:message).join(', ')}")
|
|
137
|
+
return nil
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
locator = DefLocator.new(target_line: line, method_name: method_name)
|
|
141
|
+
locator.visit(result.value)
|
|
142
|
+
reclassify_refs(Parser::AstWalker.walk(locator.node))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The shared AstWalker maps every bare predicate call to a PredicateNode
|
|
146
|
+
# leaf. Inside a *method* body, though, a call to another method that this
|
|
147
|
+
# model defines in Ruby is a MethodRefNode the resolver can expand. This
|
|
148
|
+
# pass rewrites those leaves: a PredicateNode whose name resolves to a
|
|
149
|
+
# method with a Ruby source_location becomes a (yet-unexpanded)
|
|
150
|
+
# MethodRefNode; everything else (AR-generated dirty-tracking predicates,
|
|
151
|
+
# C-level methods) stays a PredicateNode leaf.
|
|
152
|
+
#
|
|
153
|
+
# @param node [Parser::ConditionTree::Node, nil]
|
|
154
|
+
# @return [Parser::ConditionTree::Node, nil]
|
|
155
|
+
def reclassify_refs(node)
|
|
156
|
+
tree = Parser::ConditionTree
|
|
157
|
+
case node
|
|
158
|
+
when tree::PredicateNode
|
|
159
|
+
resolvable?(node.name) ? tree::MethodRefNode.new(name: node.name, expanded_tree: nil) : node
|
|
160
|
+
when tree::AndNode, tree::OrNode
|
|
161
|
+
node.with(children: node.children.map { |child| reclassify_refs(child) })
|
|
162
|
+
when tree::NotNode
|
|
163
|
+
node.with(child: reclassify_refs(node.child))
|
|
164
|
+
else
|
|
165
|
+
node
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param name [String] a predicate/method name
|
|
170
|
+
# @return [Boolean] true when the model defines a method by this name that
|
|
171
|
+
# has a Ruby source_location (i.e. can be followed by the resolver)
|
|
172
|
+
def resolvable?(name)
|
|
173
|
+
location = source_location_for(name.to_sym)
|
|
174
|
+
!location.nil?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# @param method_name [Symbol]
|
|
178
|
+
# @return [Array(String, Integer), nil] the [file, line] pair, or nil when
|
|
179
|
+
# the method is undefined or C-level/eval'd (nil source_location)
|
|
180
|
+
def source_location_for(method_name)
|
|
181
|
+
@model_class.instance_method(method_name).source_location
|
|
182
|
+
rescue NameError
|
|
183
|
+
nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Walks a parsed ConditionTree and replaces every MethodRefNode with a copy
|
|
187
|
+
# whose `expanded_tree` is the recursive resolution of that ref. And/Or/Not
|
|
188
|
+
# structure is preserved by rebuilding children through Data#with.
|
|
189
|
+
#
|
|
190
|
+
# @param node [Parser::ConditionTree::Node, nil]
|
|
191
|
+
# @param depth [Integer]
|
|
192
|
+
# @param visited [Set<Symbol>]
|
|
193
|
+
# @return [Parser::ConditionTree::Node, nil]
|
|
194
|
+
def expand_refs(node, depth:, visited:)
|
|
195
|
+
tree = Parser::ConditionTree
|
|
196
|
+
case node
|
|
197
|
+
when tree::MethodRefNode
|
|
198
|
+
sub_tree = resolve(node.name.to_sym, depth: depth, visited: visited)
|
|
199
|
+
node.with(expanded_tree: sub_tree)
|
|
200
|
+
when tree::AndNode, tree::OrNode
|
|
201
|
+
node.with(children: node.children.map { |child| expand_refs(child, depth: depth, visited: visited) })
|
|
202
|
+
when tree::NotNode
|
|
203
|
+
node.with(child: expand_refs(node.child, depth: depth, visited: visited))
|
|
204
|
+
else
|
|
205
|
+
node
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# A Prism visitor that captures the body of the DefNode matching a target
|
|
210
|
+
# line (and, when available, a target method name). Mirrors ConditionParser's
|
|
211
|
+
# LambdaLocator but targets `def` definitions rather than lambdas/blocks.
|
|
212
|
+
class DefLocator < Prism::Visitor
|
|
213
|
+
# @return [Prism::Node, nil] the body of the matched DefNode
|
|
214
|
+
attr_reader :node
|
|
215
|
+
|
|
216
|
+
# @param target_line [Integer] the line source_location reports for the def
|
|
217
|
+
# @param method_name [Symbol, nil] the method name to disambiguate overloads
|
|
218
|
+
def initialize(target_line:, method_name: nil)
|
|
219
|
+
@target_line = target_line
|
|
220
|
+
@method_name = method_name
|
|
221
|
+
@node = nil
|
|
222
|
+
super()
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @param def_node [Prism::DefNode]
|
|
226
|
+
# @return [void]
|
|
227
|
+
def visit_def_node(def_node)
|
|
228
|
+
capture(def_node)
|
|
229
|
+
super
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
# Records the def's body when it both starts on the target line and (if a
|
|
235
|
+
# name was supplied) carries the expected method name. Matching on the
|
|
236
|
+
# start line keeps a method's own `def` from being shadowed by an enclosing
|
|
237
|
+
# definition, while the name check guards against same-line edge cases.
|
|
238
|
+
#
|
|
239
|
+
# @param candidate [Prism::DefNode]
|
|
240
|
+
# @return [void]
|
|
241
|
+
def capture(candidate)
|
|
242
|
+
return unless candidate.location.start_line == @target_line
|
|
243
|
+
return if @method_name && candidate.name != @method_name
|
|
244
|
+
|
|
245
|
+
@node = candidate.body
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -3,15 +3,18 @@
|
|
|
3
3
|
require_relative "callback_lens_helpers"
|
|
4
4
|
|
|
5
5
|
namespace :callback_lens do
|
|
6
|
-
desc "Print callback Mermaid diagram for MODEL
|
|
6
|
+
desc "Print callback Mermaid diagram for MODEL " \
|
|
7
|
+
"(e.g. rake callback_lens:analyze MODEL=User EXPAND=true)"
|
|
7
8
|
task analyze: :environment do
|
|
8
9
|
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
9
|
-
|
|
10
|
+
expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
|
|
11
|
+
puts CallbackLensRakeHelpers.render_mermaid(model_class, expand: expand)
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
desc "Alias for callback_lens:analyze — print Mermaid diagram for MODEL"
|
|
13
15
|
task mermaid: :environment do
|
|
14
16
|
model_class = CallbackLensRakeHelpers.resolve_model!(ENV.fetch("MODEL", nil))
|
|
15
|
-
|
|
17
|
+
expand = CallbackLensRakeHelpers.expand?(ENV.fetch("EXPAND", nil))
|
|
18
|
+
puts CallbackLensRakeHelpers.render_mermaid(model_class, expand: expand)
|
|
16
19
|
end
|
|
17
20
|
end
|
|
@@ -22,14 +22,37 @@ module CallbackLensRakeHelpers
|
|
|
22
22
|
raise "Cannot find model class '#{name}'. Make sure it is loaded."
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
# Runs the full pipeline (collect -> parse -> build -> render) for
|
|
25
|
+
# Runs the full pipeline (collect -> parse -> [expand] -> build -> render) for
|
|
26
|
+
# a model.
|
|
27
|
+
#
|
|
28
|
+
# When +expand+ is true, every parsed definition's condition_tree has its
|
|
29
|
+
# MethodRefNodes resolved into ConditionTree sub-trees via MethodResolver,
|
|
30
|
+
# matching the CLI's +--expand+ behaviour. When false (the default), the
|
|
31
|
+
# pipeline is identical to v0.1 output. Threading +expand+ through this single
|
|
32
|
+
# helper keeps every rake task that delegates here uniform.
|
|
26
33
|
#
|
|
27
34
|
# @param model_class [Class]
|
|
35
|
+
# @param expand [Boolean]
|
|
28
36
|
# @return [String] the Mermaid diagram
|
|
29
|
-
def render_mermaid(model_class)
|
|
37
|
+
def render_mermaid(model_class, expand: false)
|
|
30
38
|
definitions = ActiverecordCallbackLens::Collector::CallbackCollector.collect(model_class)
|
|
31
39
|
definitions = definitions.map { |definition| ActiverecordCallbackLens::Parser::ConditionParser.parse(definition) }
|
|
40
|
+
if expand
|
|
41
|
+
definitions = definitions.map do |definition|
|
|
42
|
+
ActiverecordCallbackLens::Resolver::MethodResolver.expand(definition, model_class)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
32
45
|
graph = ActiverecordCallbackLens::Graph::GraphBuilder.build(definitions)
|
|
33
46
|
ActiverecordCallbackLens::Renderer::MermaidRenderer.render(graph)
|
|
34
47
|
end
|
|
48
|
+
|
|
49
|
+
# Parses the EXPAND environment variable using the strict truthy rule: only the
|
|
50
|
+
# exact string "true" (case-insensitive, surrounding whitespace stripped)
|
|
51
|
+
# enables expansion. Any other value ("1", "yes", "", nil) leaves it off.
|
|
52
|
+
#
|
|
53
|
+
# @param value [String, nil] the raw ENV["EXPAND"] value
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def expand?(value)
|
|
56
|
+
value.to_s.strip.downcase == "true"
|
|
57
|
+
end
|
|
35
58
|
end
|
|
@@ -4,7 +4,9 @@ require "activerecord_callback_lens/version"
|
|
|
4
4
|
require "activerecord_callback_lens/collector/callback_definition"
|
|
5
5
|
require "activerecord_callback_lens/collector/callback_collector"
|
|
6
6
|
require "activerecord_callback_lens/parser/condition_tree"
|
|
7
|
+
require "activerecord_callback_lens/parser/ast_walker"
|
|
7
8
|
require "activerecord_callback_lens/parser/condition_parser"
|
|
9
|
+
require "activerecord_callback_lens/resolver/method_resolver"
|
|
8
10
|
require "activerecord_callback_lens/graph/nodes"
|
|
9
11
|
require "activerecord_callback_lens/graph/graph_builder"
|
|
10
12
|
require "activerecord_callback_lens/renderer/mermaid_renderer"
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Eraxel.Dev
|
|
@@ -61,7 +61,7 @@ dependencies:
|
|
|
61
61
|
description: Inspect, parse, and visualize the callbacks registered on your ActiveRecord
|
|
62
62
|
models — including their if/unless conditions — as graphs and diagrams.
|
|
63
63
|
email:
|
|
64
|
-
-
|
|
64
|
+
- eraxel.dev@gmail.com
|
|
65
65
|
executables:
|
|
66
66
|
- callback_lens
|
|
67
67
|
extensions: []
|
|
@@ -77,19 +77,21 @@ files:
|
|
|
77
77
|
- lib/activerecord_callback_lens/collector/callback_definition.rb
|
|
78
78
|
- lib/activerecord_callback_lens/graph/graph_builder.rb
|
|
79
79
|
- lib/activerecord_callback_lens/graph/nodes.rb
|
|
80
|
+
- lib/activerecord_callback_lens/parser/ast_walker.rb
|
|
80
81
|
- lib/activerecord_callback_lens/parser/condition_parser.rb
|
|
81
82
|
- lib/activerecord_callback_lens/parser/condition_tree.rb
|
|
82
83
|
- lib/activerecord_callback_lens/railtie.rb
|
|
83
84
|
- lib/activerecord_callback_lens/renderer/mermaid_renderer.rb
|
|
85
|
+
- lib/activerecord_callback_lens/resolver/method_resolver.rb
|
|
84
86
|
- lib/activerecord_callback_lens/tasks/callback_lens.rake
|
|
85
87
|
- lib/activerecord_callback_lens/tasks/callback_lens_helpers.rb
|
|
86
88
|
- lib/activerecord_callback_lens/version.rb
|
|
87
|
-
homepage: https://github.com/eraxel/activerecord_callback_lens
|
|
89
|
+
homepage: https://github.com/eraxel-dev/activerecord_callback_lens
|
|
88
90
|
licenses:
|
|
89
91
|
- MIT
|
|
90
92
|
metadata:
|
|
91
|
-
homepage_uri: https://github.com/eraxel/activerecord_callback_lens
|
|
92
|
-
source_code_uri: https://github.com/eraxel/activerecord_callback_lens
|
|
93
|
+
homepage_uri: https://github.com/eraxel-dev/activerecord_callback_lens
|
|
94
|
+
source_code_uri: https://github.com/eraxel-dev/activerecord_callback_lens
|
|
93
95
|
rubygems_mfa_required: 'true'
|
|
94
96
|
post_install_message:
|
|
95
97
|
rdoc_options: []
|