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.
@@ -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
- write_edges(edges)
270
- write_nodes(nodes)
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: html embeds mermaid; svg embeds dot;
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 = 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
- )
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 = render_payload(edges, nodes, collapse, groups)
403
- deliver(payload, binary: binary, edges: edges)
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
- def deliver(payload, binary:, edges:)
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
- 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) }
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
- open_in_browser(destination) if html? && @options[:open]
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] == "html"
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