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,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Restricts an edge list to the subgraph reachable from a set
|
|
6
|
+
# of root nodes within a hop limit.
|
|
7
|
+
#
|
|
8
|
+
# Used by the +--from+ / +--depth+ CLI flags to make a graph
|
|
9
|
+
# focused on one or a few constants tractable to look at on a
|
|
10
|
+
# large project (where dumping every edge produces 1000+-node
|
|
11
|
+
# Mermaid output that browsers refuse to render).
|
|
12
|
+
#
|
|
13
|
+
# Direction is configurable:
|
|
14
|
+
#
|
|
15
|
+
# +:out+:: follow edges in the natural direction
|
|
16
|
+
# (depends-on)
|
|
17
|
+
# +:in+:: follow edges backwards (depended-on-by)
|
|
18
|
+
# +:both+:: union of the two (the default — usually what
|
|
19
|
+
# "what's around Article?" means)
|
|
20
|
+
module Reachability
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
VALID_DIRECTIONS = %i[out in both].freeze
|
|
24
|
+
VALID_EDGE_SCOPES = %i[cluster walk].freeze
|
|
25
|
+
|
|
26
|
+
EMPTY = [].freeze
|
|
27
|
+
private_constant :EMPTY
|
|
28
|
+
|
|
29
|
+
# @param edges [Array<Edge>]
|
|
30
|
+
# @param roots [Array<String>] node names to start from
|
|
31
|
+
# @param depth [Integer, nil] hop limit, nil for unlimited
|
|
32
|
+
# @param direction [Symbol] one of VALID_DIRECTIONS
|
|
33
|
+
# @param edge_scope [Symbol] one of VALID_EDGE_SCOPES.
|
|
34
|
+
# +:cluster+ (default) keeps every edge whose endpoints
|
|
35
|
+
# both fall in the reachable node set — useful when you
|
|
36
|
+
# want the neighbourhood as a cluster. +:walk+ keeps only
|
|
37
|
+
# the edges the BFS itself traverses, so a
|
|
38
|
+
# +depth=1 --direction out+ walk from +Article+ returns
|
|
39
|
+
# exactly the edges whose +from+ is +Article+, never the
|
|
40
|
+
# sibling +inherits ApplicationRecord+ rows from the
|
|
41
|
+
# reached set.
|
|
42
|
+
# @return [Array<Edge>] filtered edges with original order
|
|
43
|
+
# preserved.
|
|
44
|
+
def filter(edges, roots:, depth: nil, direction: :both, edge_scope: :cluster)
|
|
45
|
+
roots = Array(roots).map(&:to_s).reject(&:empty?)
|
|
46
|
+
return edges if roots.empty?
|
|
47
|
+
|
|
48
|
+
unless VALID_DIRECTIONS.include?(direction)
|
|
49
|
+
raise ArgumentError, "unknown direction #{direction.inspect}; expected one of #{VALID_DIRECTIONS.inspect}"
|
|
50
|
+
end
|
|
51
|
+
unless VALID_EDGE_SCOPES.include?(edge_scope)
|
|
52
|
+
raise ArgumentError, "unknown edge_scope #{edge_scope.inspect}; expected one of #{VALID_EDGE_SCOPES.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
case edge_scope
|
|
56
|
+
when :cluster
|
|
57
|
+
reachable = walk(edges, roots, depth, direction)
|
|
58
|
+
edges.select { |e| reachable.key?(e.from) && reachable.key?(e.to) }
|
|
59
|
+
when :walk
|
|
60
|
+
indexes = walked_edge_indexes(edges, roots, depth, direction)
|
|
61
|
+
# `walked` is a Hash<edge_index, true>; filter by
|
|
62
|
+
# membership keeping the original edge order.
|
|
63
|
+
out = []
|
|
64
|
+
edges.each_with_index do |e, i|
|
|
65
|
+
out << e if indexes.key?(i)
|
|
66
|
+
end
|
|
67
|
+
out
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# BFS over the edge graph; returns a Hash<node_name, true>
|
|
72
|
+
# whose keys are the reachable nodes. We use a Hash instead
|
|
73
|
+
# of Set because Hash key lookup is faster on Ruby 4 and we
|
|
74
|
+
# don't need Set's union/intersection methods here.
|
|
75
|
+
def walk(edges, roots, depth, direction)
|
|
76
|
+
forward, backward = build_adjacency(edges, direction)
|
|
77
|
+
|
|
78
|
+
visited = {}
|
|
79
|
+
frontier = []
|
|
80
|
+
roots.each do |r|
|
|
81
|
+
visited[r] = true
|
|
82
|
+
frontier << r
|
|
83
|
+
end
|
|
84
|
+
hops = 0
|
|
85
|
+
|
|
86
|
+
until frontier.empty?
|
|
87
|
+
break if depth && hops >= depth
|
|
88
|
+
|
|
89
|
+
next_frontier = []
|
|
90
|
+
frontier.each do |node|
|
|
91
|
+
each_neighbour(node, forward, backward, direction) do |n|
|
|
92
|
+
next if visited.key?(n)
|
|
93
|
+
|
|
94
|
+
visited[n] = true
|
|
95
|
+
next_frontier << n
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
frontier = next_frontier
|
|
99
|
+
hops += 1
|
|
100
|
+
end
|
|
101
|
+
visited
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns a Hash<edge_index, true> for the edges actually
|
|
105
|
+
# traversed by the BFS. `direction=both` runs the out-walk
|
|
106
|
+
# and in-walk against separate adjacency tables so the
|
|
107
|
+
# zigzag chain (+A <- X -> Y+ whose +X -> Y+ isn't on any
|
|
108
|
+
# genuine path from the roots) stays excluded.
|
|
109
|
+
def walked_edge_indexes(edges, roots, depth, direction)
|
|
110
|
+
if direction == :both
|
|
111
|
+
forward, backward = build_indexed_adjacencies(edges)
|
|
112
|
+
walked = {}
|
|
113
|
+
run_indexed_walk(forward, roots, depth, walked)
|
|
114
|
+
run_indexed_walk(backward, roots, depth, walked)
|
|
115
|
+
return walked
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
adjacency = build_indexed_adjacency(edges, direction)
|
|
119
|
+
walked = {}
|
|
120
|
+
run_indexed_walk(adjacency, roots, depth, walked)
|
|
121
|
+
walked
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @api private
|
|
125
|
+
def build_adjacency(edges, direction)
|
|
126
|
+
# Build only the adjacency tables we actually need. The
|
|
127
|
+
# caller asking for +:out+ never touches +backward+ etc.
|
|
128
|
+
forward = direction == :in ? nil : Hash.new { |h, k| h[k] = [] }
|
|
129
|
+
backward = direction == :out ? nil : Hash.new { |h, k| h[k] = [] }
|
|
130
|
+
edges.each do |edge|
|
|
131
|
+
forward[edge.from] << edge.to if forward
|
|
132
|
+
backward[edge.to] << edge.from if backward
|
|
133
|
+
end
|
|
134
|
+
[forward, backward]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def each_neighbour(node, forward, backward, direction, &block)
|
|
138
|
+
case direction
|
|
139
|
+
when :out
|
|
140
|
+
forward.fetch(node, EMPTY).each(&block)
|
|
141
|
+
when :in
|
|
142
|
+
backward.fetch(node, EMPTY).each(&block)
|
|
143
|
+
when :both
|
|
144
|
+
forward.fetch(node, EMPTY).each(&block)
|
|
145
|
+
backward.fetch(node, EMPTY).each(&block)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build_indexed_adjacency(edges, direction)
|
|
150
|
+
adjacency = Hash.new { |h, k| h[k] = [] }
|
|
151
|
+
edges.each_with_index do |edge, i|
|
|
152
|
+
case direction
|
|
153
|
+
when :out then adjacency[edge.from] << [edge.to, i]
|
|
154
|
+
when :in then adjacency[edge.to] << [edge.from, i]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
adjacency
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def build_indexed_adjacencies(edges)
|
|
161
|
+
forward = Hash.new { |h, k| h[k] = [] }
|
|
162
|
+
backward = Hash.new { |h, k| h[k] = [] }
|
|
163
|
+
edges.each_with_index do |edge, i|
|
|
164
|
+
forward[edge.from] << [edge.to, i]
|
|
165
|
+
backward[edge.to] << [edge.from, i]
|
|
166
|
+
end
|
|
167
|
+
[forward, backward]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def run_indexed_walk(adjacency, roots, depth, walked)
|
|
171
|
+
visited = {}
|
|
172
|
+
frontier = []
|
|
173
|
+
roots.each do |r|
|
|
174
|
+
visited[r] = true
|
|
175
|
+
frontier << r
|
|
176
|
+
end
|
|
177
|
+
hops = 0
|
|
178
|
+
until frontier.empty?
|
|
179
|
+
break if depth && hops >= depth
|
|
180
|
+
|
|
181
|
+
next_frontier = []
|
|
182
|
+
frontier.each do |node|
|
|
183
|
+
adjacency.fetch(node, EMPTY).each do |neighbour, edge_index|
|
|
184
|
+
walked[edge_index] = true
|
|
185
|
+
next if visited.key?(neighbour)
|
|
186
|
+
|
|
187
|
+
visited[neighbour] = true
|
|
188
|
+
next_frontier << neighbour
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
frontier = next_frontier
|
|
192
|
+
hops += 1
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Computes per-namespace dependency metrics over an edge list.
|
|
6
|
+
#
|
|
7
|
+
# Five numbers per namespace:
|
|
8
|
+
#
|
|
9
|
+
# +nodes+:: number of distinct constants in the namespace
|
|
10
|
+
# +fan_out+:: edges whose +from+ is in the namespace and
|
|
11
|
+
# +to+ is outside it
|
|
12
|
+
# +fan_in+:: edges whose +to+ is in the namespace and
|
|
13
|
+
# +from+ is outside it
|
|
14
|
+
# +internal+:: edges where both endpoints sit in the namespace
|
|
15
|
+
# +total+:: +fan_out+ + +internal+ — every edge originating
|
|
16
|
+
# in the namespace
|
|
17
|
+
#
|
|
18
|
+
# Grouping is by top-level namespace by default
|
|
19
|
+
# (+Billing::Invoice+ → +Billing+). Pass +depth: N+ for a
|
|
20
|
+
# deeper split (+Billing::Invoice::Line+ at depth 2 →
|
|
21
|
+
# +Billing::Invoice+). Names without enough segments at the
|
|
22
|
+
# requested depth bucket under the special label
|
|
23
|
+
# +"(top-level)"+ so they stay visible in the report.
|
|
24
|
+
module Stats
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
TOP_LEVEL_BUCKET = "(top-level)"
|
|
28
|
+
|
|
29
|
+
# @param edges [Array<Edge>]
|
|
30
|
+
# @param depth [Integer] number of leading +::+ segments to
|
|
31
|
+
# keep when grouping
|
|
32
|
+
# @return [Array<NamespaceMetrics>] sorted by fan_out desc,
|
|
33
|
+
# then namespace asc; deterministic output.
|
|
34
|
+
def compute(edges, depth: 1)
|
|
35
|
+
# Single pass over edges. For each edge we resolve both
|
|
36
|
+
# endpoints to a normalised name (cached), then to a
|
|
37
|
+
# bucket (cached), then update the bucket's mutable
|
|
38
|
+
# `[nodes_set, fan_out, fan_in, internal]` counter array.
|
|
39
|
+
# Allocating one immutable `NamespaceMetrics` per edge via
|
|
40
|
+
# `Data#with` is what made the old implementation slow.
|
|
41
|
+
normalised = {}
|
|
42
|
+
groups = {}
|
|
43
|
+
counters = Hash.new { |h, k| h[k] = [{}, 0, 0, 0] }
|
|
44
|
+
|
|
45
|
+
edges.each do |edge|
|
|
46
|
+
from = (normalised[edge.from] ||= fast_normalise(edge.from))
|
|
47
|
+
to = (normalised[edge.to] ||= fast_normalise(edge.to))
|
|
48
|
+
|
|
49
|
+
from_group = (groups[from] ||= bucket_for_normalised(from, depth))
|
|
50
|
+
to_group = (groups[to] ||= bucket_for_normalised(to, depth))
|
|
51
|
+
|
|
52
|
+
from_counter = counters[from_group]
|
|
53
|
+
to_counter = counters[to_group]
|
|
54
|
+
from_counter[0][from] = true
|
|
55
|
+
to_counter[0][to] = true
|
|
56
|
+
|
|
57
|
+
if from_group == to_group
|
|
58
|
+
from_counter[3] += 1
|
|
59
|
+
else
|
|
60
|
+
from_counter[1] += 1
|
|
61
|
+
to_counter[2] += 1
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
metrics = counters.map do |namespace, counter|
|
|
66
|
+
NamespaceMetrics.new(
|
|
67
|
+
namespace: namespace,
|
|
68
|
+
nodes: counter[0].size,
|
|
69
|
+
fan_out: counter[1],
|
|
70
|
+
fan_in: counter[2],
|
|
71
|
+
internal: counter[3]
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
metrics.sort_by { |m| [-m.fan_out, m.namespace] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Strip leading "::" so absolute and relative names share
|
|
78
|
+
# the same bucket / node identity — they refer to the same
|
|
79
|
+
# constant either way.
|
|
80
|
+
def fast_normalise(name)
|
|
81
|
+
name.start_with?("::") ? name[2..] : name
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Like +bucket_for+, but skips the +split+ allocation when
|
|
85
|
+
# the name has more segments than +depth+. Walks +index+
|
|
86
|
+
# once per +::+ separator and slices once at the boundary.
|
|
87
|
+
def bucket_for_normalised(name, depth)
|
|
88
|
+
cursor = -2
|
|
89
|
+
depth.times do
|
|
90
|
+
cursor = name.index("::", cursor + 2)
|
|
91
|
+
return TOP_LEVEL_BUCKET unless cursor
|
|
92
|
+
end
|
|
93
|
+
name[0...cursor]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The five numbers for one namespace, exposed as a Data
|
|
97
|
+
# value so callers can treat the result like a row of a
|
|
98
|
+
# table.
|
|
99
|
+
NamespaceMetrics = Data.define(:namespace, :nodes, :fan_out, :fan_in, :internal) do
|
|
100
|
+
# Sum of edges originating in the namespace
|
|
101
|
+
# (fan_out + internal).
|
|
102
|
+
def total
|
|
103
|
+
fan_out + internal
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def to_h
|
|
107
|
+
{
|
|
108
|
+
"namespace" => namespace,
|
|
109
|
+
"nodes" => nodes,
|
|
110
|
+
"fan_out" => fan_out,
|
|
111
|
+
"fan_in" => fan_in,
|
|
112
|
+
"internal" => internal,
|
|
113
|
+
"total" => total
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title><%= title %></title>
|
|
6
|
+
<script type="module">
|
|
7
|
+
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
|
|
8
|
+
mermaid.initialize({
|
|
9
|
+
startOnLoad: true,
|
|
10
|
+
securityLevel: "loose",
|
|
11
|
+
maxTextSize: 5_000_000,
|
|
12
|
+
maxEdges: 50_000,
|
|
13
|
+
flowchart: { useMaxWidth: false, htmlLabels: true }
|
|
14
|
+
});
|
|
15
|
+
</script>
|
|
16
|
+
<style>
|
|
17
|
+
body { font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem; color: #0f172a; background: #f8fafc; }
|
|
18
|
+
h1 { margin-top: 0; }
|
|
19
|
+
.meta { color: #64748b; margin-bottom: 1.5rem; }
|
|
20
|
+
.card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1.5rem; box-shadow: 0 1px 2px rgba(15,23,42,0.04); }
|
|
21
|
+
.legend { display: flex; gap: 1rem; flex-wrap: wrap; margin-top: 1rem; }
|
|
22
|
+
.legend span { padding: 0.25rem 0.75rem; border-radius: 999px; color: white; font-size: 12px; }
|
|
23
|
+
.legend .inherits { background: #0f172a; }
|
|
24
|
+
.legend .include { background: #1d4ed8; }
|
|
25
|
+
.legend .prepend { background: #9333ea; }
|
|
26
|
+
.legend .extend { background: #0f766e; }
|
|
27
|
+
.legend .const_ref { background: #94a3b8; color: #0f172a; }
|
|
28
|
+
.legend .unresolved { background: #fef3c7; color: #0f172a; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<h1><%= title %></h1>
|
|
33
|
+
<% if subtitle -%>
|
|
34
|
+
<p class="meta"><%= subtitle %></p>
|
|
35
|
+
<% end -%>
|
|
36
|
+
<div class="card">
|
|
37
|
+
<pre class="mermaid">
|
|
38
|
+
<%= indented_mermaid %>
|
|
39
|
+
</pre>
|
|
40
|
+
</div>
|
|
41
|
+
<div class="legend">
|
|
42
|
+
<span class="inherits">inherits</span>
|
|
43
|
+
<span class="include">include</span>
|
|
44
|
+
<span class="prepend">prepend</span>
|
|
45
|
+
<span class="extend">extend</span>
|
|
46
|
+
<span class="const_ref">const_ref</span>
|
|
47
|
+
<span class="unresolved">unresolved</span>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
module Uml
|
|
6
|
+
# Renders a +classDiagram+ Mermaid document from a list of
|
|
7
|
+
# edges plus a list of node metadata rows (the +nodes.jsonl+
|
|
8
|
+
# the collector writes alongside +edges.jsonl+).
|
|
9
|
+
#
|
|
10
|
+
# Differences from the +flowchart+ renderer:
|
|
11
|
+
#
|
|
12
|
+
# * Each class / module gets a body block listing its
|
|
13
|
+
# instance methods, class methods, and attributes, with the
|
|
14
|
+
# standard UML visibility glyphs (+, -, #).
|
|
15
|
+
# * Modules are annotated +<<module>>+ so a Ruby module is
|
|
16
|
+
# visually distinct from a class.
|
|
17
|
+
# * +inherits+ uses +--|>+, mixin uses +..|>+, +const_ref+
|
|
18
|
+
# uses +..>+, ActiveRecord associations carry cardinality
|
|
19
|
+
# pairs ("1" / "*") as edge endpoints.
|
|
20
|
+
# * Mermaid disallows +::+ in class identifiers; we sanitise
|
|
21
|
+
# to +__+ and keep the original as the label only.
|
|
22
|
+
#
|
|
23
|
+
# Filtering knobs:
|
|
24
|
+
#
|
|
25
|
+
# * +include_methods:+ (default true) — show methods inside
|
|
26
|
+
# class bodies.
|
|
27
|
+
# * +include_attributes:+ (default true) — show attributes.
|
|
28
|
+
# * +visibilities:+ — array subset of +%w[public protected
|
|
29
|
+
# private]+, default all.
|
|
30
|
+
module ClassDiagram
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
VISIBILITY_GLYPH = {
|
|
34
|
+
"public" => "+",
|
|
35
|
+
"protected" => "#",
|
|
36
|
+
"private" => "-"
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
ARROW_FOR_KIND = {
|
|
40
|
+
"inherits" => "<|--",
|
|
41
|
+
"include" => "<|..",
|
|
42
|
+
"prepend" => "<|..",
|
|
43
|
+
"extend" => "<|..",
|
|
44
|
+
"const_ref" => "<.."
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
CARDINALITY = {
|
|
48
|
+
"has_many" => ['"1"', '"*"'],
|
|
49
|
+
"belongs_to" => ['"*"', '"1"'],
|
|
50
|
+
"has_one" => ['"1"', '"1"'],
|
|
51
|
+
"has_and_belongs_to_many" => ['"*"', '"*"']
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def render(edges, nodes,
|
|
55
|
+
include_methods: true,
|
|
56
|
+
include_attributes: true,
|
|
57
|
+
visibilities: %w[public protected private])
|
|
58
|
+
declarations = node_declarations(nodes)
|
|
59
|
+
members = node_members(nodes, include_methods, include_attributes, visibilities)
|
|
60
|
+
|
|
61
|
+
out = +"classDiagram\n"
|
|
62
|
+
render_classes(out, declarations, members, edges)
|
|
63
|
+
render_edges(out, dedup(edges))
|
|
64
|
+
out
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# +Foo::Bar+ can't be a Mermaid identifier; coerce to a
|
|
68
|
+
# safe form. The label always carries the original.
|
|
69
|
+
def safe_id(name)
|
|
70
|
+
name.gsub(/[^A-Za-z0-9_]+/, "__")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dedup(edges)
|
|
74
|
+
seen = {}
|
|
75
|
+
edges.each_with_object([]) do |edge, acc|
|
|
76
|
+
key = edge.dedup_key
|
|
77
|
+
next if seen[key]
|
|
78
|
+
|
|
79
|
+
seen[key] = true
|
|
80
|
+
acc << edge
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Build a +{name => "class"|"module"}+ table from the
|
|
85
|
+
# node-declaration rows.
|
|
86
|
+
def node_declarations(nodes)
|
|
87
|
+
decl = {}
|
|
88
|
+
nodes.each do |row|
|
|
89
|
+
case row.kind
|
|
90
|
+
when "class", "module"
|
|
91
|
+
# Re-opens may set the same row multiple times —
|
|
92
|
+
# whichever wins doesn't matter because the kind is
|
|
93
|
+
# the same.
|
|
94
|
+
decl[row.name] = row.kind
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
decl
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Build a +{owner_name => [{glyph, name, label}, ...]}+
|
|
101
|
+
# table covering the displayable members for every owner.
|
|
102
|
+
def node_members(nodes, include_methods, include_attributes, visibilities)
|
|
103
|
+
members = Hash.new { |h, k| h[k] = [] }
|
|
104
|
+
nodes.each do |row|
|
|
105
|
+
owner = row.owner
|
|
106
|
+
next if owner.nil?
|
|
107
|
+
|
|
108
|
+
visibility = row.visibility || "public"
|
|
109
|
+
next unless visibilities.include?(visibility)
|
|
110
|
+
|
|
111
|
+
glyph = VISIBILITY_GLYPH.fetch(visibility, "+")
|
|
112
|
+
|
|
113
|
+
case row.kind
|
|
114
|
+
when "instance_method", "class_method"
|
|
115
|
+
next unless include_methods
|
|
116
|
+
|
|
117
|
+
suffix = row.kind == "class_method" ? "$ " : ""
|
|
118
|
+
members[owner] << "#{glyph}#{row.name}() #{suffix}".strip
|
|
119
|
+
when "attribute"
|
|
120
|
+
next unless include_attributes
|
|
121
|
+
|
|
122
|
+
# access (read/write/accessor) hints at getter/setter
|
|
123
|
+
# presence; we annotate it after the name.
|
|
124
|
+
members[owner] << "#{glyph}#{row.name} : #{row.access}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
members
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Emit one +class Foo+ line per node, plus a body block of
|
|
131
|
+
# methods / attributes when we have any.
|
|
132
|
+
#
|
|
133
|
+
# We intentionally do NOT emit the UML +<<module>>+
|
|
134
|
+
# annotation: Mermaid 10.x's classDiagram parser silently
|
|
135
|
+
# rejects the document when an annotation co-exists with
|
|
136
|
+
# the +class Foo["Label"]+ form we need for namespaced
|
|
137
|
+
# constants, and rejecting the namespace label is worse for
|
|
138
|
+
# a Ruby graph than losing the module marker. The module
|
|
139
|
+
# vs class distinction is therefore encoded as a +" (mod)"+
|
|
140
|
+
# label suffix for module nodes — it is rendered inside the
|
|
141
|
+
# box where every Mermaid renderer surfaces it.
|
|
142
|
+
#
|
|
143
|
+
# Any class that appears only in an edge keeps its bare
|
|
144
|
+
# +class Foo+ line so the arrow has a target.
|
|
145
|
+
def render_classes(out, declarations, members, edges)
|
|
146
|
+
known = Set.new(declarations.keys)
|
|
147
|
+
edges.each do |edge|
|
|
148
|
+
known << edge.from << edge.to
|
|
149
|
+
end
|
|
150
|
+
known.sort.each do |name|
|
|
151
|
+
id = safe_id(name)
|
|
152
|
+
kind = declarations[name]
|
|
153
|
+
label = label_for(name, kind)
|
|
154
|
+
label_suffix = (label == id ? "" : "[\"#{label}\"]")
|
|
155
|
+
out << " class #{id}#{label_suffix}\n"
|
|
156
|
+
|
|
157
|
+
owner_members = members[name]
|
|
158
|
+
next if owner_members.nil? || owner_members.empty?
|
|
159
|
+
|
|
160
|
+
out << " class #{id} {\n"
|
|
161
|
+
owner_members.each do |line|
|
|
162
|
+
out << " #{line}\n"
|
|
163
|
+
end
|
|
164
|
+
out << " }\n"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def label_for(name, kind)
|
|
169
|
+
if kind == "module"
|
|
170
|
+
"#{name} «module»"
|
|
171
|
+
else
|
|
172
|
+
name
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def render_edges(out, edges)
|
|
177
|
+
out << "\n" unless edges.empty?
|
|
178
|
+
edges.each do |edge|
|
|
179
|
+
from_id = safe_id(edge.from)
|
|
180
|
+
to_id = safe_id(edge.to)
|
|
181
|
+
if (cardinality = CARDINALITY[edge.kind])
|
|
182
|
+
left, right = cardinality
|
|
183
|
+
# `from --> to : has_many` — Mermaid renders this as
|
|
184
|
+
# an association arrow with the kind label.
|
|
185
|
+
out << " #{to_id} #{left} -- #{right} #{from_id} : #{edge.kind}\n"
|
|
186
|
+
elsif (arrow = ARROW_FOR_KIND[edge.kind])
|
|
187
|
+
out << " #{to_id} #{arrow} #{from_id} : #{edge.kind}\n"
|
|
188
|
+
else
|
|
189
|
+
out << " #{to_id} <-- #{from_id} : #{edge.kind}\n"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
# The +rigor-module-graph+ gem's public API. See the README for
|
|
5
|
+
# an overview and Rigor::ModuleGraph::CLI for the CLI surface.
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# The installed gem version.
|
|
8
|
+
VERSION = "0.1.0"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Pre-walks a Prism tree once and records each +DefNode+'s
|
|
8
|
+
# effective visibility based on the running +public+ /
|
|
9
|
+
# +protected+ / +private+ marker calls inside its enclosing
|
|
10
|
+
# class / module body.
|
|
11
|
+
#
|
|
12
|
+
# The Analyzer's per-node rules can then look up a visibility
|
|
13
|
+
# in O(1) without re-walking the surrounding body. Built once
|
|
14
|
+
# per file by the plugin's +node_file_context+ hook.
|
|
15
|
+
#
|
|
16
|
+
# Limitations (acknowledged):
|
|
17
|
+
#
|
|
18
|
+
# * +private :foo+ (explicit symbol form) is ignored; only the
|
|
19
|
+
# bare keyword that flips the running visibility is honoured.
|
|
20
|
+
# The bare form covers ~90% of Ruby; the symbol form mostly
|
|
21
|
+
# shows up in DSL-generated method blocks.
|
|
22
|
+
# * +private_class_method+ and singleton-class blocks
|
|
23
|
+
# (+class << self+) are not interpreted.
|
|
24
|
+
# * Methods inside a +class+ inside a method body (rare) stay
|
|
25
|
+
# at +public+.
|
|
26
|
+
class VisibilityMap
|
|
27
|
+
VISIBILITY_MARKERS = %i[public protected private].freeze
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@table = {}.compare_by_identity
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param root [Prism::Node]
|
|
34
|
+
# @return [VisibilityMap]
|
|
35
|
+
def self.build(root)
|
|
36
|
+
map = new
|
|
37
|
+
walk_top_level(root, map) if root
|
|
38
|
+
map
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def visibility_for(node)
|
|
42
|
+
@table[node]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.walk_top_level(node, map)
|
|
46
|
+
return unless node.is_a?(Prism::Node)
|
|
47
|
+
|
|
48
|
+
# The top level is a module-like scope; defs there read as
|
|
49
|
+
# public. Modules nested below get their own pass with the
|
|
50
|
+
# visibility reset to public.
|
|
51
|
+
case node
|
|
52
|
+
when Prism::ProgramNode
|
|
53
|
+
walk_top_level(node.statements, map)
|
|
54
|
+
when Prism::StatementsNode
|
|
55
|
+
node.body.each { |child| walk_top_level(child, map) }
|
|
56
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
57
|
+
walk_body(node, map)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.walk_body(class_or_module, map)
|
|
62
|
+
body = class_or_module.body
|
|
63
|
+
statements = body.respond_to?(:body) ? Array(body.body) : []
|
|
64
|
+
current = "public"
|
|
65
|
+
|
|
66
|
+
statements.each do |stmt|
|
|
67
|
+
case stmt
|
|
68
|
+
when Prism::CallNode
|
|
69
|
+
if VISIBILITY_MARKERS.include?(stmt.name) && bare_marker?(stmt)
|
|
70
|
+
current = stmt.name.to_s
|
|
71
|
+
end
|
|
72
|
+
when Prism::DefNode
|
|
73
|
+
map.record(stmt, current)
|
|
74
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
75
|
+
walk_body(stmt, map)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.bare_marker?(call_node)
|
|
81
|
+
call_node.receiver.nil? &&
|
|
82
|
+
(call_node.arguments.nil? || call_node.arguments.arguments.empty?)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def record(node, visibility)
|
|
86
|
+
@table[node] = visibility
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|