Class: Rigor::ModuleGraph::CLI::View

Inherits:
Object
  • Object
show all
Includes:
EdgeFilters
Defined in:
lib/rigor/module_graph/cli.rb

Overview

‘view` is the one-shot entry point: from the project root type `rigor-module-graph` and it analyses the current directory, writes a self-contained Mermaid HTML report, and opens it in a browser.

Defaults are tuned to need zero flags on a Rails-shaped project. The lower-level subcommands (collect / dot / mermaid) stay available for piped use.

Defined Under Namespace

Classes: RenderError

Constant Summary collapse

DEFAULT_OUTPUT =
".rigor/module_graph/view.html"
AUTO_COLLAPSE_THRESHOLD =

An auto-collapsed cluster needs at least this many members before it’s worth folding. Three is the sweet spot empirically: a 1500-edge Rails app collapses into roughly the right shape, and a small fixture still leaves trivial Foo / Bar pairs uncollapsed.

3
SUBTITLE_COLLAPSE_PREVIEW =

Cap the visible “collapsed: …” trailer in the subtitle so it doesn’t grow into an unreadable wall on large projects.

6
FORMATS =

The supported output formats, in roughly increasing “wrapping” order: html embeds mermaid; svg embeds dot; the rest are raw text.

%w[html mermaid dot svg class-diagram].freeze
DEFAULT_HTML_OUTPUT =

Default file destination when format is html and the user didn’t override with -o. Non-html formats default to stdout.

".rigor/module_graph/view.html"

Constants included from EdgeFilters

EdgeFilters::VALID_CONFIDENCES, EdgeFilters::VALID_DIRECTIONS, EdgeFilters::VALID_EDGE_SCOPES, EdgeFilters::VALID_KINDS

Instance Method Summary collapse

Methods included from EdgeFilters

#add_filter_options, #apply_filters, #validate!

Constructor Details

#initialize(stdout:, stderr:) ⇒ View

Returns a new instance of View.



377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/rigor/module_graph/cli.rb', line 377

def initialize(stdout:, stderr:)
  @stdout = stdout
  @stderr = stderr
  @options = {
    format: "html",
    output: nil,
    cache: false,
    quiet: false,
    rigor_cmd: ENV.fetch("RIGOR_CMD", "rigor"),
    open: true,
    collapse: nil,
    kinds: nil,
    confidences: nil,
    from: nil,
    depth: nil,
    direction: :both,
    edge_scope: :cluster,
    package: nil,
    include_methods: true,
    include_attributes: true,
    visibilities: %w[public protected private]
  }
end

Instance Method Details

#any_filter_active?Boolean

Returns:

  • (Boolean)


447
448
449
450
# File 'lib/rigor/module_graph/cli.rb', line 447

def any_filter_active?
  @options[:kinds] || @options[:confidences] ||
    @options[:from] || @options[:depth]
end

#build_parserObject



560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
# File 'lib/rigor/module_graph/cli.rb', line 560

def build_parser
  OptionParser.new do |opts|
    opts.banner = "Usage: rigor-module-graph view [options] [PATHS...]"
    opts.on("--output FORMAT", FORMATS,
            "Output format (#{FORMATS.join("|")}; default: html). " \
            "Non-html streams to stdout unless -o is given.") do |fmt|
      @options[:format] = fmt
    end
    opts.on("-o", "--save PATH",
            "Write to PATH instead of stdout / the default html location") do |path|
      @options[:output] = path
    end
    opts.on("--[no-]open",
            "Open the html in a browser (default: true; ignored for non-html)") do |flag|
      @options[:open] = flag
    end
    opts.on("--collapse PREFIXES", Array,
            "Manual collapse list (disables auto-detection)") do |prefixes|
      @options[:collapse] = prefixes
    end
    opts.on("--no-collapse",
            "Disable namespace collapse entirely") do
      @options[:collapse] = []
    end
    opts.on("--no-methods",
            "[class-diagram] Don't render methods inside class bodies") do
      @options[:include_methods] = false
    end
    opts.on("--no-attributes",
            "[class-diagram] Don't render attributes inside class bodies") do
      @options[:include_attributes] = false
    end
    opts.on("--public-only",
            "[class-diagram] Only show public members") do
      @options[:visibilities] = %w[public]
    end
    opts.on("--no-private",
            "[class-diagram] Hide private members") do
      @options[:visibilities] = %w[public protected]
    end
    opts.on("--package",
            "Cluster by Packwerk packages discovered in cwd") do
      @options[:package] ||= "."
    end
    opts.on("--package-root PATH",
            "Cluster by Packwerk packages discovered under PATH") do |root|
      @options[:package] = root
    end
    opts.on("--[no-]cache",
            "Pass --cache / --no-cache to rigor (default: --no-cache)") do |cache|
      @options[:cache] = cache
    end
    opts.on("--rigor-cmd CMD",
            "Override the rigor binary (default: rigor or $RIGOR_CMD)") do |cmd|
      @options[:rigor_cmd] = cmd
    end
    opts.on("-q", "--quiet", "Suppress step-level progress on stderr") do
      @options[:quiet] = true
    end
    add_filter_options(opts, @options)
    opts.on("-h", "--help") do
      @stdout.puts opts
      exit 0
    end
  end
end

#deliver(payload, binary:, edges:, status: silent_status) ⇒ Object

Writes the payload to the configured destination and opens the browser when the html-default flow applies. ‘status:` defaults to a silent reporter so the existing test surface (which exercises `deliver` directly) keeps working without threading a reporter through.



524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'lib/rigor/module_graph/cli.rb', line 524

def deliver(payload, binary:, edges:, status: silent_status)
  destination = effective_output_path
  if destination.nil?
    if binary
      @stdout.binmode
    end
    @stdout.write(payload)
    return
  end

  status.step("Writing #{destination}") do
    dir = File.dirname(destination)
    FileUtils.mkdir_p(dir) unless dir.empty? || dir == "."
    mode = binary ? "wb" : "w"
    File.open(destination, mode) { |io| io.write(payload) }
  end
  @stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{destination}"
  return unless html? && @options[:open]

  status.step("Opening #{destination} in browser") { open_in_browser(destination) }
end

#effective_collapse(edges) ⇒ Object

Choose collapse prefixes. Explicit ‘–collapse` wins; otherwise we auto-pick top-level namespaces that have at least AUTO_COLLAPSE_THRESHOLD distinct nodes under them, which is what most graphs benefit from.



631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/rigor/module_graph/cli.rb', line 631

def effective_collapse(edges)
  return @options[:collapse] unless @options[:collapse].nil?

  counts = Hash.new { |h, k| h[k] = Set.new }
  edges.each do |edge|
    [edge.from, edge.to].each do |name|
      head, tail = name.split("::", 2)
      # Only collapse on the top-level segment so a deep
      # tree like `Billing::Invoice::Line` still feeds into
      # the `Billing` cluster — picking inner prefixes
      # would compete with each other and produce nested
      # clusters that hurt readability.
      next if tail.nil? || tail.empty?
      # Absolute paths (`::Foo::Bar`) split with an empty
      # head; skip them so they don't surface as the bogus
      # `""` collapse target.
      next if head.empty?

      counts[head] << name
    end
  end
  counts.select { |_, members| members.size >= AUTO_COLLAPSE_THRESHOLD }.keys.sort
end

#effective_output_pathObject

Resolve the output path. ‘-o PATH` always wins. With no explicit path, html falls back to `.rigor/module_graph/ view.html`; every other format streams to stdout.



549
550
551
552
553
554
# File 'lib/rigor/module_graph/cli.rb', line 549

def effective_output_path
  return @options[:output] if @options[:output]
  return DEFAULT_HTML_OUTPUT if html?

  nil
end

#graphviz_svg(dot_source) ⇒ Object

Shell out to Graphviz ‘dot -Tsvg`. Surfacing the binary check as a clear error keeps the message friendlier than the raw `Errno::ENOENT` Open3 would propagate.



507
508
509
510
511
512
513
514
515
516
517
# File 'lib/rigor/module_graph/cli.rb', line 507

def graphviz_svg(dot_source)
  stdout_str, stderr_str, status = Open3.capture3("dot", "-Tsvg", stdin_data: dot_source)
  unless status.success?
    raise RenderError, "graphviz `dot` failed (exit #{status.exitstatus}): #{stderr_str}"
  end

  stdout_str
rescue Errno::ENOENT
  raise RenderError, "graphviz `dot` not found on PATH; install via " \
                     "`brew install graphviz` (macOS) or your distro's package manager"
end

#html?Boolean

Returns:

  • (Boolean)


556
557
558
# File 'lib/rigor/module_graph/cli.rb', line 556

def html?
  @options[:format] == "html"
end

#open_in_browser(path) ⇒ Object



693
694
695
696
697
698
699
# File 'lib/rigor/module_graph/cli.rb', line 693

def open_in_browser(path)
  opener = ENV["BROWSER"] ||
           (RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open")
  system(opener, path)
rescue StandardError => e
  @stderr.puts "rigor-module-graph view: could not open #{path}: #{e.message}"
end

#package_groups(edges) ⇒ Object



680
681
682
683
684
685
686
687
688
689
690
691
# File 'lib/rigor/module_graph/cli.rb', line 680

def package_groups(edges)
  return nil unless @options[:package]

  overlay = PackwerkOverlay.discover(@options[:package])
  unless overlay.any?
    @stderr.puts "rigor-module-graph view: no package.yml found under " \
                 "#{@options[:package].inspect}; falling back to namespace collapse"
    return nil
  end

  overlay.groups_for(edges)
end

#render_payload(edges, nodes, collapse, groups) ⇒ Object

Builds the rendered payload for the chosen format and signals whether the bytes are binary (svg via Graphviz can return a non-UTF-8 image stream).



461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
# File 'lib/rigor/module_graph/cli.rb', line 461

def render_payload(edges, nodes, collapse, groups)
  case @options[:format]
  when "html"
    mermaid = Mermaid.render(edges, collapse: collapse, groups: groups)
    html = HtmlView.render(
      title: "rigor-module-graph: #{File.basename(Dir.pwd)}",
      subtitle: render_subtitle(edges, collapse, groups),
      mermaid_source: mermaid
    )
    [html, false]
  when "mermaid"
    [Mermaid.render(edges, collapse: collapse, groups: groups), false]
  when "dot"
    [Dot.render(edges, collapse: collapse, groups: groups), false]
  when "svg"
    [graphviz_svg(Dot.render(edges, collapse: collapse, groups: groups)), true]
  when "class-diagram"
    [
      Uml::ClassDiagram.render(
        edges, restrict_nodes_to_edges(nodes, edges),
        include_methods: @options[:include_methods],
        include_attributes: @options[:include_attributes],
        visibilities: @options[:visibilities]
      ),
      false
    ]
  end
end

#render_subtitle(edges, collapse, groups) ⇒ Object



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
# File 'lib/rigor/module_graph/cli.rb', line 655

def render_subtitle(edges, collapse, groups)
  parts = ["#{edges.size} edge(s) from #{Dir.pwd}"]
  if @options[:from]
    from_part = +"from: #{Array(@options[:from]).join(", ")}"
    from_part << " (depth=#{@options[:depth]})" if @options[:depth]
    from_part << " [#{@options[:direction]}]" unless @options[:direction] == :both
    parts << from_part
  end
  if groups
    uniq_packages = groups.values.uniq.sort
    preview = uniq_packages.first(SUBTITLE_COLLAPSE_PREVIEW)
    label = +"packages: #{preview.join(", ")}"
    if uniq_packages.size > preview.size
      label << " (+#{uniq_packages.size - preview.size} more)"
    end
    parts << label
  elsif !collapse.empty?
    preview = collapse.first(SUBTITLE_COLLAPSE_PREVIEW)
    label = +"collapsed: #{preview.join(", ")}"
    label << " (+#{collapse.size - preview.size} more)" if collapse.size > preview.size
    parts << label
  end
  parts.join(" · ")
end

#restrict_nodes_to_edges(nodes, edges) ⇒ Object

When the user narrows the edge set with ‘–from` / `–kind` / `–confidence`, the class diagram should only show classes that participate in those edges — otherwise every constant declared in the project still shows up as a body-less class. The filter is a no-op when the edge set already covers every node (no filters applied).



496
497
498
499
500
501
502
# File 'lib/rigor/module_graph/cli.rb', line 496

def restrict_nodes_to_edges(nodes, edges)
  return nodes if edges.empty?

  visible = Set.new
  edges.each { |edge| visible << edge.from << edge.to }
  nodes.select { |node| visible.include?(node.owner) || visible.include?(node.name) }
end

#rigor_step_label(paths) ⇒ Object



442
443
444
445
# File 'lib/rigor/module_graph/cli.rb', line 442

def rigor_step_label(paths)
  target = paths.empty? ? "configured paths" : paths.join(", ")
  "Running rigor check on #{target}"
end

#run(argv) ⇒ Object



401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/rigor/module_graph/cli.rb', line 401

def run(argv)
  parser = build_parser
  paths = parser.parse(argv)

  status = Rigor::ModuleGraph::StatusReporter.new(stderr: @stderr, quiet: @options[:quiet])

  runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
  edges, nodes = status.step(rigor_step_label(paths)) { runner.analyse(paths) }
  status.info "#{edges.size} edge(s), #{nodes.size} node(s)"

  if any_filter_active?
    edges = status.step("Applying filters") do
      apply_filters(
        edges,
        kinds: @options[:kinds],
        confidences: @options[:confidences],
        from: @options[:from],
        depth: @options[:depth],
        direction: @options[:direction],
        edge_scope: @options[:edge_scope]
      )
    end
    status.info "#{edges.size} edge(s) after filters"
  end

  groups = package_groups(edges)
  collapse = groups ? [] : effective_collapse(edges)

  payload, binary = status.step("Rendering #{@options[:format]}") do
    render_payload(edges, nodes, collapse, groups)
  end
  deliver(payload, binary: binary, edges: edges, status: status)
  0
rescue OptionParser::ParseError => e
  @stderr.puts "rigor-module-graph view: #{e.message}"
  2
rescue CollectError, RenderError => e
  @stderr.puts "rigor-module-graph view: #{e.message}"
  1
end

#silent_statusObject



452
453
454
# File 'lib/rigor/module_graph/cli.rb', line 452

def silent_status
  Rigor::ModuleGraph::StatusReporter.new(stderr: @stderr, quiet: true)
end