rigor-module-graph 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +119 -1
- data/README.md +152 -129
- data/lib/rigor/module_graph/cli.rb +122 -25
- data/lib/rigor/module_graph/status_reporter.rb +94 -0
- data/lib/rigor/module_graph/templates/vendor/CHECKSUMS +59 -0
- data/lib/rigor/module_graph/templates/vendor/cytoscape.min.js +31 -0
- data/lib/rigor/module_graph/templates/viewer.css +63 -0
- data/lib/rigor/module_graph/templates/viewer.html.erb +32 -0
- data/lib/rigor/module_graph/templates/viewer.js +166 -0
- data/lib/rigor/module_graph/version.rb +1 -1
- data/lib/rigor/module_graph/viewer/html.rb +132 -0
- data/lib/rigor-module-graph.rb +2 -0
- metadata +10 -3
|
@@ -16,7 +16,9 @@ require_relative "reachability"
|
|
|
16
16
|
require_relative "stats"
|
|
17
17
|
require_relative "packwerk_overlay"
|
|
18
18
|
require_relative "html_view"
|
|
19
|
+
require_relative "status_reporter"
|
|
19
20
|
require_relative "uml/class_diagram"
|
|
21
|
+
require_relative "viewer/html"
|
|
20
22
|
|
|
21
23
|
module Rigor
|
|
22
24
|
module ModuleGraph
|
|
@@ -255,6 +257,7 @@ module Rigor
|
|
|
255
257
|
output: DEFAULT_EDGES_PATH,
|
|
256
258
|
nodes_output: DEFAULT_NODES_PATH,
|
|
257
259
|
cache: false,
|
|
260
|
+
quiet: false,
|
|
258
261
|
rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor")
|
|
259
262
|
}
|
|
260
263
|
end
|
|
@@ -263,11 +266,15 @@ module Rigor
|
|
|
263
266
|
parser = build_parser
|
|
264
267
|
paths = parser.parse(argv)
|
|
265
268
|
|
|
269
|
+
status = Rigor::ModuleGraph::StatusReporter.new(stderr: @stderr, quiet: @options[:quiet])
|
|
270
|
+
|
|
266
271
|
ensure_output_dirs
|
|
267
272
|
runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
|
|
268
|
-
edges, nodes = runner.analyse(paths)
|
|
269
|
-
|
|
270
|
-
|
|
273
|
+
edges, nodes = status.step(rigor_step_label(paths)) { runner.analyse(paths) }
|
|
274
|
+
status.info "#{edges.size} edge(s), #{nodes.size} node(s)"
|
|
275
|
+
status.step("Writing #{@options[:output]}") { write_edges(edges) }
|
|
276
|
+
status.step("Writing #{@options[:nodes_output]}") { write_nodes(nodes) }
|
|
277
|
+
|
|
271
278
|
@stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{@options[:output]}, " \
|
|
272
279
|
"#{nodes.size} node(s) to #{@options[:nodes_output]}"
|
|
273
280
|
0
|
|
@@ -279,6 +286,13 @@ module Rigor
|
|
|
279
286
|
1
|
|
280
287
|
end
|
|
281
288
|
|
|
289
|
+
# Path-aware label so the user can see which paths Rigor
|
|
290
|
+
# is being pointed at when the step is slow.
|
|
291
|
+
def rigor_step_label(paths)
|
|
292
|
+
target = paths.empty? ? "configured paths" : paths.join(", ")
|
|
293
|
+
"Running rigor check on #{target}"
|
|
294
|
+
end
|
|
295
|
+
|
|
282
296
|
def build_parser
|
|
283
297
|
OptionParser.new do |opts|
|
|
284
298
|
opts.banner = "Usage: rigor-module-graph collect [options] [PATHS...]"
|
|
@@ -298,6 +312,9 @@ module Rigor
|
|
|
298
312
|
"Override the rigor binary (default: rigor or $RIGOR_CMD)") do |cmd|
|
|
299
313
|
@options[:rigor_cmd] = cmd
|
|
300
314
|
end
|
|
315
|
+
opts.on("-q", "--quiet", "Suppress step-level progress on stderr") do
|
|
316
|
+
@options[:quiet] = true
|
|
317
|
+
end
|
|
301
318
|
opts.on("-h", "--help") do
|
|
302
319
|
@stdout.puts opts
|
|
303
320
|
exit 0
|
|
@@ -349,9 +366,23 @@ module Rigor
|
|
|
349
366
|
SUBTITLE_COLLAPSE_PREVIEW = 6
|
|
350
367
|
|
|
351
368
|
# The supported output formats, in roughly increasing
|
|
352
|
-
# "wrapping" order
|
|
369
|
+
# "wrapping" order. `html` is the interactive Cytoscape
|
|
370
|
+
# viewer (vendored, self-contained); `mermaid-html` is
|
|
371
|
+
# the older static-Mermaid-via-CDN page kept for
|
|
372
|
+
# backwards compatibility; `svg` embeds the dot layout;
|
|
353
373
|
# the rest are raw text.
|
|
354
|
-
FORMATS = %w[html mermaid dot svg class-diagram].freeze
|
|
374
|
+
FORMATS = %w[html mermaid-html mermaid dot svg class-diagram].freeze
|
|
375
|
+
|
|
376
|
+
# `--path-mode` controls how the click-through metadata
|
|
377
|
+
# `data.path` is reported on every node. See
|
|
378
|
+
# `Viewer::Html#path_for` for what each mode emits.
|
|
379
|
+
PATH_MODES = %i[relative absolute none].freeze
|
|
380
|
+
|
|
381
|
+
# `--open-with` flips the node-click action from
|
|
382
|
+
# clipboard copy to opening the file in an editor via
|
|
383
|
+
# a custom URL scheme. `vscode` is the only supported
|
|
384
|
+
# editor today.
|
|
385
|
+
OPEN_WITH = %i[vscode].freeze
|
|
355
386
|
|
|
356
387
|
# Default file destination when format is html and the
|
|
357
388
|
# user didn't override with -o. Non-html formats default to
|
|
@@ -365,6 +396,7 @@ module Rigor
|
|
|
365
396
|
format: "html",
|
|
366
397
|
output: nil,
|
|
367
398
|
cache: false,
|
|
399
|
+
quiet: false,
|
|
368
400
|
rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor"),
|
|
369
401
|
open: true,
|
|
370
402
|
collapse: nil,
|
|
@@ -377,7 +409,9 @@ module Rigor
|
|
|
377
409
|
package: nil,
|
|
378
410
|
include_methods: true,
|
|
379
411
|
include_attributes: true,
|
|
380
|
-
visibilities: %w[public protected private]
|
|
412
|
+
visibilities: %w[public protected private],
|
|
413
|
+
path_mode: :relative,
|
|
414
|
+
open_with: nil
|
|
381
415
|
}
|
|
382
416
|
end
|
|
383
417
|
|
|
@@ -385,22 +419,34 @@ module Rigor
|
|
|
385
419
|
parser = build_parser
|
|
386
420
|
paths = parser.parse(argv)
|
|
387
421
|
|
|
422
|
+
status = Rigor::ModuleGraph::StatusReporter.new(stderr: @stderr, quiet: @options[:quiet])
|
|
423
|
+
|
|
388
424
|
runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
|
|
389
|
-
edges, nodes = runner.analyse(paths)
|
|
390
|
-
edges
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
425
|
+
edges, nodes = status.step(rigor_step_label(paths)) { runner.analyse(paths) }
|
|
426
|
+
status.info "#{edges.size} edge(s), #{nodes.size} node(s)"
|
|
427
|
+
|
|
428
|
+
if any_filter_active?
|
|
429
|
+
edges = status.step("Applying filters") do
|
|
430
|
+
apply_filters(
|
|
431
|
+
edges,
|
|
432
|
+
kinds: @options[:kinds],
|
|
433
|
+
confidences: @options[:confidences],
|
|
434
|
+
from: @options[:from],
|
|
435
|
+
depth: @options[:depth],
|
|
436
|
+
direction: @options[:direction],
|
|
437
|
+
edge_scope: @options[:edge_scope]
|
|
438
|
+
)
|
|
439
|
+
end
|
|
440
|
+
status.info "#{edges.size} edge(s) after filters"
|
|
441
|
+
end
|
|
442
|
+
|
|
399
443
|
groups = package_groups(edges)
|
|
400
444
|
collapse = groups ? [] : effective_collapse(edges)
|
|
401
445
|
|
|
402
|
-
payload, binary =
|
|
403
|
-
|
|
446
|
+
payload, binary = status.step("Rendering #{@options[:format]}") do
|
|
447
|
+
render_payload(edges, nodes, collapse, groups)
|
|
448
|
+
end
|
|
449
|
+
deliver(payload, binary: binary, edges: edges, status: status)
|
|
404
450
|
0
|
|
405
451
|
rescue OptionParser::ParseError => e
|
|
406
452
|
@stderr.puts "rigor-module-graph view: #{e.message}"
|
|
@@ -410,6 +456,20 @@ module Rigor
|
|
|
410
456
|
1
|
|
411
457
|
end
|
|
412
458
|
|
|
459
|
+
def rigor_step_label(paths)
|
|
460
|
+
target = paths.empty? ? "configured paths" : paths.join(", ")
|
|
461
|
+
"Running rigor check on #{target}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def any_filter_active?
|
|
465
|
+
@options[:kinds] || @options[:confidences] ||
|
|
466
|
+
@options[:from] || @options[:depth]
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def silent_status
|
|
470
|
+
Rigor::ModuleGraph::StatusReporter.new(stderr: @stderr, quiet: true)
|
|
471
|
+
end
|
|
472
|
+
|
|
413
473
|
class RenderError < StandardError; end
|
|
414
474
|
|
|
415
475
|
# Builds the rendered payload for the chosen format and
|
|
@@ -418,6 +478,16 @@ module Rigor
|
|
|
418
478
|
def render_payload(edges, nodes, collapse, groups)
|
|
419
479
|
case @options[:format]
|
|
420
480
|
when "html"
|
|
481
|
+
html = Viewer::Html.render(
|
|
482
|
+
edges: edges,
|
|
483
|
+
nodes: restrict_nodes_to_edges(nodes, edges),
|
|
484
|
+
title: "rigor-module-graph: #{File.basename(Dir.pwd)}",
|
|
485
|
+
subtitle: render_subtitle(edges, collapse, groups),
|
|
486
|
+
path_mode: @options[:path_mode],
|
|
487
|
+
open_with: @options[:open_with]
|
|
488
|
+
)
|
|
489
|
+
[html, false]
|
|
490
|
+
when "mermaid-html"
|
|
421
491
|
mermaid = Mermaid.render(edges, collapse: collapse, groups: groups)
|
|
422
492
|
html = HtmlView.render(
|
|
423
493
|
title: "rigor-module-graph: #{File.basename(Dir.pwd)}",
|
|
@@ -475,7 +545,10 @@ module Rigor
|
|
|
475
545
|
|
|
476
546
|
# Writes the payload to the configured destination and
|
|
477
547
|
# opens the browser when the html-default flow applies.
|
|
478
|
-
|
|
548
|
+
# `status:` defaults to a silent reporter so the existing
|
|
549
|
+
# test surface (which exercises `deliver` directly) keeps
|
|
550
|
+
# working without threading a reporter through.
|
|
551
|
+
def deliver(payload, binary:, edges:, status: silent_status)
|
|
479
552
|
destination = effective_output_path
|
|
480
553
|
if destination.nil?
|
|
481
554
|
if binary
|
|
@@ -485,12 +558,16 @@ module Rigor
|
|
|
485
558
|
return
|
|
486
559
|
end
|
|
487
560
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
561
|
+
status.step("Writing #{destination}") do
|
|
562
|
+
dir = File.dirname(destination)
|
|
563
|
+
FileUtils.mkdir_p(dir) unless dir.empty? || dir == "."
|
|
564
|
+
mode = binary ? "wb" : "w"
|
|
565
|
+
File.open(destination, mode) { |io| io.write(payload) }
|
|
566
|
+
end
|
|
492
567
|
@stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{destination}"
|
|
493
|
-
|
|
568
|
+
return unless html? && @options[:open]
|
|
569
|
+
|
|
570
|
+
status.step("Opening #{destination} in browser") { open_in_browser(destination) }
|
|
494
571
|
end
|
|
495
572
|
|
|
496
573
|
# Resolve the output path. `-o PATH` always wins. With no
|
|
@@ -504,7 +581,7 @@ module Rigor
|
|
|
504
581
|
end
|
|
505
582
|
|
|
506
583
|
def html?
|
|
507
|
-
@options[:format]
|
|
584
|
+
%w[html mermaid-html].include?(@options[:format])
|
|
508
585
|
end
|
|
509
586
|
|
|
510
587
|
def build_parser
|
|
@@ -563,6 +640,10 @@ module Rigor
|
|
|
563
640
|
"Override the rigor binary (default: rigor or $RIGOR_CMD)") do |cmd|
|
|
564
641
|
@options[:rigor_cmd] = cmd
|
|
565
642
|
end
|
|
643
|
+
opts.on("-q", "--quiet", "Suppress step-level progress on stderr") do
|
|
644
|
+
@options[:quiet] = true
|
|
645
|
+
end
|
|
646
|
+
add_viewer_options(opts)
|
|
566
647
|
add_filter_options(opts, @options)
|
|
567
648
|
opts.on("-h", "--help") do
|
|
568
649
|
@stdout.puts opts
|
|
@@ -571,6 +652,22 @@ module Rigor
|
|
|
571
652
|
end
|
|
572
653
|
end
|
|
573
654
|
|
|
655
|
+
def add_viewer_options(opts)
|
|
656
|
+
opts.on("--path-mode MODE", PATH_MODES,
|
|
657
|
+
"How to report node paths in the html viewer: " \
|
|
658
|
+
"#{PATH_MODES.join(" / ")} (default: relative). " \
|
|
659
|
+
"`none` strips path metadata entirely — useful when " \
|
|
660
|
+
"sharing the html artefact outside the project.") do |mode|
|
|
661
|
+
@options[:path_mode] = mode
|
|
662
|
+
end
|
|
663
|
+
opts.on("--open-with EDITOR", OPEN_WITH,
|
|
664
|
+
"Make node clicks open the file in EDITOR instead of " \
|
|
665
|
+
"copying path:line to the clipboard. " \
|
|
666
|
+
"Supported: #{OPEN_WITH.join(" / ")}.") do |editor|
|
|
667
|
+
@options[:open_with] = editor
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
|
|
574
671
|
# Choose collapse prefixes. Explicit `--collapse` wins;
|
|
575
672
|
# otherwise we auto-pick top-level namespaces that have at
|
|
576
673
|
# least AUTO_COLLAPSE_THRESHOLD distinct nodes under them,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module ModuleGraph
|
|
5
|
+
# Step-level progress reporter that prints to stderr.
|
|
6
|
+
#
|
|
7
|
+
# On a TTY the message + elapsed time render inline on a
|
|
8
|
+
# single line ("==> Running rigor check... done (4.32s)").
|
|
9
|
+
# When stderr is redirected (CI logs, piping into another
|
|
10
|
+
# command, `tee` to a file) both halves print on separate
|
|
11
|
+
# lines so the output stays line-oriented and grep-friendly.
|
|
12
|
+
#
|
|
13
|
+
# `quiet: true` silences every method; callers can wire a
|
|
14
|
+
# `--quiet` CLI flag through without litterring conditionals
|
|
15
|
+
# at each call site.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
#
|
|
19
|
+
# status = StatusReporter.new(stderr: $stderr)
|
|
20
|
+
# edges = status.step("Running rigor check") do
|
|
21
|
+
# runner.edges_for(paths)
|
|
22
|
+
# end
|
|
23
|
+
# status.info "#{edges.size} edges"
|
|
24
|
+
class StatusReporter
|
|
25
|
+
def initialize(stderr:, quiet: false)
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
@quiet = quiet
|
|
28
|
+
@tty = stderr.respond_to?(:tty?) && stderr.tty?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Print a "==> message..." line, yield, then print the
|
|
32
|
+
# outcome ("done (Xms)" or "failed") with elapsed time.
|
|
33
|
+
# Returns whatever the block returns; re-raises on
|
|
34
|
+
# exception after printing the failure tail so callers can
|
|
35
|
+
# still rescue normally.
|
|
36
|
+
def step(message)
|
|
37
|
+
return yield if @quiet
|
|
38
|
+
|
|
39
|
+
start_step(message)
|
|
40
|
+
started_at = monotonic
|
|
41
|
+
begin
|
|
42
|
+
result = yield
|
|
43
|
+
rescue StandardError
|
|
44
|
+
finish_step("failed", monotonic - started_at)
|
|
45
|
+
raise
|
|
46
|
+
end
|
|
47
|
+
finish_step("done", monotonic - started_at)
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Print an informational line indented under the most
|
|
52
|
+
# recent step. Used for "2016 edges, 87 nodes" style
|
|
53
|
+
# post-step counts.
|
|
54
|
+
def info(message)
|
|
55
|
+
return if @quiet
|
|
56
|
+
|
|
57
|
+
@stderr.puts " #{message}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def start_step(message)
|
|
63
|
+
prefix = "==> #{message}"
|
|
64
|
+
if @tty
|
|
65
|
+
@stderr.print "#{prefix}... "
|
|
66
|
+
@stderr.flush
|
|
67
|
+
else
|
|
68
|
+
@stderr.puts prefix
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def finish_step(verb, elapsed)
|
|
73
|
+
duration = format_duration(elapsed)
|
|
74
|
+
if @tty
|
|
75
|
+
@stderr.puts "#{verb} #{duration}"
|
|
76
|
+
else
|
|
77
|
+
@stderr.puts " #{verb} #{duration}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def monotonic
|
|
82
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def format_duration(seconds)
|
|
86
|
+
if seconds < 1
|
|
87
|
+
"(#{(seconds * 1000).round}ms)"
|
|
88
|
+
else
|
|
89
|
+
"(#{seconds.round(2)}s)"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Vendored third-party assets — sha256 manifest.
|
|
2
|
+
#
|
|
3
|
+
# This file is the local integrity gate: `rake vendor:verify`
|
|
4
|
+
# re-computes sha256 for every row below and aborts on
|
|
5
|
+
# mismatch. Pre-commit and CI both run it. Per-file
|
|
6
|
+
# provenance (upstream URLs, npm `dist.integrity`, license,
|
|
7
|
+
# release date) lives next door in `MANIFEST.yml`; the audit
|
|
8
|
+
# task reads that.
|
|
9
|
+
#
|
|
10
|
+
# What this catches: bytes on disk no longer matching what we
|
|
11
|
+
# committed.
|
|
12
|
+
# What this does NOT catch on its own: bytes that were
|
|
13
|
+
# silently wrong at commit time (TOFU). For that, the bump
|
|
14
|
+
# SOP below requires `rake vendor:audit`, which cross-checks
|
|
15
|
+
# against four independent sources (npm tarball + npm
|
|
16
|
+
# `dist.integrity` + GitHub raw + every CDN listed in
|
|
17
|
+
# MANIFEST).
|
|
18
|
+
#
|
|
19
|
+
# ---
|
|
20
|
+
# Bumping a vendored asset (SOP):
|
|
21
|
+
#
|
|
22
|
+
# 1. Edit `MANIFEST.yml`: bump `release_tag`, `release_date`,
|
|
23
|
+
# `npm.version`, `tarball_url`, `github_raw_url`, and
|
|
24
|
+
# each CDN URL to the new version.
|
|
25
|
+
# 2. Look up the new `npm.integrity` from
|
|
26
|
+
# `https://registry.npmjs.org/<package>/<version>`
|
|
27
|
+
# (`dist.integrity` field) and paste it into
|
|
28
|
+
# `MANIFEST.yml`.
|
|
29
|
+
# 3. Download the new asset locally; replace the file under
|
|
30
|
+
# `vendor/`.
|
|
31
|
+
# 4. `shasum -a 256 vendor/<filename>` → update the sha256
|
|
32
|
+
# row in this file.
|
|
33
|
+
# 5. `bundle exec rake vendor:audit` — this fetches the new
|
|
34
|
+
# version from npm + GitHub + every CDN and asserts they
|
|
35
|
+
# all agree with the recorded sha256, and that the npm
|
|
36
|
+
# tarball's sha512 matches the recorded `dist.integrity`.
|
|
37
|
+
# Any mismatch means at least one source disagrees — do
|
|
38
|
+
# NOT proceed.
|
|
39
|
+
# 6. Update `last_audited:` in `MANIFEST.yml` to today's date.
|
|
40
|
+
# 7. Open a manual PR. Reviewer checks:
|
|
41
|
+
# - the diff touches only `vendor/<filename>`,
|
|
42
|
+
# `CHECKSUMS`, and `MANIFEST.yml`
|
|
43
|
+
# - release tag / date / license / source URLs match the
|
|
44
|
+
# new version
|
|
45
|
+
# - CI `vendor:verify` step passes
|
|
46
|
+
# - the audit step output was attached to the PR (paste
|
|
47
|
+
# `rake vendor:audit` output into the PR description)
|
|
48
|
+
# - `npm audit signatures cytoscape@<new>` clean (when
|
|
49
|
+
# npm CLI is available locally)
|
|
50
|
+
#
|
|
51
|
+
# Format: sha256 (64 hex chars) two spaces filename
|
|
52
|
+
# Lines starting with `#` and blank lines are ignored.
|
|
53
|
+
#
|
|
54
|
+
# ---
|
|
55
|
+
# cytoscape.min.js
|
|
56
|
+
# See MANIFEST.yml for the full provenance.
|
|
57
|
+
# release: v3.34.0 (2026-06-02), MIT
|
|
58
|
+
# npm: cytoscape@3.34.0
|
|
59
|
+
9c2a3bf2592e0b14a1f7bec07c03a54f16dedf32af9cd0af155c716aa6c87bc3 cytoscape.min.js
|