rigor-module-graph 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 +7 -0
- data/CHANGELOG.md +118 -0
- data/LICENSE.txt +21 -0
- data/README.md +294 -0
- data/exe/rigor-module-graph +7 -0
- data/lib/rigor/module_graph/analyzer.rb +445 -0
- data/lib/rigor/module_graph/cli.rb +1024 -0
- data/lib/rigor/module_graph/constant_name.rb +86 -0
- data/lib/rigor/module_graph/cycle_detector.rb +154 -0
- data/lib/rigor/module_graph/dot.rb +164 -0
- data/lib/rigor/module_graph/edge.rb +181 -0
- data/lib/rigor/module_graph/html_view.rb +43 -0
- data/lib/rigor/module_graph/inflector.rb +64 -0
- data/lib/rigor/module_graph/mermaid.rb +171 -0
- data/lib/rigor/module_graph/node.rb +146 -0
- data/lib/rigor/module_graph/packwerk_overlay.rb +143 -0
- data/lib/rigor/module_graph/plugin/rigor_plugin.rb +150 -0
- data/lib/rigor/module_graph/plugin.rb +25 -0
- data/lib/rigor/module_graph/reachability.rb +197 -0
- data/lib/rigor/module_graph/stats.rb +119 -0
- data/lib/rigor/module_graph/templates/view.html.erb +50 -0
- data/lib/rigor/module_graph/uml/class_diagram.rb +196 -0
- data/lib/rigor/module_graph/version.rb +10 -0
- data/lib/rigor/module_graph/visibility_map.rb +90 -0
- data/lib/rigor/module_graph/zeitwerk_resolver.rb +116 -0
- data/lib/rigor-module-graph.rb +32 -0
- metadata +111 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Resolves a fully-qualified constant name from Prism AST nodes
|
|
8
|
+
# and lexical ancestor chains.
|
|
9
|
+
#
|
|
10
|
+
# Three things this module handles that the bare Prism API does
|
|
11
|
+
# not give you in one place:
|
|
12
|
+
#
|
|
13
|
+
# - Owner from lexical nesting. `node.constant_path.full_name`
|
|
14
|
+
# on a `class Billing::Invoice` only returns `"Invoice"`; the
|
|
15
|
+
# outer `module Billing` does not enter unless we walk
|
|
16
|
+
# ancestors ourselves.
|
|
17
|
+
# - Absolute paths (`::Foo::Bar`). Prism encodes the leading
|
|
18
|
+
# `::` as an empty-symbol `:""` in `full_name_parts`; we
|
|
19
|
+
# render it as `"::Foo::Bar"`.
|
|
20
|
+
# - Mixed AST shapes. `ClassNode#constant_path` is either a
|
|
21
|
+
# `ConstantReadNode` (single name) or a `ConstantPathNode`
|
|
22
|
+
# (dotted path); same for `superclass` and `include` args.
|
|
23
|
+
module ConstantName
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Render a single Prism constant node into a string like
|
|
27
|
+
# `"Foo"`, `"Foo::Bar"`, or `"::Foo::Bar"`. Returns nil when
|
|
28
|
+
# the node is not a constant carrier (e.g. `include SOME_VAR`
|
|
29
|
+
# where the arg is a `CallNode`).
|
|
30
|
+
def render(node)
|
|
31
|
+
case node
|
|
32
|
+
when Prism::ConstantReadNode
|
|
33
|
+
node.name.to_s
|
|
34
|
+
when Prism::ConstantPathNode
|
|
35
|
+
full_name_for_path(node)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build the lexical-nesting owner string for a node, by
|
|
40
|
+
# walking `context.ancestors` from outer to inner and joining
|
|
41
|
+
# every enclosing `ClassNode`/`ModuleNode` constant path with
|
|
42
|
+
# `::`. Returns nil when no class/module encloses the node.
|
|
43
|
+
def lexical_owner(context)
|
|
44
|
+
parts = lexical_parts(context.ancestors)
|
|
45
|
+
return nil if parts.empty?
|
|
46
|
+
|
|
47
|
+
parts.join("::")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Same as `#lexical_owner`, but with `extra` appended as the
|
|
51
|
+
# innermost element. Used to build the owner of a class or
|
|
52
|
+
# module decl itself: pass `context.ancestors` (which does NOT
|
|
53
|
+
# include the node itself) plus the node's own constant path.
|
|
54
|
+
def lexical_owner_with(context, extra)
|
|
55
|
+
parts = lexical_parts(context.ancestors)
|
|
56
|
+
parts << extra unless extra.nil? || extra.empty?
|
|
57
|
+
return nil if parts.empty?
|
|
58
|
+
|
|
59
|
+
parts.join("::")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def lexical_parts(ancestors)
|
|
63
|
+
ancestors.flat_map do |ancestor|
|
|
64
|
+
case ancestor
|
|
65
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
66
|
+
name = render(ancestor.constant_path)
|
|
67
|
+
name ? [name] : []
|
|
68
|
+
else
|
|
69
|
+
[]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def full_name_for_path(node)
|
|
75
|
+
parts = node.full_name_parts
|
|
76
|
+
# Prism encodes a leading `::` as an empty-symbol first
|
|
77
|
+
# part. Render it as a literal `"::"` prefix.
|
|
78
|
+
if parts.first == :""
|
|
79
|
+
"::" + parts.drop(1).join("::")
|
|
80
|
+
else
|
|
81
|
+
parts.join("::")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Finds dependency cycles in an Edge list.
|
|
8
|
+
#
|
|
9
|
+
# Tarjan's strongly-connected-components, sized ≥ 2 (a SCC of
|
|
10
|
+
# 1 is a single node with no self-loop and represents no cycle).
|
|
11
|
+
# We also surface single-node self-loops if any edge points a
|
|
12
|
+
# constant at itself.
|
|
13
|
+
#
|
|
14
|
+
# Returns an array of Cycle, where each Cycle carries the list
|
|
15
|
+
# of node names in the cycle in a canonical rotation: smallest
|
|
16
|
+
# name first. The actual edge instances making up each cycle
|
|
17
|
+
# are not surfaced — for visualisation we only need the node
|
|
18
|
+
# set — but a kind filter is offered so callers can ask "do
|
|
19
|
+
# these edges form a cycle using only `include` / `inherits`?"
|
|
20
|
+
module CycleDetector
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# One detected cycle, expressed as the list of node names
|
|
24
|
+
# along it. Always rotated so the lexicographically smallest
|
|
25
|
+
# name comes first, so the rendered output is stable across
|
|
26
|
+
# runs.
|
|
27
|
+
class Cycle < Data.define(:nodes)
|
|
28
|
+
# Render the cycle as +A -> B -> C -> A+ (note the closing
|
|
29
|
+
# repeat of the first node so the round-trip is obvious).
|
|
30
|
+
def to_s
|
|
31
|
+
nodes.join(" -> ") + " -> " + nodes.first
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param edges [Array<Edge>]
|
|
36
|
+
# @param kinds [Array<String>, nil] when given, only edges
|
|
37
|
+
# whose `kind` is in the list participate in cycle detection.
|
|
38
|
+
def detect(edges, kinds: nil)
|
|
39
|
+
graph = build_adjacency(edges, kinds)
|
|
40
|
+
sccs = tarjan(graph)
|
|
41
|
+
cycles = sccs.select { |scc| scc.size >= 2 }.map { |scc| canonicalize(scc, graph) }
|
|
42
|
+
self_loops = collect_self_loops(graph)
|
|
43
|
+
(cycles + self_loops).sort_by { |c| c.nodes.first }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_adjacency(edges, kinds)
|
|
47
|
+
graph = Hash.new { |h, k| h[k] = [] }
|
|
48
|
+
edges.each do |edge|
|
|
49
|
+
next if kinds && !kinds.include?(edge.kind)
|
|
50
|
+
|
|
51
|
+
graph[edge.from] << edge.to
|
|
52
|
+
graph[edge.to] # ensure target appears even with no outgoing edges
|
|
53
|
+
end
|
|
54
|
+
graph.each_value(&:uniq!)
|
|
55
|
+
graph
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Iterative Tarjan to avoid blowing the Ruby stack on a wide
|
|
59
|
+
# graph. Returns SCCs as arrays of node names.
|
|
60
|
+
def tarjan(graph)
|
|
61
|
+
index = 0
|
|
62
|
+
indices = {}
|
|
63
|
+
lowlink = {}
|
|
64
|
+
on_stack = {}
|
|
65
|
+
stack = []
|
|
66
|
+
sccs = []
|
|
67
|
+
|
|
68
|
+
graph.each_key do |start|
|
|
69
|
+
next if indices.key?(start)
|
|
70
|
+
|
|
71
|
+
work = [[start, 0]]
|
|
72
|
+
indices[start] = index
|
|
73
|
+
lowlink[start] = index
|
|
74
|
+
index += 1
|
|
75
|
+
stack.push(start)
|
|
76
|
+
on_stack[start] = true
|
|
77
|
+
|
|
78
|
+
until work.empty?
|
|
79
|
+
node, i = work.last
|
|
80
|
+
neighbours = graph[node]
|
|
81
|
+
if i < neighbours.size
|
|
82
|
+
work[-1] = [node, i + 1]
|
|
83
|
+
succ = neighbours[i]
|
|
84
|
+
if !indices.key?(succ)
|
|
85
|
+
indices[succ] = index
|
|
86
|
+
lowlink[succ] = index
|
|
87
|
+
index += 1
|
|
88
|
+
stack.push(succ)
|
|
89
|
+
on_stack[succ] = true
|
|
90
|
+
work.push([succ, 0])
|
|
91
|
+
elsif on_stack[succ]
|
|
92
|
+
lowlink[node] = [lowlink[node], indices[succ]].min
|
|
93
|
+
end
|
|
94
|
+
else
|
|
95
|
+
if lowlink[node] == indices[node]
|
|
96
|
+
scc = []
|
|
97
|
+
loop do
|
|
98
|
+
w = stack.pop
|
|
99
|
+
on_stack.delete(w)
|
|
100
|
+
scc << w
|
|
101
|
+
break if w == node
|
|
102
|
+
end
|
|
103
|
+
sccs << scc
|
|
104
|
+
end
|
|
105
|
+
work.pop
|
|
106
|
+
parent = work.last&.first
|
|
107
|
+
lowlink[parent] = [lowlink[parent], lowlink[node]].min if parent
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
sccs
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def canonicalize(scc, graph)
|
|
115
|
+
nodes = walk_cycle(scc, graph)
|
|
116
|
+
nodes = rotate_to_smallest(nodes)
|
|
117
|
+
Cycle.new(nodes: nodes)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Walk a single trip around the SCC starting from its
|
|
121
|
+
# smallest-named node, following outgoing edges that stay
|
|
122
|
+
# in the SCC. Falls back to sorted membership if the cycle
|
|
123
|
+
# is degenerate (should not happen for a real SCC ≥ 2).
|
|
124
|
+
def walk_cycle(scc, graph)
|
|
125
|
+
set = scc.to_set
|
|
126
|
+
start = scc.min
|
|
127
|
+
path = [start]
|
|
128
|
+
current = start
|
|
129
|
+
loop do
|
|
130
|
+
next_node = graph[current].find { |n| set.include?(n) && !path.include?(n) }
|
|
131
|
+
break unless next_node
|
|
132
|
+
|
|
133
|
+
path << next_node
|
|
134
|
+
current = next_node
|
|
135
|
+
end
|
|
136
|
+
# If we couldn't visit everyone (rare: SCC with parallel
|
|
137
|
+
# branches), fall back to sorted order so output stays
|
|
138
|
+
# deterministic.
|
|
139
|
+
path.size == scc.size ? path : scc.sort
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rotate_to_smallest(nodes)
|
|
143
|
+
i = nodes.each_with_index.min_by { |name, _| name }[1]
|
|
144
|
+
nodes.rotate(i)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def collect_self_loops(graph)
|
|
148
|
+
graph.each_with_object([]) do |(node, targets), acc|
|
|
149
|
+
acc << Cycle.new(nodes: [node]) if targets.include?(node)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Renders an array of Edges as a Graphviz DOT document.
|
|
6
|
+
#
|
|
7
|
+
# Style decisions (per docs/plan.md "グラフモデル"):
|
|
8
|
+
# - rankdir=LR for readability of inheritance towers
|
|
9
|
+
# - inherits: thick solid
|
|
10
|
+
# - include: solid
|
|
11
|
+
# - prepend: solid, distinct color
|
|
12
|
+
# - extend: dashed
|
|
13
|
+
# - const_ref: faded dotted
|
|
14
|
+
#
|
|
15
|
+
# When `collapse:` is given, every node whose fully-qualified
|
|
16
|
+
# name sits under one of the listed prefixes is wrapped in a
|
|
17
|
+
# `subgraph cluster_<prefix>` block, and the prefix is stripped
|
|
18
|
+
# from the visible label. Edges across clusters render normally;
|
|
19
|
+
# Graphviz routes them between the cluster boundaries.
|
|
20
|
+
module Dot
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
KIND_STYLE = {
|
|
24
|
+
"inherits" => 'color="#0f172a", penwidth=2.0',
|
|
25
|
+
"include" => 'color="#1d4ed8"',
|
|
26
|
+
"prepend" => 'color="#9333ea"',
|
|
27
|
+
"extend" => 'color="#0f766e", style="dashed"',
|
|
28
|
+
"const_ref" => 'color="#94a3b8", style="dotted"'
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
CONFIDENCE_STYLE = {
|
|
32
|
+
"unresolved" => 'style="dashed", color="#94a3b8"'
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
HEADER = <<~DOT
|
|
36
|
+
digraph ruby_modules {
|
|
37
|
+
rankdir=LR;
|
|
38
|
+
graph [compound=true, overlap=false, splines=true];
|
|
39
|
+
node [shape=box, style="rounded,filled", fillcolor="#f8fafc", color="#94a3b8", fontname="Helvetica"];
|
|
40
|
+
edge [color="#64748b", arrowsize=0.7, fontname="Helvetica"];
|
|
41
|
+
DOT
|
|
42
|
+
|
|
43
|
+
# @param edges [Array<Edge>]
|
|
44
|
+
# @param collapse [Array<String>] namespace prefixes to
|
|
45
|
+
# fold into clusters (mutually exclusive with +groups+)
|
|
46
|
+
# @param groups [Hash{String=>String}, nil] explicit
|
|
47
|
+
# +{node_name => cluster_label}+ mapping. Takes precedence
|
|
48
|
+
# over +collapse+ when given. Used by the +--package+
|
|
49
|
+
# overlay where the cluster boundary is something other
|
|
50
|
+
# than a +::+ namespace prefix.
|
|
51
|
+
def render(edges, collapse: [], groups: nil)
|
|
52
|
+
edges = dedup(edges)
|
|
53
|
+
nodes = collect_nodes(edges)
|
|
54
|
+
clusters, ungrouped = build_groups(nodes, collapse, groups)
|
|
55
|
+
|
|
56
|
+
out = +HEADER
|
|
57
|
+
clusters.each do |label, members|
|
|
58
|
+
out << render_cluster(label, members, use_namespace_prefix: groups.nil?)
|
|
59
|
+
end
|
|
60
|
+
ungrouped.each do |name|
|
|
61
|
+
out << " #{quote(name)};\n"
|
|
62
|
+
end
|
|
63
|
+
out << "\n" unless nodes.empty?
|
|
64
|
+
edges.each do |edge|
|
|
65
|
+
out << render_edge(edge)
|
|
66
|
+
end
|
|
67
|
+
out << "}\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def dedup(edges)
|
|
71
|
+
seen = {}
|
|
72
|
+
edges.each_with_object([]) do |edge, acc|
|
|
73
|
+
key = edge.dedup_key
|
|
74
|
+
next if seen[key]
|
|
75
|
+
|
|
76
|
+
seen[key] = true
|
|
77
|
+
acc << edge
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def collect_nodes(edges)
|
|
82
|
+
names = edges.flat_map { |edge| [edge.from, edge.to] }
|
|
83
|
+
names.uniq.sort
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build the cluster partition. When +groups+ is given we
|
|
87
|
+
# use it verbatim; otherwise fall back to prefix-matching
|
|
88
|
+
# against +collapse+ (the legacy namespace-collapse path).
|
|
89
|
+
def build_groups(nodes, collapse, groups)
|
|
90
|
+
if groups && !groups.empty?
|
|
91
|
+
clusters = Hash.new { |h, k| h[k] = [] }
|
|
92
|
+
ungrouped = []
|
|
93
|
+
nodes.each do |name|
|
|
94
|
+
if (label = groups[name])
|
|
95
|
+
clusters[label] << name
|
|
96
|
+
else
|
|
97
|
+
ungrouped << name
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
[clusters, ungrouped]
|
|
101
|
+
else
|
|
102
|
+
group_by_prefix(nodes, collapse)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def group_by_prefix(nodes, collapse)
|
|
107
|
+
prefixes = Array(collapse).map(&:to_s).reject(&:empty?)
|
|
108
|
+
return [{}, nodes] if prefixes.empty?
|
|
109
|
+
|
|
110
|
+
sorted = prefixes.sort_by { |p| -p.length }
|
|
111
|
+
clusters = Hash.new { |h, k| h[k] = [] }
|
|
112
|
+
ungrouped = []
|
|
113
|
+
nodes.each do |name|
|
|
114
|
+
match = sorted.find { |p| name.start_with?(p + "::") }
|
|
115
|
+
if match
|
|
116
|
+
clusters[match] << name
|
|
117
|
+
else
|
|
118
|
+
ungrouped << name
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
[clusters, ungrouped]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def render_cluster(label, members, use_namespace_prefix: true)
|
|
125
|
+
out = +" subgraph #{quote("cluster_" + cluster_id(label))} {\n"
|
|
126
|
+
out << " label=#{quote(label)};\n"
|
|
127
|
+
out << " style=\"rounded,filled\";\n"
|
|
128
|
+
out << " color=\"#cbd5e1\";\n"
|
|
129
|
+
out << " fillcolor=\"#f1f5f9\";\n"
|
|
130
|
+
members.each do |name|
|
|
131
|
+
short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name
|
|
132
|
+
out << " #{quote(name)} [label=#{quote(short)}];\n"
|
|
133
|
+
end
|
|
134
|
+
out << " }\n"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Cluster identifiers in DOT must match `[A-Za-z_][A-Za-z0-9_]*`
|
|
138
|
+
# — package names like `packages/billing` would otherwise
|
|
139
|
+
# break Graphviz's parser even inside quotes. Squash every
|
|
140
|
+
# non-id character to `_` so the prefix `cluster_` still
|
|
141
|
+
# triggers Graphviz's cluster handling.
|
|
142
|
+
def cluster_id(prefix)
|
|
143
|
+
prefix.gsub(/[^A-Za-z0-9_]+/, "_")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def render_edge(edge)
|
|
147
|
+
attrs = +"label=\"#{edge.kind}\""
|
|
148
|
+
if (style = KIND_STYLE[edge.kind])
|
|
149
|
+
attrs << ", " << style
|
|
150
|
+
end
|
|
151
|
+
if (style = CONFIDENCE_STYLE[edge.confidence])
|
|
152
|
+
attrs << ", " << style
|
|
153
|
+
end
|
|
154
|
+
" #{quote(edge.from)} -> #{quote(edge.to)} [#{attrs}];\n"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def quote(name)
|
|
158
|
+
# DOT identifiers that contain `::` or quotes must be
|
|
159
|
+
# double-quoted; escape embedded double quotes.
|
|
160
|
+
'"' + name.gsub('"', '\"') + '"'
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# The list of valid +kind+ values for an Edge.
|
|
8
|
+
EDGE_KINDS = %w[
|
|
9
|
+
inherits
|
|
10
|
+
include
|
|
11
|
+
prepend
|
|
12
|
+
extend
|
|
13
|
+
const_ref
|
|
14
|
+
has_many
|
|
15
|
+
belongs_to
|
|
16
|
+
has_one
|
|
17
|
+
has_and_belongs_to_many
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Subset of EDGE_KINDS that represent Rails ActiveRecord
|
|
21
|
+
# associations. Used by the class diagram renderer to attach
|
|
22
|
+
# cardinality labels, and by callers that want to filter the
|
|
23
|
+
# set as a group.
|
|
24
|
+
ASSOCIATION_KINDS = %w[has_many belongs_to has_one has_and_belongs_to_many].freeze
|
|
25
|
+
|
|
26
|
+
# The structural / mixin kinds — the "first class" relations
|
|
27
|
+
# of a typical dependency graph. const_ref and associations
|
|
28
|
+
# sit outside this set.
|
|
29
|
+
STRUCTURAL_KINDS = %w[inherits include prepend extend].freeze
|
|
30
|
+
|
|
31
|
+
# The list of valid +confidence+ values for an Edge.
|
|
32
|
+
EDGE_CONFIDENCES = %w[syntax zeitwerk rigor_type unresolved].freeze
|
|
33
|
+
|
|
34
|
+
# A single dependency edge between two constants.
|
|
35
|
+
#
|
|
36
|
+
# Carries the dependency itself (+from+, +to+, +kind+,
|
|
37
|
+
# +confidence+), the source position it was extracted from
|
|
38
|
+
# (+path+, +line+, +column+), and the raw source slice (+raw+)
|
|
39
|
+
# when the resolution went through a fallback path. Edge is a
|
|
40
|
+
# +Data+ subclass — every instance is immutable.
|
|
41
|
+
#
|
|
42
|
+
# == Two serialisation shapes
|
|
43
|
+
#
|
|
44
|
+
# +to_message_payload+::
|
|
45
|
+
# What the plugin embeds in a diagnostic's +message+ field.
|
|
46
|
+
# The collector reconstructs an Edge from this payload plus
|
|
47
|
+
# the diagnostic's own +path+/+line+/+column+, so the payload
|
|
48
|
+
# omits position to keep the message compact.
|
|
49
|
+
# +to_h+::
|
|
50
|
+
# What the JSONL writer dumps to disk. Full row, with
|
|
51
|
+
# +path+/+line+/+column+ included.
|
|
52
|
+
#
|
|
53
|
+
# == Dedup key
|
|
54
|
+
#
|
|
55
|
+
# +dedup_key+ ignores +path+ and +line+ so the same logical
|
|
56
|
+
# edge declared in two files (or surfaced by two re-runs of
|
|
57
|
+
# +rigor check+) collapses to one row.
|
|
58
|
+
# The +dedup_key+ member at the end is internal: it's the
|
|
59
|
+
# cached +"\\x00"+-joined key string the renderers' dedup
|
|
60
|
+
# loops use for Hash lookups. Storing it on the value pays
|
|
61
|
+
# for itself once a few hundred edges flow through any
|
|
62
|
+
# rendering / IO path.
|
|
63
|
+
class Edge < Data.define(:from, :to, :kind, :path, :line, :column, :confidence, :raw, :dedup_key)
|
|
64
|
+
# Same as Rigor::ModuleGraph::EDGE_KINDS; exposed on the
|
|
65
|
+
# class so callers can write +Edge::KINDS+.
|
|
66
|
+
KINDS = EDGE_KINDS
|
|
67
|
+
|
|
68
|
+
# Same as Rigor::ModuleGraph::EDGE_CONFIDENCES; exposed on
|
|
69
|
+
# the class so callers can write +Edge::CONFIDENCES+.
|
|
70
|
+
CONFIDENCES = EDGE_CONFIDENCES
|
|
71
|
+
|
|
72
|
+
# Build an Edge, validating +kind+ and +confidence+ against
|
|
73
|
+
# the canonical lists and frozen-stringifying +from+ / +to+.
|
|
74
|
+
# Raises +ArgumentError+ on unknown values.
|
|
75
|
+
def self.build(from:, to:, kind:, path: nil, line: nil, column: nil, confidence: "syntax", raw: nil)
|
|
76
|
+
from = from.to_s.freeze
|
|
77
|
+
to = to.to_s.freeze
|
|
78
|
+
kind = validate_kind!(kind)
|
|
79
|
+
confidence = validate_confidence!(confidence)
|
|
80
|
+
new(
|
|
81
|
+
from: from,
|
|
82
|
+
to: to,
|
|
83
|
+
kind: kind,
|
|
84
|
+
path: path,
|
|
85
|
+
line: line,
|
|
86
|
+
column: column,
|
|
87
|
+
confidence: confidence,
|
|
88
|
+
raw: raw,
|
|
89
|
+
dedup_key: -"#{from}\x00#{to}\x00#{kind}\x00#{confidence}"
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.validate_kind!(kind) # :nodoc:
|
|
94
|
+
kind = kind.to_s
|
|
95
|
+
return kind if KINDS.include?(kind)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "unknown edge kind #{kind.inspect}; expected one of #{KINDS.inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.validate_confidence!(confidence) # :nodoc:
|
|
101
|
+
confidence = confidence.to_s
|
|
102
|
+
return confidence if CONFIDENCES.include?(confidence)
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "unknown confidence #{confidence.inspect}; expected one of #{CONFIDENCES.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The on-disk JSONL row. Nil-valued positional fields are
|
|
108
|
+
# omitted so a stand-alone edge (e.g. constructed in a test
|
|
109
|
+
# without a path) does not leak +"path":null+ noise.
|
|
110
|
+
def to_h
|
|
111
|
+
h = { "from" => from, "to" => to, "kind" => kind }
|
|
112
|
+
h["path"] = path if path
|
|
113
|
+
h["line"] = line if line
|
|
114
|
+
h["column"] = column if column
|
|
115
|
+
h["confidence"] = confidence
|
|
116
|
+
h["raw"] = raw if raw
|
|
117
|
+
h
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The payload embedded in a +:info+ diagnostic's message.
|
|
121
|
+
# Position is intentionally absent — the diagnostic carries
|
|
122
|
+
# its own +path+/+line+/+column+, so duplicating them here
|
|
123
|
+
# would just bloat output.
|
|
124
|
+
def to_message_payload
|
|
125
|
+
h = { "from" => from, "to" => to, "kind" => kind, "confidence" => confidence }
|
|
126
|
+
h["raw"] = raw if raw
|
|
127
|
+
h
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def to_json(*args)
|
|
131
|
+
JSON.generate(to_h, *args)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# +dedup_key+ is a generated Data accessor; no override
|
|
135
|
+
# needed. See the class header for the rationale.
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# JSONL reader / writer for Edge rows. Used by the plugin
|
|
139
|
+
# collector and the +rigor-module-graph+ renderer subcommands.
|
|
140
|
+
module EdgeIO
|
|
141
|
+
module_function
|
|
142
|
+
|
|
143
|
+
# Stream +edges+ to +io+ as JSONL, deduping by Edge#dedup_key
|
|
144
|
+
# so re-runs don't accumulate duplicate rows.
|
|
145
|
+
def write(edges, io)
|
|
146
|
+
seen = {}
|
|
147
|
+
edges.each do |edge|
|
|
148
|
+
key = edge.dedup_key
|
|
149
|
+
next if seen[key]
|
|
150
|
+
|
|
151
|
+
seen[key] = true
|
|
152
|
+
io.puts(JSON.generate(edge.to_h))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Parse JSONL from +io+ into Edge instances. Blank lines are
|
|
157
|
+
# skipped. Missing +confidence+ defaults to +"syntax"+ so the
|
|
158
|
+
# format stays backwards-compatible with earlier outputs.
|
|
159
|
+
def read(io)
|
|
160
|
+
edges = []
|
|
161
|
+
io.each_line do |line|
|
|
162
|
+
line = line.strip
|
|
163
|
+
next if line.empty?
|
|
164
|
+
|
|
165
|
+
row = JSON.parse(line)
|
|
166
|
+
edges << Edge.build(
|
|
167
|
+
from: row.fetch("from"),
|
|
168
|
+
to: row.fetch("to"),
|
|
169
|
+
kind: row.fetch("kind"),
|
|
170
|
+
path: row["path"],
|
|
171
|
+
line: row["line"],
|
|
172
|
+
column: row["column"],
|
|
173
|
+
confidence: row.fetch("confidence", "syntax"),
|
|
174
|
+
raw: row["raw"]
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
edges
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Self-contained HTML document that embeds Mermaid output
|
|
8
|
+
# inline so it renders without any local server (works over
|
|
9
|
+
# +file://+, no fetch).
|
|
10
|
+
#
|
|
11
|
+
# The view loads +mermaid@10+ from a CDN at render time. The
|
|
12
|
+
# only network access is that one CDN URL; if a project needs
|
|
13
|
+
# to ship a fully offline page, render the SVG via Graphviz
|
|
14
|
+
# and embed that instead.
|
|
15
|
+
#
|
|
16
|
+
# The HTML body lives in
|
|
17
|
+
# +lib/rigor/module_graph/templates/view.html.erb+; bumping
|
|
18
|
+
# styling or Mermaid init options is an edit of that file
|
|
19
|
+
# alone.
|
|
20
|
+
module HtmlView
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
TEMPLATE_PATH = File.expand_path("templates/view.html.erb", __dir__)
|
|
24
|
+
|
|
25
|
+
# @param title [String] page <title> and <h1> text
|
|
26
|
+
# @param mermaid_source [String] the mermaid flowchart body
|
|
27
|
+
# @param subtitle [String, nil] one-line caption under the H1
|
|
28
|
+
# @return [String] the rendered HTML document
|
|
29
|
+
def render(title:, mermaid_source:, subtitle: nil)
|
|
30
|
+
indented = mermaid_source.strip.gsub("\n", "\n ")
|
|
31
|
+
template.result_with_hash(
|
|
32
|
+
title: title,
|
|
33
|
+
subtitle: subtitle,
|
|
34
|
+
indented_mermaid: indented
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def template
|
|
39
|
+
@template ||= ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Minimal Rails-style inflection helpers. Used to infer a class
|
|
6
|
+
# name from a Rails association argument (+has_many :invoices+
|
|
7
|
+
# → +Invoice+).
|
|
8
|
+
#
|
|
9
|
+
# Deliberately tiny — we don't ship +ActiveSupport::Inflector+
|
|
10
|
+
# and its irregular-noun table; the plugin records its guess
|
|
11
|
+
# at +confidence: "syntax"+ so a downstream reviewer can spot
|
|
12
|
+
# mis-singularised plurals in the graph. Apps that need exact
|
|
13
|
+
# association class names should rely on +class_name:+ overrides
|
|
14
|
+
# in the source, which the Analyzer reads verbatim.
|
|
15
|
+
module Inflector
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
IRREGULAR_PLURALS = {
|
|
19
|
+
"people" => "person",
|
|
20
|
+
"men" => "man",
|
|
21
|
+
"women" => "woman",
|
|
22
|
+
"children" => "child",
|
|
23
|
+
"feet" => "foot",
|
|
24
|
+
"teeth" => "tooth",
|
|
25
|
+
"geese" => "goose",
|
|
26
|
+
"mice" => "mouse",
|
|
27
|
+
"lice" => "louse"
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# @param word [String]
|
|
31
|
+
# @return [String] best-effort singular form
|
|
32
|
+
def singularize(word)
|
|
33
|
+
downcased = word.downcase
|
|
34
|
+
return word.dup if word.empty?
|
|
35
|
+
return preserve_case(IRREGULAR_PLURALS[downcased], word) if IRREGULAR_PLURALS.key?(downcased)
|
|
36
|
+
return word[0..-4] + "y" if word =~ /ies\z/i && word.size > 3
|
|
37
|
+
return word[0..-3] if word =~ /ses\z/i # buses → bus, classes → clas... we accept loss
|
|
38
|
+
return word[0..-2] if word.end_with?("s") && !word.end_with?("ss")
|
|
39
|
+
|
|
40
|
+
word.dup
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# +"foo_bar_baz" → "FooBarBaz"+. Plain Rails camelize without
|
|
44
|
+
# acronym handling.
|
|
45
|
+
def camelize(word)
|
|
46
|
+
word.to_s.split("_").map { |seg| seg.empty? ? seg : seg[0].upcase + seg[1..] }.join
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# +"invoices" → "Invoice"+ — the common Rails association
|
|
50
|
+
# argument to class-name path.
|
|
51
|
+
def class_name_for(symbol_or_string)
|
|
52
|
+
camelize(singularize(symbol_or_string.to_s))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def preserve_case(replacement, original)
|
|
56
|
+
# The irregular table is lower-case; preserve a leading
|
|
57
|
+
# capital from the source so +People+ → +Person+.
|
|
58
|
+
return replacement.capitalize if original[0] =~ /[A-Z]/
|
|
59
|
+
|
|
60
|
+
replacement.dup
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|