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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Renders edges as a Mermaid flowchart.
|
|
6
|
+
#
|
|
7
|
+
# Mermaid does not have per-edge style classes the way DOT does;
|
|
8
|
+
# we use distinct arrow heads per kind (`==>`, `-->`, `-.->`)
|
|
9
|
+
# plus an `:::kind` classDef on the target node so the legend is
|
|
10
|
+
# readable in any Mermaid renderer.
|
|
11
|
+
#
|
|
12
|
+
# When `collapse:` is given, every node whose name sits under one
|
|
13
|
+
# of the listed prefixes is wrapped in a `subgraph <prefix>`
|
|
14
|
+
# block, with the prefix stripped from the visible label.
|
|
15
|
+
module Mermaid
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
ARROW_FOR_KIND = {
|
|
19
|
+
"inherits" => "==>",
|
|
20
|
+
"include" => "-->",
|
|
21
|
+
"prepend" => "-->",
|
|
22
|
+
"extend" => "-.->",
|
|
23
|
+
"const_ref" => "-.->"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
CLASS_DEFS = <<~MERMAID
|
|
27
|
+
classDef inherits fill:#0f172a,color:#fff,stroke:#0f172a;
|
|
28
|
+
classDef include fill:#1d4ed8,color:#fff,stroke:#1d4ed8;
|
|
29
|
+
classDef prepend fill:#9333ea,color:#fff,stroke:#9333ea;
|
|
30
|
+
classDef extend fill:#0f766e,color:#fff,stroke:#0f766e;
|
|
31
|
+
classDef const_ref fill:#cbd5e1,color:#0f172a,stroke:#94a3b8;
|
|
32
|
+
classDef unresolved fill:#fef3c7,color:#0f172a,stroke:#d97706,stroke-dasharray: 4 4;
|
|
33
|
+
MERMAID
|
|
34
|
+
|
|
35
|
+
# @param edges [Array<Edge>]
|
|
36
|
+
# @param collapse [Array<String>] namespace prefixes to fold
|
|
37
|
+
# into subgraphs (mutually exclusive with +groups+)
|
|
38
|
+
# @param groups [Hash{String=>String}, nil] explicit
|
|
39
|
+
# +{node_name => cluster_label}+ mapping. Takes precedence
|
|
40
|
+
# over +collapse+ when given.
|
|
41
|
+
def render(edges, collapse: [], groups: nil)
|
|
42
|
+
edges = dedup(edges)
|
|
43
|
+
node_ids = assign_node_ids(edges)
|
|
44
|
+
clusters, ungrouped = build_groups(node_ids.keys.sort, collapse, groups)
|
|
45
|
+
|
|
46
|
+
out = +"flowchart LR\n"
|
|
47
|
+
clusters.each do |label, members|
|
|
48
|
+
out << render_cluster(label, members, node_ids, use_namespace_prefix: groups.nil?)
|
|
49
|
+
end
|
|
50
|
+
ungrouped.each do |name|
|
|
51
|
+
out << " #{node_ids[name]}[\"#{escape_label(name)}\"]\n"
|
|
52
|
+
end
|
|
53
|
+
out << "\n" unless node_ids.empty?
|
|
54
|
+
edges.each do |edge|
|
|
55
|
+
arrow = ARROW_FOR_KIND.fetch(edge.kind, "-->")
|
|
56
|
+
out << " #{node_ids[edge.from]} #{arrow}|#{edge.kind}| #{node_ids[edge.to]}\n"
|
|
57
|
+
end
|
|
58
|
+
out << "\n"
|
|
59
|
+
out << CLASS_DEFS
|
|
60
|
+
# One class assignment per node id. Mermaid silently keeps
|
|
61
|
+
# the last assignment but starts to error out when the same
|
|
62
|
+
# `class N kind;` line repeats many hundreds of times in a
|
|
63
|
+
# large graph, so we dedupe and pick the most structural
|
|
64
|
+
# kind per node (inherits > include > prepend > extend >
|
|
65
|
+
# const_ref) so the resulting colour conveys intent.
|
|
66
|
+
out << render_class_assignments(edges, node_ids)
|
|
67
|
+
out
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
KIND_PRIORITY = {
|
|
71
|
+
"inherits" => 0,
|
|
72
|
+
"include" => 1,
|
|
73
|
+
"prepend" => 2,
|
|
74
|
+
"extend" => 3,
|
|
75
|
+
"const_ref" => 4
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
def render_class_assignments(edges, node_ids)
|
|
79
|
+
per_node = {}
|
|
80
|
+
edges.each do |edge|
|
|
81
|
+
id = node_ids[edge.to]
|
|
82
|
+
tag = edge.confidence == "unresolved" ? "unresolved" : edge.kind
|
|
83
|
+
current = per_node[id]
|
|
84
|
+
if current.nil? || better_tag?(tag, current)
|
|
85
|
+
per_node[id] = tag
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
per_node.map { |id, tag| " class #{id} #{tag};\n" }.join
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def better_tag?(candidate, current)
|
|
92
|
+
return false if current == "unresolved"
|
|
93
|
+
return true if candidate == "unresolved"
|
|
94
|
+
|
|
95
|
+
(KIND_PRIORITY[candidate] || 99) < (KIND_PRIORITY[current] || 99)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def dedup(edges)
|
|
99
|
+
seen = {}
|
|
100
|
+
edges.each_with_object([]) do |edge, acc|
|
|
101
|
+
key = edge.dedup_key
|
|
102
|
+
next if seen[key]
|
|
103
|
+
|
|
104
|
+
seen[key] = true
|
|
105
|
+
acc << edge
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def assign_node_ids(edges)
|
|
110
|
+
names = edges.flat_map { |edge| [edge.from, edge.to] }.uniq.sort
|
|
111
|
+
names.each_with_index.to_h { |name, idx| [name, "n#{idx}"] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def build_groups(names, collapse, groups)
|
|
115
|
+
if groups && !groups.empty?
|
|
116
|
+
clusters = Hash.new { |h, k| h[k] = [] }
|
|
117
|
+
ungrouped = []
|
|
118
|
+
names.each do |name|
|
|
119
|
+
if (label = groups[name])
|
|
120
|
+
clusters[label] << name
|
|
121
|
+
else
|
|
122
|
+
ungrouped << name
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
[clusters, ungrouped]
|
|
126
|
+
else
|
|
127
|
+
group_by_prefix(names, collapse)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def group_by_prefix(names, collapse)
|
|
132
|
+
prefixes = Array(collapse).map(&:to_s).reject(&:empty?)
|
|
133
|
+
return [{}, names] if prefixes.empty?
|
|
134
|
+
|
|
135
|
+
sorted = prefixes.sort_by { |p| -p.length }
|
|
136
|
+
clusters = Hash.new { |h, k| h[k] = [] }
|
|
137
|
+
ungrouped = []
|
|
138
|
+
names.each do |name|
|
|
139
|
+
match = sorted.find { |p| name.start_with?(p + "::") }
|
|
140
|
+
if match
|
|
141
|
+
clusters[match] << name
|
|
142
|
+
else
|
|
143
|
+
ungrouped << name
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
[clusters, ungrouped]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def render_cluster(label, members, node_ids, use_namespace_prefix: true)
|
|
150
|
+
out = +" subgraph #{cluster_id(label)} [\"#{escape_label(label)}\"]\n"
|
|
151
|
+
members.each do |name|
|
|
152
|
+
short = use_namespace_prefix ? name.sub(/\A#{Regexp.escape(label)}::/, "") : name
|
|
153
|
+
out << " #{node_ids[name]}[\"#{escape_label(short)}\"]\n"
|
|
154
|
+
end
|
|
155
|
+
out << " end\n"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Mermaid subgraph ids must be plain identifiers; anything
|
|
159
|
+
# else breaks the parser silently. Coerce non-alnum
|
|
160
|
+
# characters to `_` so `packages/billing` ends up as
|
|
161
|
+
# `sg_packages_billing` and stays unambiguous.
|
|
162
|
+
def cluster_id(prefix)
|
|
163
|
+
"sg_#{prefix.gsub(/[^A-Za-z0-9_]+/, "_")}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def escape_label(name)
|
|
167
|
+
name.gsub('"', "#quot;")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Kinds of node metadata the plugin emits. Class and module
|
|
8
|
+
# declarations carry the class itself; the remaining kinds are
|
|
9
|
+
# children of one — methods and attributes.
|
|
10
|
+
NODE_KINDS = %w[
|
|
11
|
+
class
|
|
12
|
+
module
|
|
13
|
+
instance_method
|
|
14
|
+
class_method
|
|
15
|
+
attribute
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
# Visibility values for a method / attribute. Matches the Ruby
|
|
19
|
+
# access modifiers.
|
|
20
|
+
NODE_VISIBILITIES = %w[public protected private].freeze
|
|
21
|
+
|
|
22
|
+
# Access flavours for an +attr_*+ macro. Used to label
|
|
23
|
+
# attribute glyphs in a class diagram and nothing else.
|
|
24
|
+
NODE_ACCESSES = %w[read write accessor].freeze
|
|
25
|
+
|
|
26
|
+
# A piece of node metadata extracted from a source file.
|
|
27
|
+
#
|
|
28
|
+
# Three flavours, distinguished by +kind+:
|
|
29
|
+
#
|
|
30
|
+
# * +class+ / +module+ — a constant declaration. Carries
|
|
31
|
+
# +name+, +path+, +line+, +column+. +owner+/+visibility+/
|
|
32
|
+
# +access+ are nil.
|
|
33
|
+
# * +instance_method+ / +class_method+ — a method definition.
|
|
34
|
+
# Carries +name+, +owner+ (the enclosing class/module),
|
|
35
|
+
# +visibility+, +path+, +line+, +column+.
|
|
36
|
+
# * +attribute+ — an +attr_reader+ / +attr_writer+ /
|
|
37
|
+
# +attr_accessor+ symbol. Carries +name+, +owner+,
|
|
38
|
+
# +visibility+, +access+, +path+, +line+, +column+.
|
|
39
|
+
class Node < Data.define(:kind, :name, :owner, :path, :line, :column, :visibility, :access)
|
|
40
|
+
KINDS = NODE_KINDS
|
|
41
|
+
VISIBILITIES = NODE_VISIBILITIES
|
|
42
|
+
ACCESSES = NODE_ACCESSES
|
|
43
|
+
|
|
44
|
+
def self.build(kind:, name:, owner: nil, path: nil, line: nil, column: nil,
|
|
45
|
+
visibility: nil, access: nil)
|
|
46
|
+
new(
|
|
47
|
+
kind: validate_kind!(kind),
|
|
48
|
+
name: name.to_s.freeze,
|
|
49
|
+
owner: owner && owner.to_s.freeze,
|
|
50
|
+
path: path,
|
|
51
|
+
line: line,
|
|
52
|
+
column: column,
|
|
53
|
+
visibility: visibility && validate_visibility!(visibility),
|
|
54
|
+
access: access && validate_access!(access)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.validate_kind!(kind) # :nodoc:
|
|
59
|
+
kind = kind.to_s
|
|
60
|
+
return kind if KINDS.include?(kind)
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "unknown node kind #{kind.inspect}; expected one of #{KINDS.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.validate_visibility!(visibility) # :nodoc:
|
|
66
|
+
visibility = visibility.to_s
|
|
67
|
+
return visibility if VISIBILITIES.include?(visibility)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "unknown visibility #{visibility.inspect}; expected one of #{VISIBILITIES.inspect}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.validate_access!(access) # :nodoc:
|
|
73
|
+
access = access.to_s
|
|
74
|
+
return access if ACCESSES.include?(access)
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "unknown access #{access.inspect}; expected one of #{ACCESSES.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
h = { "kind" => kind, "name" => name }
|
|
81
|
+
h["owner"] = owner if owner
|
|
82
|
+
h["path"] = path if path
|
|
83
|
+
h["line"] = line if line
|
|
84
|
+
h["column"] = column if column
|
|
85
|
+
h["visibility"] = visibility if visibility
|
|
86
|
+
h["access"] = access if access
|
|
87
|
+
h
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The payload embedded in the plugin's +:info+ diagnostic
|
|
91
|
+
# message. Position is intentionally absent — the diagnostic
|
|
92
|
+
# row carries +path+/+line+/+column+ on its own.
|
|
93
|
+
def to_message_payload
|
|
94
|
+
h = { "kind" => kind, "name" => name }
|
|
95
|
+
h["owner"] = owner if owner
|
|
96
|
+
h["visibility"] = visibility if visibility
|
|
97
|
+
h["access"] = access if access
|
|
98
|
+
h
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Key used to dedupe node rows. Two declarations of the same
|
|
102
|
+
# method on the same owner collapse to one row; class re-opens
|
|
103
|
+
# collapse to one class node.
|
|
104
|
+
def dedup_key
|
|
105
|
+
[kind, owner, name]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# JSONL reader / writer for Node rows.
|
|
110
|
+
module NodeIO
|
|
111
|
+
module_function
|
|
112
|
+
|
|
113
|
+
def write(nodes, io)
|
|
114
|
+
seen = {}
|
|
115
|
+
nodes.each do |node|
|
|
116
|
+
key = node.dedup_key
|
|
117
|
+
next if seen[key]
|
|
118
|
+
|
|
119
|
+
seen[key] = true
|
|
120
|
+
io.puts(JSON.generate(node.to_h))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def read(io)
|
|
125
|
+
nodes = []
|
|
126
|
+
io.each_line do |line|
|
|
127
|
+
line = line.strip
|
|
128
|
+
next if line.empty?
|
|
129
|
+
|
|
130
|
+
row = JSON.parse(line)
|
|
131
|
+
nodes << Node.build(
|
|
132
|
+
kind: row.fetch("kind"),
|
|
133
|
+
name: row.fetch("name"),
|
|
134
|
+
owner: row["owner"],
|
|
135
|
+
path: row["path"],
|
|
136
|
+
line: row["line"],
|
|
137
|
+
column: row["column"],
|
|
138
|
+
visibility: row["visibility"],
|
|
139
|
+
access: row["access"]
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
nodes
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module ModuleGraph
|
|
7
|
+
# Discovers Packwerk-style packages (`package.yml`) inside a
|
|
8
|
+
# project tree and maps source file paths to their owning
|
|
9
|
+
# package.
|
|
10
|
+
#
|
|
11
|
+
# Treats every directory that contains a +package.yml+ as a
|
|
12
|
+
# package root. The package's name is its path relative to the
|
|
13
|
+
# project root with a leading +./+ stripped — that's how
|
|
14
|
+
# +packwerk+ itself reports them, and it's stable across
|
|
15
|
+
# Packwerk versions which gives the renderer something to use
|
|
16
|
+
# as the cluster label.
|
|
17
|
+
#
|
|
18
|
+
# Files map to the +deepest+ ancestor package — if a nested
|
|
19
|
+
# `packages/billing/invoices/package.yml` lives under
|
|
20
|
+
# `packages/billing/package.yml`, a file under the inner one
|
|
21
|
+
# belongs to +packages/billing/invoices+, not +packages/billing+.
|
|
22
|
+
class PackwerkOverlay
|
|
23
|
+
Package = Data.define(:name, :root) do
|
|
24
|
+
# Stable rendering for snapshot / debug output.
|
|
25
|
+
def to_s
|
|
26
|
+
"Package(#{name})"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
EXCLUDED_DIRS = %w[.git node_modules tmp log vendor].freeze
|
|
31
|
+
private_constant :EXCLUDED_DIRS
|
|
32
|
+
|
|
33
|
+
attr_reader :project_root, :packages
|
|
34
|
+
|
|
35
|
+
# @param project_root [String] the project root the packages
|
|
36
|
+
# are reported relative to
|
|
37
|
+
# @param packages [Array<Package>] frozen
|
|
38
|
+
def initialize(project_root:, packages:)
|
|
39
|
+
@project_root = realpath_or_expand(project_root)
|
|
40
|
+
@packages = packages
|
|
41
|
+
.map { |pkg| Package.new(name: pkg.name, root: realpath_or_expand(pkg.root)) }
|
|
42
|
+
.sort_by { |p| -p.root.length }
|
|
43
|
+
.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @param project_root [String]
|
|
47
|
+
# @return [PackwerkOverlay]
|
|
48
|
+
def self.discover(project_root)
|
|
49
|
+
root = File.expand_path(project_root)
|
|
50
|
+
packages = []
|
|
51
|
+
Find.find(root) do |path|
|
|
52
|
+
base = File.basename(path)
|
|
53
|
+
if File.directory?(path) && EXCLUDED_DIRS.include?(base) && path != root
|
|
54
|
+
Find.prune
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
next unless File.file?(path) && base == "package.yml"
|
|
58
|
+
|
|
59
|
+
pkg_root = File.dirname(path)
|
|
60
|
+
packages << Package.new(name: package_name(pkg_root, root), root: pkg_root)
|
|
61
|
+
end
|
|
62
|
+
new(project_root: root, packages: packages)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.package_name(pkg_root, project_root)
|
|
66
|
+
# The root package (a `package.yml` at the project root) is
|
|
67
|
+
# canonically called `.` in Packwerk output. Match that so
|
|
68
|
+
# users see the familiar label.
|
|
69
|
+
return "." if pkg_root == project_root
|
|
70
|
+
|
|
71
|
+
rel = pkg_root.sub(%r{\A#{Regexp.escape(project_root)}/?}, "")
|
|
72
|
+
rel.empty? ? "." : rel
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Boolean] true when at least one package.yml was
|
|
76
|
+
# found.
|
|
77
|
+
def any?
|
|
78
|
+
!@packages.empty?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Find the deepest package whose root is an ancestor of
|
|
82
|
+
# +path+. Returns nil when the path is outside every
|
|
83
|
+
# package's root.
|
|
84
|
+
#
|
|
85
|
+
# Both sides are normalised through +realpath+ when
|
|
86
|
+
# possible so a macOS +/tmp+ ↔ +/private/tmp+ symlink (or
|
|
87
|
+
# any other symlink in the project root path) doesn't make
|
|
88
|
+
# the comparison spuriously miss.
|
|
89
|
+
def package_for(path)
|
|
90
|
+
return nil if path.nil? || path.empty?
|
|
91
|
+
|
|
92
|
+
absolute = realpath_of(File.expand_path(path, @project_root))
|
|
93
|
+
@packages.find do |pkg|
|
|
94
|
+
absolute == pkg.root || absolute.start_with?(pkg.root + "/")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def realpath_or_expand(path)
|
|
99
|
+
File.realpath(File.expand_path(path))
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
File.expand_path(path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def realpath_of(path)
|
|
105
|
+
File.realpath(path)
|
|
106
|
+
rescue Errno::ENOENT
|
|
107
|
+
# Tests and synthetic edges may carry paths whose tail
|
|
108
|
+
# doesn't exist on disk; walk up to the deepest existing
|
|
109
|
+
# ancestor, realpath that, then reattach the missing tail.
|
|
110
|
+
# That makes a macOS +/tmp+ ↔ +/private/tmp+ symlink
|
|
111
|
+
# transparent even for synthetic paths.
|
|
112
|
+
parent = path
|
|
113
|
+
until parent == File.dirname(parent)
|
|
114
|
+
parent = File.dirname(parent)
|
|
115
|
+
if File.exist?(parent)
|
|
116
|
+
return File.realpath(parent) + path[parent.length..]
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Build a +{node_name => package_name}+ mapping from a list
|
|
123
|
+
# of edges. We only attribute a node to a package when we
|
|
124
|
+
# have evidence the node is *declared* under that package's
|
|
125
|
+
# root — that is, the node appears as +edge.from+ for at
|
|
126
|
+
# least one edge, and that edge's path lives under the
|
|
127
|
+
# package. The +to+ side is just a reference; using it would
|
|
128
|
+
# mis-attribute base classes (+ApplicationRecord+) and any
|
|
129
|
+
# other external constant to whichever package happens to
|
|
130
|
+
# reference them first.
|
|
131
|
+
def groups_for(edges)
|
|
132
|
+
node_paths = {}
|
|
133
|
+
edges.each do |edge|
|
|
134
|
+
node_paths[edge.from] ||= edge.path
|
|
135
|
+
end
|
|
136
|
+
node_paths.each_with_object({}) do |(name, path), acc|
|
|
137
|
+
pkg = package_for(path)
|
|
138
|
+
acc[name] = pkg.name if pkg
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module ModuleGraph
|
|
8
|
+
# Rigor plugin: declares the node rules that emit class/module/
|
|
9
|
+
# constant dependency edges as `:info` diagnostics. Loaded only
|
|
10
|
+
# when `rigortype` is available (see `plugin.rb`).
|
|
11
|
+
class Plugin < ::Rigor::Plugin::Base
|
|
12
|
+
EDGE_RULE = "edge"
|
|
13
|
+
NODE_RULE = "node"
|
|
14
|
+
private_constant :EDGE_RULE, :NODE_RULE
|
|
15
|
+
|
|
16
|
+
manifest(
|
|
17
|
+
id: "module-graph",
|
|
18
|
+
version: Rigor::ModuleGraph::VERSION,
|
|
19
|
+
description: "Extract Ruby class/module/constant dependency graph as :info diagnostics.",
|
|
20
|
+
config_schema: {
|
|
21
|
+
"autoload_paths" => {
|
|
22
|
+
kind: :array,
|
|
23
|
+
default: ZeitwerkResolver::DEFAULT_AUTOLOAD_PATHS
|
|
24
|
+
},
|
|
25
|
+
"concern_dirs" => {
|
|
26
|
+
kind: :array,
|
|
27
|
+
default: ZeitwerkResolver::DEFAULT_CONCERN_DIRS
|
|
28
|
+
},
|
|
29
|
+
"rails_zeitwerk" => { kind: :boolean, default: true },
|
|
30
|
+
"include_constant_refs" => { kind: :boolean, default: false },
|
|
31
|
+
"emit_node_metadata" => { kind: :boolean, default: true },
|
|
32
|
+
"emit_associations" => { kind: :boolean, default: true }
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Pre-walk each file once to record per-DefNode visibility.
|
|
37
|
+
# Shared across all node_rule invocations for the same file.
|
|
38
|
+
node_file_context do |root, _scope|
|
|
39
|
+
VisibilityMap.build(root)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
node_rule Prism::ClassNode do |node, scope, path, file_context, context|
|
|
43
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
44
|
+
diagnostics = analyzer.class_edges(node).map { |edge| edge_diagnostic(edge, node) }
|
|
45
|
+
if config["emit_node_metadata"]
|
|
46
|
+
if (meta = analyzer.class_node_metadata(node))
|
|
47
|
+
diagnostics << node_diagnostic(meta, node)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
diagnostics
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
node_rule Prism::ModuleNode do |node, scope, path, file_context, context|
|
|
54
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
55
|
+
diagnostics = []
|
|
56
|
+
if config["emit_node_metadata"]
|
|
57
|
+
if (meta = analyzer.module_node_metadata(node))
|
|
58
|
+
diagnostics << node_diagnostic(meta, node)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
diagnostics
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
node_rule Prism::CallNode do |node, scope, path, file_context, context|
|
|
65
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
66
|
+
diagnostics = analyzer.call_edges(node).map { |edge| edge_diagnostic(edge, node) }
|
|
67
|
+
if config["emit_associations"]
|
|
68
|
+
analyzer.association_edges(node).each do |edge|
|
|
69
|
+
diagnostics << edge_diagnostic(edge, node)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
if config["emit_node_metadata"]
|
|
73
|
+
analyzer.attribute_nodes(node).each do |meta|
|
|
74
|
+
diagnostics << node_diagnostic(meta, node)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
diagnostics
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
node_rule Prism::DefNode do |node, scope, path, file_context, context|
|
|
81
|
+
next [] unless config["emit_node_metadata"]
|
|
82
|
+
|
|
83
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
84
|
+
if (meta = analyzer.method_node_metadata(node))
|
|
85
|
+
[node_diagnostic(meta, node)]
|
|
86
|
+
else
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
node_rule Prism::ConstantReadNode do |node, scope, path, file_context, context|
|
|
92
|
+
next [] unless config["include_constant_refs"]
|
|
93
|
+
|
|
94
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
95
|
+
analyzer.constant_read_edges(node).map { |edge| edge_diagnostic(edge, node) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
node_rule Prism::ConstantPathNode do |node, scope, path, file_context, context|
|
|
99
|
+
next [] unless config["include_constant_refs"]
|
|
100
|
+
|
|
101
|
+
analyzer = analyzer_for(scope, path, context, file_context)
|
|
102
|
+
analyzer.constant_path_edges(node).map { |edge| edge_diagnostic(edge, node) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def analyzer_for(scope, path, context, visibility_map)
|
|
106
|
+
Analyzer.new(
|
|
107
|
+
path: path,
|
|
108
|
+
context: context,
|
|
109
|
+
scope: scope,
|
|
110
|
+
zeitwerk: zeitwerk_resolver,
|
|
111
|
+
visibility_map: visibility_map
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def zeitwerk_resolver
|
|
116
|
+
return @zeitwerk_resolver if defined?(@zeitwerk_resolver)
|
|
117
|
+
|
|
118
|
+
@zeitwerk_resolver =
|
|
119
|
+
if config["rails_zeitwerk"]
|
|
120
|
+
ZeitwerkResolver.new(
|
|
121
|
+
autoload_paths: config["autoload_paths"],
|
|
122
|
+
concern_dirs: config["concern_dirs"]
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def edge_diagnostic(edge, node)
|
|
128
|
+
diagnostic(
|
|
129
|
+
node,
|
|
130
|
+
path: edge.path,
|
|
131
|
+
message: JSON.generate(edge.to_message_payload),
|
|
132
|
+
severity: :info,
|
|
133
|
+
rule: EDGE_RULE
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def node_diagnostic(meta, ast_node)
|
|
138
|
+
diagnostic(
|
|
139
|
+
ast_node,
|
|
140
|
+
path: meta.path,
|
|
141
|
+
message: JSON.generate(meta.to_message_payload),
|
|
142
|
+
severity: :info,
|
|
143
|
+
rule: NODE_RULE
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
::Rigor::Plugin.register(Plugin)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Lazy plugin entry point.
|
|
4
|
+
#
|
|
5
|
+
# This file is `require`d by `lib/rigor-module-graph.rb`, which itself
|
|
6
|
+
# is `require`d two distinct ways:
|
|
7
|
+
#
|
|
8
|
+
# 1. From `Rigor::Plugin::Loader` when a host project's `.rigor.yml`
|
|
9
|
+
# lists `rigor-module-graph` under `plugins:` — the host gem has
|
|
10
|
+
# `rigortype` already loaded, so we want to subclass
|
|
11
|
+
# `Rigor::Plugin::Base` and register at require-time.
|
|
12
|
+
# 2. From the `rigor-module-graph` CLI when the user only wants the
|
|
13
|
+
# converter subcommands (`dot`, `mermaid`, `cycles`) — `rigortype`
|
|
14
|
+
# may not be available and we must NOT crash.
|
|
15
|
+
#
|
|
16
|
+
# We detect which mode we're in and defer the Rigor wiring to the
|
|
17
|
+
# concrete subclass file when appropriate.
|
|
18
|
+
|
|
19
|
+
require_relative "edge"
|
|
20
|
+
require_relative "analyzer"
|
|
21
|
+
require_relative "constant_name"
|
|
22
|
+
|
|
23
|
+
if defined?(Rigor::Plugin::Base)
|
|
24
|
+
require_relative "plugin/rigor_plugin"
|
|
25
|
+
end
|