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.
@@ -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