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,1024 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "optparse"
|
|
7
|
+
require "set"
|
|
8
|
+
require "shellwords"
|
|
9
|
+
|
|
10
|
+
require_relative "edge"
|
|
11
|
+
require_relative "node"
|
|
12
|
+
require_relative "dot"
|
|
13
|
+
require_relative "mermaid"
|
|
14
|
+
require_relative "cycle_detector"
|
|
15
|
+
require_relative "reachability"
|
|
16
|
+
require_relative "stats"
|
|
17
|
+
require_relative "packwerk_overlay"
|
|
18
|
+
require_relative "html_view"
|
|
19
|
+
require_relative "uml/class_diagram"
|
|
20
|
+
|
|
21
|
+
module Rigor
|
|
22
|
+
module ModuleGraph
|
|
23
|
+
# Entry point for the `rigor-module-graph` executable.
|
|
24
|
+
#
|
|
25
|
+
# Subcommands:
|
|
26
|
+
#
|
|
27
|
+
# collect [PATHS...] Run `rigor check` and write edges JSONL
|
|
28
|
+
# dot [FILE] Render edges JSONL as Graphviz DOT
|
|
29
|
+
# mermaid [FILE] Render edges JSONL as Mermaid
|
|
30
|
+
# cycles [FILE] Detect cycles and print them
|
|
31
|
+
#
|
|
32
|
+
# Every reader subcommand takes the path to an edges file, or
|
|
33
|
+
# reads stdin if no path is given. Each reader supports
|
|
34
|
+
# `--kind` and `--confidence` filters so a noisy graph can be
|
|
35
|
+
# pruned without touching the JSONL on disk.
|
|
36
|
+
module CLI
|
|
37
|
+
DEFAULT_EDGES_PATH = ".rigor/module_graph/edges.jsonl"
|
|
38
|
+
DEFAULT_NODES_PATH = ".rigor/module_graph/nodes.jsonl"
|
|
39
|
+
SOURCE_FAMILY = "plugin.module-graph"
|
|
40
|
+
EDGE_RULE = "edge"
|
|
41
|
+
NODE_RULE = "node"
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
def run(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
46
|
+
argv = argv.dup
|
|
47
|
+
command = argv.shift
|
|
48
|
+
case command
|
|
49
|
+
when nil
|
|
50
|
+
View.new(stdout: stdout, stderr: stderr).run([])
|
|
51
|
+
when "view"
|
|
52
|
+
View.new(stdout: stdout, stderr: stderr).run(argv)
|
|
53
|
+
when "collect"
|
|
54
|
+
Collect.new(stdout: stdout, stderr: stderr).run(argv)
|
|
55
|
+
when "dot"
|
|
56
|
+
Render.new(:dot, stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
57
|
+
when "mermaid"
|
|
58
|
+
Render.new(:mermaid, stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
59
|
+
when "cycles"
|
|
60
|
+
Cycles.new(stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
61
|
+
when "stats"
|
|
62
|
+
StatsCmd.new(stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
63
|
+
when "class-diagram"
|
|
64
|
+
ClassDiagramCmd.new(stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
65
|
+
when "-h", "--help", "help"
|
|
66
|
+
stdout.puts USAGE
|
|
67
|
+
0
|
|
68
|
+
when "version", "-v", "--version"
|
|
69
|
+
stdout.puts Rigor::ModuleGraph::VERSION
|
|
70
|
+
0
|
|
71
|
+
else
|
|
72
|
+
stderr.puts "rigor-module-graph: unknown command #{command.inspect}"
|
|
73
|
+
stderr.puts USAGE
|
|
74
|
+
2
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
USAGE = <<~USAGE
|
|
79
|
+
Usage: rigor-module-graph [command] [options] [paths]
|
|
80
|
+
|
|
81
|
+
Default (no command): same as `view` — analyse the current
|
|
82
|
+
directory, write an HTML report, and open it in a browser.
|
|
83
|
+
|
|
84
|
+
Commands:
|
|
85
|
+
view [PATHS...] Analyse, write HTML, open in a browser
|
|
86
|
+
collect [PATHS...] Run `rigor check` and write edges + nodes JSONL
|
|
87
|
+
dot [FILE] Render edges JSONL as Graphviz DOT
|
|
88
|
+
mermaid [FILE] Render edges JSONL as Mermaid flowchart
|
|
89
|
+
class-diagram [FILE] Render edges + nodes as Mermaid classDiagram (UML)
|
|
90
|
+
cycles [FILE] Detect cycles in edges JSONL
|
|
91
|
+
stats [FILE] Per-namespace fan-in / fan-out report
|
|
92
|
+
|
|
93
|
+
Run `rigor-module-graph <command> --help` for command-specific options.
|
|
94
|
+
USAGE
|
|
95
|
+
|
|
96
|
+
# Shared filter options reused by dot / mermaid / cycles / view.
|
|
97
|
+
module EdgeFilters
|
|
98
|
+
VALID_KINDS = Rigor::ModuleGraph::EDGE_KINDS
|
|
99
|
+
VALID_CONFIDENCES = Rigor::ModuleGraph::EDGE_CONFIDENCES
|
|
100
|
+
VALID_DIRECTIONS = Reachability::VALID_DIRECTIONS
|
|
101
|
+
VALID_EDGE_SCOPES = Reachability::VALID_EDGE_SCOPES
|
|
102
|
+
|
|
103
|
+
def apply_filters(edges, kinds:, confidences:, from: nil, depth: nil,
|
|
104
|
+
direction: :both, edge_scope: :cluster)
|
|
105
|
+
edges = edges.select { |e| kinds.include?(e.kind) } if kinds
|
|
106
|
+
edges = edges.select { |e| confidences.include?(e.confidence) } if confidences
|
|
107
|
+
if from && !from.empty?
|
|
108
|
+
edges = Reachability.filter(
|
|
109
|
+
edges, roots: from, depth: depth, direction: direction, edge_scope: edge_scope
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
edges
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def add_filter_options(opts, state)
|
|
116
|
+
opts.on("--kind KINDS", Array,
|
|
117
|
+
"Only render the listed edge kinds (#{VALID_KINDS.join(",")})") do |list|
|
|
118
|
+
state[:kinds] = validate!(list, VALID_KINDS, "kind")
|
|
119
|
+
end
|
|
120
|
+
opts.on("--confidence LEVELS", Array,
|
|
121
|
+
"Only render the listed confidence levels (#{VALID_CONFIDENCES.join(",")})") do |list|
|
|
122
|
+
state[:confidences] = validate!(list, VALID_CONFIDENCES, "confidence")
|
|
123
|
+
end
|
|
124
|
+
opts.on("--from NAMES", Array,
|
|
125
|
+
"Restrict the graph to nodes reachable from NAMES (comma-separated)") do |names|
|
|
126
|
+
state[:from] = names
|
|
127
|
+
end
|
|
128
|
+
opts.on("--depth N", Integer,
|
|
129
|
+
"Maximum hops from --from roots (default: unlimited)") do |n|
|
|
130
|
+
state[:depth] = n
|
|
131
|
+
end
|
|
132
|
+
opts.on("--direction DIR", VALID_DIRECTIONS.map(&:to_s),
|
|
133
|
+
"Direction to follow from --from roots (#{VALID_DIRECTIONS.join(", ")}; default: both)") do |dir|
|
|
134
|
+
state[:direction] = dir.to_sym
|
|
135
|
+
end
|
|
136
|
+
opts.on("--edge-scope SCOPE", VALID_EDGE_SCOPES.map(&:to_s),
|
|
137
|
+
"Edges to keep when --from is set: cluster keeps every edge whose " \
|
|
138
|
+
"endpoints both fall in the reachable node set; walk keeps only " \
|
|
139
|
+
"the edges the BFS actually traverses " \
|
|
140
|
+
"(#{VALID_EDGE_SCOPES.join("|")}; default: cluster)") do |scope|
|
|
141
|
+
state[:edge_scope] = scope.to_sym
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate!(list, allowed, label)
|
|
146
|
+
unknown = list - allowed
|
|
147
|
+
unless unknown.empty?
|
|
148
|
+
raise OptionParser::InvalidArgument,
|
|
149
|
+
"unknown #{label}(s): #{unknown.join(",")}. Allowed: #{allowed.join(",")}"
|
|
150
|
+
end
|
|
151
|
+
list
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Encapsulates the actual `rigor check --format json` shell-out
|
|
156
|
+
# and the diagnostic → Edge / Node transformation. Reused by
|
|
157
|
+
# both `Collect` (write JSONL) and `View` (render HTML).
|
|
158
|
+
class RigorRunner
|
|
159
|
+
def initialize(rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor"), cache: false)
|
|
160
|
+
@rigor_cmd = rigor_cmd
|
|
161
|
+
@cache = cache
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def edges_for(paths)
|
|
165
|
+
diagnostics = run_rigor(paths)
|
|
166
|
+
diagnostics_to_edges(diagnostics)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Returns both edges and nodes from one rigor invocation.
|
|
170
|
+
def analyse(paths)
|
|
171
|
+
diagnostics = run_rigor(paths)
|
|
172
|
+
[diagnostics_to_edges(diagnostics), diagnostics_to_nodes(diagnostics)]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def run_rigor(paths)
|
|
176
|
+
cmd = [@rigor_cmd, "check", "--format", "json"]
|
|
177
|
+
cmd << (@cache ? "--cache" : "--no-cache")
|
|
178
|
+
cmd << "--no-stats"
|
|
179
|
+
cmd.concat(paths) unless paths.empty?
|
|
180
|
+
|
|
181
|
+
stdout_str, stderr_str, status = Open3.capture3(*cmd)
|
|
182
|
+
unless status.success?
|
|
183
|
+
# `rigor check` exits non-zero when it finds any error
|
|
184
|
+
# diagnostic — our edges live inside that same output,
|
|
185
|
+
# so we still parse the JSON. We only escalate when no
|
|
186
|
+
# JSON was emitted at all (e.g. binary missing).
|
|
187
|
+
if stdout_str.empty?
|
|
188
|
+
raise CollectError, "rigor exited #{status.exitstatus} with no output\n#{stderr_str}"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
payload = JSON.parse(stdout_str)
|
|
192
|
+
payload.fetch("diagnostics", [])
|
|
193
|
+
rescue Errno::ENOENT
|
|
194
|
+
raise CollectError, "rigor binary not found: #{cmd.first.inspect}. " \
|
|
195
|
+
"Install rigortype or set RIGOR_CMD."
|
|
196
|
+
rescue JSON::ParserError => e
|
|
197
|
+
raise CollectError, "rigor produced invalid JSON: #{e.message}\n#{stdout_str}"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def diagnostics_to_edges(diagnostics)
|
|
201
|
+
diagnostics.filter_map do |row|
|
|
202
|
+
next unless row["rule"] == EDGE_RULE
|
|
203
|
+
next unless row["source_family"] == SOURCE_FAMILY
|
|
204
|
+
|
|
205
|
+
payload = JSON.parse(row.fetch("message"))
|
|
206
|
+
Edge.build(
|
|
207
|
+
from: payload.fetch("from"),
|
|
208
|
+
to: payload.fetch("to"),
|
|
209
|
+
kind: payload.fetch("kind"),
|
|
210
|
+
path: row["path"],
|
|
211
|
+
line: row["line"],
|
|
212
|
+
column: row["column"],
|
|
213
|
+
confidence: payload.fetch("confidence", "syntax"),
|
|
214
|
+
raw: payload["raw"]
|
|
215
|
+
)
|
|
216
|
+
rescue JSON::ParserError, KeyError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def diagnostics_to_nodes(diagnostics)
|
|
222
|
+
diagnostics.filter_map do |row|
|
|
223
|
+
next unless row["rule"] == NODE_RULE
|
|
224
|
+
next unless row["source_family"] == SOURCE_FAMILY
|
|
225
|
+
|
|
226
|
+
payload = JSON.parse(row.fetch("message"))
|
|
227
|
+
Node.build(
|
|
228
|
+
kind: payload.fetch("kind"),
|
|
229
|
+
name: payload.fetch("name"),
|
|
230
|
+
owner: payload["owner"],
|
|
231
|
+
path: row["path"],
|
|
232
|
+
line: row["line"],
|
|
233
|
+
column: row["column"],
|
|
234
|
+
visibility: payload["visibility"],
|
|
235
|
+
access: payload["access"]
|
|
236
|
+
)
|
|
237
|
+
rescue JSON::ParserError, KeyError
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
class CollectError < StandardError; end
|
|
244
|
+
|
|
245
|
+
# `collect` shells out to `rigor check --format json` and
|
|
246
|
+
# writes a JSONL edge file by filtering the diagnostics for
|
|
247
|
+
# our `source_family` + `rule`.
|
|
248
|
+
class Collect
|
|
249
|
+
DEFAULT_PATHS = [].freeze
|
|
250
|
+
|
|
251
|
+
def initialize(stdout:, stderr:)
|
|
252
|
+
@stdout = stdout
|
|
253
|
+
@stderr = stderr
|
|
254
|
+
@options = {
|
|
255
|
+
output: DEFAULT_EDGES_PATH,
|
|
256
|
+
nodes_output: DEFAULT_NODES_PATH,
|
|
257
|
+
cache: false,
|
|
258
|
+
rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor")
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def run(argv)
|
|
263
|
+
parser = build_parser
|
|
264
|
+
paths = parser.parse(argv)
|
|
265
|
+
|
|
266
|
+
ensure_output_dirs
|
|
267
|
+
runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
|
|
268
|
+
edges, nodes = runner.analyse(paths)
|
|
269
|
+
write_edges(edges)
|
|
270
|
+
write_nodes(nodes)
|
|
271
|
+
@stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{@options[:output]}, " \
|
|
272
|
+
"#{nodes.size} node(s) to #{@options[:nodes_output]}"
|
|
273
|
+
0
|
|
274
|
+
rescue OptionParser::ParseError => e
|
|
275
|
+
@stderr.puts "rigor-module-graph collect: #{e.message}"
|
|
276
|
+
2
|
|
277
|
+
rescue CollectError => e
|
|
278
|
+
@stderr.puts "rigor-module-graph collect: #{e.message}"
|
|
279
|
+
1
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def build_parser
|
|
283
|
+
OptionParser.new do |opts|
|
|
284
|
+
opts.banner = "Usage: rigor-module-graph collect [options] [PATHS...]"
|
|
285
|
+
opts.on("-o", "--output PATH",
|
|
286
|
+
"Write edges to PATH (default: #{DEFAULT_EDGES_PATH})") do |path|
|
|
287
|
+
@options[:output] = path
|
|
288
|
+
end
|
|
289
|
+
opts.on("--nodes-output PATH",
|
|
290
|
+
"Write nodes to PATH (default: #{DEFAULT_NODES_PATH})") do |path|
|
|
291
|
+
@options[:nodes_output] = path
|
|
292
|
+
end
|
|
293
|
+
opts.on("--[no-]cache",
|
|
294
|
+
"Pass `--cache` / `--no-cache` to rigor (default: --no-cache)") do |cache|
|
|
295
|
+
@options[:cache] = cache
|
|
296
|
+
end
|
|
297
|
+
opts.on("--rigor-cmd CMD",
|
|
298
|
+
"Override the rigor binary (default: rigor or $RIGOR_CMD)") do |cmd|
|
|
299
|
+
@options[:rigor_cmd] = cmd
|
|
300
|
+
end
|
|
301
|
+
opts.on("-h", "--help") do
|
|
302
|
+
@stdout.puts opts
|
|
303
|
+
exit 0
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def ensure_output_dirs
|
|
309
|
+
[@options[:output], @options[:nodes_output]].each do |path|
|
|
310
|
+
dir = File.dirname(path)
|
|
311
|
+
FileUtils.mkdir_p(dir) unless dir.empty?
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def write_edges(edges)
|
|
316
|
+
File.open(@options[:output], "w") do |io|
|
|
317
|
+
EdgeIO.write(edges, io)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def write_nodes(nodes)
|
|
322
|
+
File.open(@options[:nodes_output], "w") do |io|
|
|
323
|
+
NodeIO.write(nodes, io)
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# `view` is the one-shot entry point: from the project root
|
|
329
|
+
# type `rigor-module-graph` and it analyses the current
|
|
330
|
+
# directory, writes a self-contained Mermaid HTML report,
|
|
331
|
+
# and opens it in a browser.
|
|
332
|
+
#
|
|
333
|
+
# Defaults are tuned to need zero flags on a Rails-shaped
|
|
334
|
+
# project. The lower-level subcommands (collect / dot /
|
|
335
|
+
# mermaid) stay available for piped use.
|
|
336
|
+
class View
|
|
337
|
+
include EdgeFilters
|
|
338
|
+
|
|
339
|
+
DEFAULT_OUTPUT = ".rigor/module_graph/view.html"
|
|
340
|
+
# An auto-collapsed cluster needs at least this many
|
|
341
|
+
# members before it's worth folding. Three is the sweet
|
|
342
|
+
# spot empirically: a 1500-edge Rails app collapses into
|
|
343
|
+
# roughly the right shape, and a small fixture still
|
|
344
|
+
# leaves trivial Foo / Bar pairs uncollapsed.
|
|
345
|
+
AUTO_COLLAPSE_THRESHOLD = 3
|
|
346
|
+
# Cap the visible "collapsed: …" trailer in the subtitle
|
|
347
|
+
# so it doesn't grow into an unreadable wall on large
|
|
348
|
+
# projects.
|
|
349
|
+
SUBTITLE_COLLAPSE_PREVIEW = 6
|
|
350
|
+
|
|
351
|
+
# The supported output formats, in roughly increasing
|
|
352
|
+
# "wrapping" order: html embeds mermaid; svg embeds dot;
|
|
353
|
+
# the rest are raw text.
|
|
354
|
+
FORMATS = %w[html mermaid dot svg class-diagram].freeze
|
|
355
|
+
|
|
356
|
+
# Default file destination when format is html and the
|
|
357
|
+
# user didn't override with -o. Non-html formats default to
|
|
358
|
+
# stdout.
|
|
359
|
+
DEFAULT_HTML_OUTPUT = ".rigor/module_graph/view.html"
|
|
360
|
+
|
|
361
|
+
def initialize(stdout:, stderr:)
|
|
362
|
+
@stdout = stdout
|
|
363
|
+
@stderr = stderr
|
|
364
|
+
@options = {
|
|
365
|
+
format: "html",
|
|
366
|
+
output: nil,
|
|
367
|
+
cache: false,
|
|
368
|
+
rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor"),
|
|
369
|
+
open: true,
|
|
370
|
+
collapse: nil,
|
|
371
|
+
kinds: nil,
|
|
372
|
+
confidences: nil,
|
|
373
|
+
from: nil,
|
|
374
|
+
depth: nil,
|
|
375
|
+
direction: :both,
|
|
376
|
+
edge_scope: :cluster,
|
|
377
|
+
package: nil,
|
|
378
|
+
include_methods: true,
|
|
379
|
+
include_attributes: true,
|
|
380
|
+
visibilities: %w[public protected private]
|
|
381
|
+
}
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def run(argv)
|
|
385
|
+
parser = build_parser
|
|
386
|
+
paths = parser.parse(argv)
|
|
387
|
+
|
|
388
|
+
runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
|
|
389
|
+
edges, nodes = runner.analyse(paths)
|
|
390
|
+
edges = apply_filters(
|
|
391
|
+
edges,
|
|
392
|
+
kinds: @options[:kinds],
|
|
393
|
+
confidences: @options[:confidences],
|
|
394
|
+
from: @options[:from],
|
|
395
|
+
depth: @options[:depth],
|
|
396
|
+
direction: @options[:direction],
|
|
397
|
+
edge_scope: @options[:edge_scope]
|
|
398
|
+
)
|
|
399
|
+
groups = package_groups(edges)
|
|
400
|
+
collapse = groups ? [] : effective_collapse(edges)
|
|
401
|
+
|
|
402
|
+
payload, binary = render_payload(edges, nodes, collapse, groups)
|
|
403
|
+
deliver(payload, binary: binary, edges: edges)
|
|
404
|
+
0
|
|
405
|
+
rescue OptionParser::ParseError => e
|
|
406
|
+
@stderr.puts "rigor-module-graph view: #{e.message}"
|
|
407
|
+
2
|
|
408
|
+
rescue CollectError, RenderError => e
|
|
409
|
+
@stderr.puts "rigor-module-graph view: #{e.message}"
|
|
410
|
+
1
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
class RenderError < StandardError; end
|
|
414
|
+
|
|
415
|
+
# Builds the rendered payload for the chosen format and
|
|
416
|
+
# signals whether the bytes are binary (svg via Graphviz
|
|
417
|
+
# can return a non-UTF-8 image stream).
|
|
418
|
+
def render_payload(edges, nodes, collapse, groups)
|
|
419
|
+
case @options[:format]
|
|
420
|
+
when "html"
|
|
421
|
+
mermaid = Mermaid.render(edges, collapse: collapse, groups: groups)
|
|
422
|
+
html = HtmlView.render(
|
|
423
|
+
title: "rigor-module-graph: #{File.basename(Dir.pwd)}",
|
|
424
|
+
subtitle: render_subtitle(edges, collapse, groups),
|
|
425
|
+
mermaid_source: mermaid
|
|
426
|
+
)
|
|
427
|
+
[html, false]
|
|
428
|
+
when "mermaid"
|
|
429
|
+
[Mermaid.render(edges, collapse: collapse, groups: groups), false]
|
|
430
|
+
when "dot"
|
|
431
|
+
[Dot.render(edges, collapse: collapse, groups: groups), false]
|
|
432
|
+
when "svg"
|
|
433
|
+
[graphviz_svg(Dot.render(edges, collapse: collapse, groups: groups)), true]
|
|
434
|
+
when "class-diagram"
|
|
435
|
+
[
|
|
436
|
+
Uml::ClassDiagram.render(
|
|
437
|
+
edges, restrict_nodes_to_edges(nodes, edges),
|
|
438
|
+
include_methods: @options[:include_methods],
|
|
439
|
+
include_attributes: @options[:include_attributes],
|
|
440
|
+
visibilities: @options[:visibilities]
|
|
441
|
+
),
|
|
442
|
+
false
|
|
443
|
+
]
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# When the user narrows the edge set with `--from` /
|
|
448
|
+
# `--kind` / `--confidence`, the class diagram should only
|
|
449
|
+
# show classes that participate in those edges — otherwise
|
|
450
|
+
# every constant declared in the project still shows up as
|
|
451
|
+
# a body-less class. The filter is a no-op when the edge
|
|
452
|
+
# set already covers every node (no filters applied).
|
|
453
|
+
def restrict_nodes_to_edges(nodes, edges)
|
|
454
|
+
return nodes if edges.empty?
|
|
455
|
+
|
|
456
|
+
visible = Set.new
|
|
457
|
+
edges.each { |edge| visible << edge.from << edge.to }
|
|
458
|
+
nodes.select { |node| visible.include?(node.owner) || visible.include?(node.name) }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Shell out to Graphviz `dot -Tsvg`. Surfacing the binary
|
|
462
|
+
# check as a clear error keeps the message friendlier than
|
|
463
|
+
# the raw `Errno::ENOENT` Open3 would propagate.
|
|
464
|
+
def graphviz_svg(dot_source)
|
|
465
|
+
stdout_str, stderr_str, status = Open3.capture3("dot", "-Tsvg", stdin_data: dot_source)
|
|
466
|
+
unless status.success?
|
|
467
|
+
raise RenderError, "graphviz `dot` failed (exit #{status.exitstatus}): #{stderr_str}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
stdout_str
|
|
471
|
+
rescue Errno::ENOENT
|
|
472
|
+
raise RenderError, "graphviz `dot` not found on PATH; install via " \
|
|
473
|
+
"`brew install graphviz` (macOS) or your distro's package manager"
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Writes the payload to the configured destination and
|
|
477
|
+
# opens the browser when the html-default flow applies.
|
|
478
|
+
def deliver(payload, binary:, edges:)
|
|
479
|
+
destination = effective_output_path
|
|
480
|
+
if destination.nil?
|
|
481
|
+
if binary
|
|
482
|
+
@stdout.binmode
|
|
483
|
+
end
|
|
484
|
+
@stdout.write(payload)
|
|
485
|
+
return
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
dir = File.dirname(destination)
|
|
489
|
+
FileUtils.mkdir_p(dir) unless dir.empty? || dir == "."
|
|
490
|
+
mode = binary ? "wb" : "w"
|
|
491
|
+
File.open(destination, mode) { |io| io.write(payload) }
|
|
492
|
+
@stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{destination}"
|
|
493
|
+
open_in_browser(destination) if html? && @options[:open]
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Resolve the output path. `-o PATH` always wins. With no
|
|
497
|
+
# explicit path, html falls back to `.rigor/module_graph/
|
|
498
|
+
# view.html`; every other format streams to stdout.
|
|
499
|
+
def effective_output_path
|
|
500
|
+
return @options[:output] if @options[:output]
|
|
501
|
+
return DEFAULT_HTML_OUTPUT if html?
|
|
502
|
+
|
|
503
|
+
nil
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def html?
|
|
507
|
+
@options[:format] == "html"
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def build_parser
|
|
511
|
+
OptionParser.new do |opts|
|
|
512
|
+
opts.banner = "Usage: rigor-module-graph view [options] [PATHS...]"
|
|
513
|
+
opts.on("--output FORMAT", FORMATS,
|
|
514
|
+
"Output format (#{FORMATS.join("|")}; default: html). " \
|
|
515
|
+
"Non-html streams to stdout unless -o is given.") do |fmt|
|
|
516
|
+
@options[:format] = fmt
|
|
517
|
+
end
|
|
518
|
+
opts.on("-o", "--save PATH",
|
|
519
|
+
"Write to PATH instead of stdout / the default html location") do |path|
|
|
520
|
+
@options[:output] = path
|
|
521
|
+
end
|
|
522
|
+
opts.on("--[no-]open",
|
|
523
|
+
"Open the html in a browser (default: true; ignored for non-html)") do |flag|
|
|
524
|
+
@options[:open] = flag
|
|
525
|
+
end
|
|
526
|
+
opts.on("--collapse PREFIXES", Array,
|
|
527
|
+
"Manual collapse list (disables auto-detection)") do |prefixes|
|
|
528
|
+
@options[:collapse] = prefixes
|
|
529
|
+
end
|
|
530
|
+
opts.on("--no-collapse",
|
|
531
|
+
"Disable namespace collapse entirely") do
|
|
532
|
+
@options[:collapse] = []
|
|
533
|
+
end
|
|
534
|
+
opts.on("--no-methods",
|
|
535
|
+
"[class-diagram] Don't render methods inside class bodies") do
|
|
536
|
+
@options[:include_methods] = false
|
|
537
|
+
end
|
|
538
|
+
opts.on("--no-attributes",
|
|
539
|
+
"[class-diagram] Don't render attributes inside class bodies") do
|
|
540
|
+
@options[:include_attributes] = false
|
|
541
|
+
end
|
|
542
|
+
opts.on("--public-only",
|
|
543
|
+
"[class-diagram] Only show public members") do
|
|
544
|
+
@options[:visibilities] = %w[public]
|
|
545
|
+
end
|
|
546
|
+
opts.on("--no-private",
|
|
547
|
+
"[class-diagram] Hide private members") do
|
|
548
|
+
@options[:visibilities] = %w[public protected]
|
|
549
|
+
end
|
|
550
|
+
opts.on("--package",
|
|
551
|
+
"Cluster by Packwerk packages discovered in cwd") do
|
|
552
|
+
@options[:package] ||= "."
|
|
553
|
+
end
|
|
554
|
+
opts.on("--package-root PATH",
|
|
555
|
+
"Cluster by Packwerk packages discovered under PATH") do |root|
|
|
556
|
+
@options[:package] = root
|
|
557
|
+
end
|
|
558
|
+
opts.on("--[no-]cache",
|
|
559
|
+
"Pass --cache / --no-cache to rigor (default: --no-cache)") do |cache|
|
|
560
|
+
@options[:cache] = cache
|
|
561
|
+
end
|
|
562
|
+
opts.on("--rigor-cmd CMD",
|
|
563
|
+
"Override the rigor binary (default: rigor or $RIGOR_CMD)") do |cmd|
|
|
564
|
+
@options[:rigor_cmd] = cmd
|
|
565
|
+
end
|
|
566
|
+
add_filter_options(opts, @options)
|
|
567
|
+
opts.on("-h", "--help") do
|
|
568
|
+
@stdout.puts opts
|
|
569
|
+
exit 0
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Choose collapse prefixes. Explicit `--collapse` wins;
|
|
575
|
+
# otherwise we auto-pick top-level namespaces that have at
|
|
576
|
+
# least AUTO_COLLAPSE_THRESHOLD distinct nodes under them,
|
|
577
|
+
# which is what most graphs benefit from.
|
|
578
|
+
def effective_collapse(edges)
|
|
579
|
+
return @options[:collapse] unless @options[:collapse].nil?
|
|
580
|
+
|
|
581
|
+
counts = Hash.new { |h, k| h[k] = Set.new }
|
|
582
|
+
edges.each do |edge|
|
|
583
|
+
[edge.from, edge.to].each do |name|
|
|
584
|
+
head, tail = name.split("::", 2)
|
|
585
|
+
# Only collapse on the top-level segment so a deep
|
|
586
|
+
# tree like `Billing::Invoice::Line` still feeds into
|
|
587
|
+
# the `Billing` cluster — picking inner prefixes
|
|
588
|
+
# would compete with each other and produce nested
|
|
589
|
+
# clusters that hurt readability.
|
|
590
|
+
next if tail.nil? || tail.empty?
|
|
591
|
+
# Absolute paths (`::Foo::Bar`) split with an empty
|
|
592
|
+
# head; skip them so they don't surface as the bogus
|
|
593
|
+
# `""` collapse target.
|
|
594
|
+
next if head.empty?
|
|
595
|
+
|
|
596
|
+
counts[head] << name
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
counts.select { |_, members| members.size >= AUTO_COLLAPSE_THRESHOLD }.keys.sort
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def render_subtitle(edges, collapse, groups)
|
|
603
|
+
parts = ["#{edges.size} edge(s) from #{Dir.pwd}"]
|
|
604
|
+
if @options[:from]
|
|
605
|
+
from_part = +"from: #{Array(@options[:from]).join(", ")}"
|
|
606
|
+
from_part << " (depth=#{@options[:depth]})" if @options[:depth]
|
|
607
|
+
from_part << " [#{@options[:direction]}]" unless @options[:direction] == :both
|
|
608
|
+
parts << from_part
|
|
609
|
+
end
|
|
610
|
+
if groups
|
|
611
|
+
uniq_packages = groups.values.uniq.sort
|
|
612
|
+
preview = uniq_packages.first(SUBTITLE_COLLAPSE_PREVIEW)
|
|
613
|
+
label = +"packages: #{preview.join(", ")}"
|
|
614
|
+
if uniq_packages.size > preview.size
|
|
615
|
+
label << " (+#{uniq_packages.size - preview.size} more)"
|
|
616
|
+
end
|
|
617
|
+
parts << label
|
|
618
|
+
elsif !collapse.empty?
|
|
619
|
+
preview = collapse.first(SUBTITLE_COLLAPSE_PREVIEW)
|
|
620
|
+
label = +"collapsed: #{preview.join(", ")}"
|
|
621
|
+
label << " (+#{collapse.size - preview.size} more)" if collapse.size > preview.size
|
|
622
|
+
parts << label
|
|
623
|
+
end
|
|
624
|
+
parts.join(" · ")
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
def package_groups(edges)
|
|
628
|
+
return nil unless @options[:package]
|
|
629
|
+
|
|
630
|
+
overlay = PackwerkOverlay.discover(@options[:package])
|
|
631
|
+
unless overlay.any?
|
|
632
|
+
@stderr.puts "rigor-module-graph view: no package.yml found under " \
|
|
633
|
+
"#{@options[:package].inspect}; falling back to namespace collapse"
|
|
634
|
+
return nil
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
overlay.groups_for(edges)
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def open_in_browser(path)
|
|
641
|
+
opener = ENV["BROWSER"] ||
|
|
642
|
+
(RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open")
|
|
643
|
+
system(opener, path)
|
|
644
|
+
rescue StandardError => e
|
|
645
|
+
@stderr.puts "rigor-module-graph view: could not open #{path}: #{e.message}"
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Shared base for `dot` / `mermaid` — both load an edges JSONL
|
|
650
|
+
# and print a rendered string.
|
|
651
|
+
class Render
|
|
652
|
+
include EdgeFilters
|
|
653
|
+
|
|
654
|
+
def initialize(format, stdout:, stderr:, stdin:)
|
|
655
|
+
@format = format
|
|
656
|
+
@stdout = stdout
|
|
657
|
+
@stderr = stderr
|
|
658
|
+
@stdin = stdin
|
|
659
|
+
@state = {
|
|
660
|
+
collapse: [], kinds: nil, confidences: nil,
|
|
661
|
+
from: nil, depth: nil, direction: :both, edge_scope: :cluster,
|
|
662
|
+
package: nil
|
|
663
|
+
}
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def run(argv)
|
|
667
|
+
argv = argv.dup
|
|
668
|
+
parse_options!(argv)
|
|
669
|
+
path, = argv
|
|
670
|
+
io = path ? File.open(path, "r") : @stdin
|
|
671
|
+
begin
|
|
672
|
+
edges = EdgeIO.read(io)
|
|
673
|
+
ensure
|
|
674
|
+
io.close if path && !io.closed?
|
|
675
|
+
end
|
|
676
|
+
edges = apply_filters(
|
|
677
|
+
edges,
|
|
678
|
+
kinds: @state[:kinds],
|
|
679
|
+
confidences: @state[:confidences],
|
|
680
|
+
from: @state[:from],
|
|
681
|
+
depth: @state[:depth],
|
|
682
|
+
direction: @state[:direction],
|
|
683
|
+
edge_scope: @state[:edge_scope]
|
|
684
|
+
)
|
|
685
|
+
groups = package_groups(edges)
|
|
686
|
+
@stdout.print(rendered(edges, groups))
|
|
687
|
+
0
|
|
688
|
+
rescue Errno::ENOENT => e
|
|
689
|
+
@stderr.puts "rigor-module-graph #{@format}: #{e.message}"
|
|
690
|
+
1
|
|
691
|
+
rescue OptionParser::ParseError => e
|
|
692
|
+
@stderr.puts "rigor-module-graph #{@format}: #{e.message}"
|
|
693
|
+
2
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def package_groups(edges)
|
|
697
|
+
return nil unless @state[:package]
|
|
698
|
+
|
|
699
|
+
overlay = PackwerkOverlay.discover(@state[:package])
|
|
700
|
+
unless overlay.any?
|
|
701
|
+
@stderr.puts "rigor-module-graph #{@format}: no package.yml found under #{@state[:package].inspect}"
|
|
702
|
+
return nil
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
overlay.groups_for(edges)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def parse_options!(argv)
|
|
709
|
+
parser = OptionParser.new do |opts|
|
|
710
|
+
opts.banner = "Usage: rigor-module-graph #{@format} [options] [FILE]"
|
|
711
|
+
opts.on("--collapse PREFIXES", Array,
|
|
712
|
+
"Comma-separated namespace prefixes to fold into clusters") do |prefixes|
|
|
713
|
+
@state[:collapse].concat(prefixes)
|
|
714
|
+
end
|
|
715
|
+
opts.on("--package",
|
|
716
|
+
"Cluster by Packwerk packages discovered in cwd") do
|
|
717
|
+
@state[:package] ||= "."
|
|
718
|
+
end
|
|
719
|
+
opts.on("--package-root PATH",
|
|
720
|
+
"Cluster by Packwerk packages discovered under PATH") do |root|
|
|
721
|
+
@state[:package] = root
|
|
722
|
+
end
|
|
723
|
+
add_filter_options(opts, @state)
|
|
724
|
+
opts.on("-h", "--help") do
|
|
725
|
+
@stdout.puts opts
|
|
726
|
+
exit 0
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
parser.parse!(argv)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def rendered(edges, groups)
|
|
733
|
+
case @format
|
|
734
|
+
when :dot then Dot.render(edges, collapse: @state[:collapse], groups: groups)
|
|
735
|
+
when :mermaid then Mermaid.render(edges, collapse: @state[:collapse], groups: groups)
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# `class-diagram` renders a Mermaid +classDiagram+ document
|
|
741
|
+
# from the +edges.jsonl+ (the dependency graph) and the
|
|
742
|
+
# +nodes.jsonl+ (class declarations + methods + attributes).
|
|
743
|
+
# Phase 5 of the project — turns the dependency graph
|
|
744
|
+
# material into a UML-style class diagram.
|
|
745
|
+
class ClassDiagramCmd
|
|
746
|
+
include EdgeFilters
|
|
747
|
+
|
|
748
|
+
DEFAULT_NODES_PATH = CLI::DEFAULT_NODES_PATH
|
|
749
|
+
|
|
750
|
+
def initialize(stdout:, stderr:, stdin:)
|
|
751
|
+
@stdout = stdout
|
|
752
|
+
@stderr = stderr
|
|
753
|
+
@stdin = stdin
|
|
754
|
+
@options = {
|
|
755
|
+
kinds: nil, confidences: nil,
|
|
756
|
+
from: nil, depth: nil, direction: :both, edge_scope: :cluster,
|
|
757
|
+
nodes_path: nil,
|
|
758
|
+
include_methods: true,
|
|
759
|
+
include_attributes: true,
|
|
760
|
+
visibilities: %w[public protected private]
|
|
761
|
+
}
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
def run(argv)
|
|
765
|
+
argv = argv.dup
|
|
766
|
+
parse_options!(argv)
|
|
767
|
+
edges_path = argv.shift
|
|
768
|
+
io = edges_path ? File.open(edges_path, "r") : @stdin
|
|
769
|
+
begin
|
|
770
|
+
edges = EdgeIO.read(io)
|
|
771
|
+
ensure
|
|
772
|
+
io.close if edges_path && !io.closed?
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
edges = apply_filters(
|
|
776
|
+
edges,
|
|
777
|
+
kinds: @options[:kinds],
|
|
778
|
+
confidences: @options[:confidences],
|
|
779
|
+
from: @options[:from],
|
|
780
|
+
depth: @options[:depth],
|
|
781
|
+
direction: @options[:direction],
|
|
782
|
+
edge_scope: @options[:edge_scope]
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
nodes_path = @options[:nodes_path] || default_nodes_for(edges_path)
|
|
786
|
+
nodes = read_nodes(nodes_path)
|
|
787
|
+
|
|
788
|
+
out = Uml::ClassDiagram.render(
|
|
789
|
+
edges, nodes,
|
|
790
|
+
include_methods: @options[:include_methods],
|
|
791
|
+
include_attributes: @options[:include_attributes],
|
|
792
|
+
visibilities: @options[:visibilities]
|
|
793
|
+
)
|
|
794
|
+
@stdout.print(out)
|
|
795
|
+
0
|
|
796
|
+
rescue OptionParser::ParseError => e
|
|
797
|
+
@stderr.puts "rigor-module-graph class-diagram: #{e.message}"
|
|
798
|
+
2
|
|
799
|
+
rescue Errno::ENOENT => e
|
|
800
|
+
@stderr.puts "rigor-module-graph class-diagram: #{e.message}"
|
|
801
|
+
1
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def parse_options!(argv)
|
|
805
|
+
parser = OptionParser.new do |opts|
|
|
806
|
+
opts.banner = "Usage: rigor-module-graph class-diagram [options] [EDGES_FILE]"
|
|
807
|
+
opts.on("--nodes PATH",
|
|
808
|
+
"Path to the nodes JSONL (default: sibling of EDGES_FILE)") do |path|
|
|
809
|
+
@options[:nodes_path] = path
|
|
810
|
+
end
|
|
811
|
+
opts.on("--no-methods",
|
|
812
|
+
"Don't render methods inside class bodies") do
|
|
813
|
+
@options[:include_methods] = false
|
|
814
|
+
end
|
|
815
|
+
opts.on("--no-attributes",
|
|
816
|
+
"Don't render attributes inside class bodies") do
|
|
817
|
+
@options[:include_attributes] = false
|
|
818
|
+
end
|
|
819
|
+
opts.on("--public-only",
|
|
820
|
+
"Only show public members") do
|
|
821
|
+
@options[:visibilities] = %w[public]
|
|
822
|
+
end
|
|
823
|
+
opts.on("--no-private",
|
|
824
|
+
"Hide private members") do
|
|
825
|
+
@options[:visibilities] = %w[public protected]
|
|
826
|
+
end
|
|
827
|
+
add_filter_options(opts, @options)
|
|
828
|
+
opts.on("-h", "--help") do
|
|
829
|
+
@stdout.puts opts
|
|
830
|
+
exit 0
|
|
831
|
+
end
|
|
832
|
+
end
|
|
833
|
+
parser.parse!(argv)
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
def default_nodes_for(edges_path)
|
|
837
|
+
return DEFAULT_NODES_PATH unless edges_path
|
|
838
|
+
|
|
839
|
+
File.join(File.dirname(edges_path), "nodes.jsonl")
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def read_nodes(path)
|
|
843
|
+
return [] unless path && File.exist?(path)
|
|
844
|
+
|
|
845
|
+
File.open(path, "r") { |io| NodeIO.read(io) }
|
|
846
|
+
end
|
|
847
|
+
end
|
|
848
|
+
|
|
849
|
+
# `stats` reports the fan-out / fan-in / internal / nodes
|
|
850
|
+
# numbers per namespace. Same filter flags as the renderers
|
|
851
|
+
# so a focused subgraph can be summarised without
|
|
852
|
+
# regenerating the JSONL.
|
|
853
|
+
class StatsCmd
|
|
854
|
+
include EdgeFilters
|
|
855
|
+
|
|
856
|
+
FORMATS = %w[text json].freeze
|
|
857
|
+
HEADERS = %w[namespace nodes fan-out fan-in internal total].freeze
|
|
858
|
+
|
|
859
|
+
def initialize(stdout:, stderr:, stdin:)
|
|
860
|
+
@stdout = stdout
|
|
861
|
+
@stderr = stderr
|
|
862
|
+
@stdin = stdin
|
|
863
|
+
@state = {
|
|
864
|
+
kinds: nil, confidences: nil,
|
|
865
|
+
from: nil, depth: nil, direction: :both, edge_scope: :cluster,
|
|
866
|
+
grouping_depth: 1, format: "text", limit: nil
|
|
867
|
+
}
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
def run(argv)
|
|
871
|
+
argv = argv.dup
|
|
872
|
+
parse_options!(argv)
|
|
873
|
+
path, = argv
|
|
874
|
+
io = path ? File.open(path, "r") : @stdin
|
|
875
|
+
begin
|
|
876
|
+
edges = EdgeIO.read(io)
|
|
877
|
+
ensure
|
|
878
|
+
io.close if path && !io.closed?
|
|
879
|
+
end
|
|
880
|
+
edges = apply_filters(
|
|
881
|
+
edges,
|
|
882
|
+
kinds: @state[:kinds],
|
|
883
|
+
confidences: @state[:confidences],
|
|
884
|
+
from: @state[:from],
|
|
885
|
+
depth: @state[:depth],
|
|
886
|
+
direction: @state[:direction],
|
|
887
|
+
edge_scope: @state[:edge_scope]
|
|
888
|
+
)
|
|
889
|
+
metrics = Stats.compute(edges, depth: @state[:grouping_depth])
|
|
890
|
+
metrics = metrics.first(@state[:limit]) if @state[:limit]
|
|
891
|
+
render(metrics)
|
|
892
|
+
0
|
|
893
|
+
rescue OptionParser::ParseError => e
|
|
894
|
+
@stderr.puts "rigor-module-graph stats: #{e.message}"
|
|
895
|
+
2
|
|
896
|
+
end
|
|
897
|
+
|
|
898
|
+
def parse_options!(argv)
|
|
899
|
+
parser = OptionParser.new do |opts|
|
|
900
|
+
opts.banner = "Usage: rigor-module-graph stats [options] [FILE]"
|
|
901
|
+
opts.on("--grouping-depth N", Integer,
|
|
902
|
+
"How many leading namespace segments to group by (default: 1)") do |n|
|
|
903
|
+
@state[:grouping_depth] = n
|
|
904
|
+
end
|
|
905
|
+
opts.on("--limit N", Integer,
|
|
906
|
+
"Show only the top N namespaces by fan-out") do |n|
|
|
907
|
+
@state[:limit] = n
|
|
908
|
+
end
|
|
909
|
+
opts.on("--format FORMAT", FORMATS,
|
|
910
|
+
"Output format (#{FORMATS.join("/")}; default: text)") do |fmt|
|
|
911
|
+
@state[:format] = fmt
|
|
912
|
+
end
|
|
913
|
+
add_filter_options(opts, @state)
|
|
914
|
+
opts.on("-h", "--help") do
|
|
915
|
+
@stdout.puts opts
|
|
916
|
+
exit 0
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
parser.parse!(argv)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
def render(metrics)
|
|
923
|
+
case @state[:format]
|
|
924
|
+
when "json"
|
|
925
|
+
@stdout.puts(JSON.pretty_generate(metrics.map(&:to_h)))
|
|
926
|
+
when "text"
|
|
927
|
+
@stdout.print(format_table(metrics))
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
# A space-padded text table sized to the widest cell per
|
|
932
|
+
# column. Numeric columns are right-aligned so a quick
|
|
933
|
+
# eye-scan finds the hotspots.
|
|
934
|
+
def format_table(metrics)
|
|
935
|
+
if metrics.empty?
|
|
936
|
+
return "(no edges)\n"
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
rows = metrics.map do |m|
|
|
940
|
+
[m.namespace, m.nodes.to_s, m.fan_out.to_s, m.fan_in.to_s,
|
|
941
|
+
m.internal.to_s, m.total.to_s]
|
|
942
|
+
end
|
|
943
|
+
widths = HEADERS.zip(*rows).map { |col| col.map(&:length).max }
|
|
944
|
+
|
|
945
|
+
out = +""
|
|
946
|
+
out << format_row(HEADERS, widths) << "\n"
|
|
947
|
+
out << ("-" * widths.sum { |w| w + 2 }) << "\n"
|
|
948
|
+
rows.each { |row| out << format_row(row, widths) << "\n" }
|
|
949
|
+
out
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
def format_row(row, widths)
|
|
953
|
+
row.each_with_index.map do |cell, idx|
|
|
954
|
+
idx.zero? ? cell.ljust(widths[idx]) : cell.rjust(widths[idx])
|
|
955
|
+
end.join(" ")
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
class Cycles
|
|
960
|
+
include EdgeFilters
|
|
961
|
+
|
|
962
|
+
def initialize(stdout:, stderr:, stdin:)
|
|
963
|
+
@stdout = stdout
|
|
964
|
+
@stderr = stderr
|
|
965
|
+
@stdin = stdin
|
|
966
|
+
@state = {
|
|
967
|
+
kinds: nil, confidences: nil,
|
|
968
|
+
from: nil, depth: nil, direction: :both
|
|
969
|
+
}
|
|
970
|
+
end
|
|
971
|
+
|
|
972
|
+
def run(argv)
|
|
973
|
+
argv = argv.dup
|
|
974
|
+
parse_options!(argv)
|
|
975
|
+
path, = argv
|
|
976
|
+
io = path ? File.open(path, "r") : @stdin
|
|
977
|
+
begin
|
|
978
|
+
edges = EdgeIO.read(io)
|
|
979
|
+
ensure
|
|
980
|
+
io.close if path && !io.closed?
|
|
981
|
+
end
|
|
982
|
+
edges = apply_filters(
|
|
983
|
+
edges,
|
|
984
|
+
kinds: @state[:kinds],
|
|
985
|
+
confidences: @state[:confidences],
|
|
986
|
+
from: @state[:from],
|
|
987
|
+
depth: @state[:depth],
|
|
988
|
+
direction: @state[:direction],
|
|
989
|
+
edge_scope: @state[:edge_scope]
|
|
990
|
+
)
|
|
991
|
+
cycles = CycleDetector.detect(edges)
|
|
992
|
+
if cycles.empty?
|
|
993
|
+
@stderr.puts "rigor-module-graph cycles: no cycles found"
|
|
994
|
+
0
|
|
995
|
+
else
|
|
996
|
+
cycles.each { |c| @stdout.puts c.to_s }
|
|
997
|
+
1
|
|
998
|
+
end
|
|
999
|
+
rescue OptionParser::ParseError => e
|
|
1000
|
+
@stderr.puts "rigor-module-graph cycles: #{e.message}"
|
|
1001
|
+
2
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def parse_options!(argv)
|
|
1005
|
+
parser = OptionParser.new do |opts|
|
|
1006
|
+
opts.banner = "Usage: rigor-module-graph cycles [options] [FILE]"
|
|
1007
|
+
# `--only` kept as an alias for `--kind` for backward
|
|
1008
|
+
# compat with the Phase 1 flag.
|
|
1009
|
+
opts.on("--only KINDS", Array,
|
|
1010
|
+
"Alias for --kind") do |kinds|
|
|
1011
|
+
@state[:kinds] = kinds
|
|
1012
|
+
end
|
|
1013
|
+
add_filter_options(opts, @state)
|
|
1014
|
+
opts.on("-h", "--help") do
|
|
1015
|
+
@stdout.puts opts
|
|
1016
|
+
exit 0
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
parser.parse!(argv)
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
end
|
|
1024
|
+
end
|