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.



361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
# File 'lib/rigor/module_graph/cli.rb', line 361

def initialize(stdout:, stderr:)
  @stdout = stdout
  @stderr = stderr
  @options = {
    format: "html",
    output: nil,
    cache: 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

#build_parserObject



510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
# File 'lib/rigor/module_graph/cli.rb', line 510

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
    add_filter_options(opts, @options)
    opts.on("-h", "--help") do
      @stdout.puts opts
      exit 0
    end
  end
end

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

Writes the payload to the configured destination and opens the browser when the html-default flow applies.



478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
# File 'lib/rigor/module_graph/cli.rb', line 478

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

  dir = File.dirname(destination)
  FileUtils.mkdir_p(dir) unless dir.empty? || dir == "."
  mode = binary ? "wb" : "w"
  File.open(destination, mode) { |io| io.write(payload) }
  @stderr.puts "rigor-module-graph: wrote #{edges.size} edge(s) to #{destination}"
  open_in_browser(destination) if html? && @options[:open]
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.



578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
# File 'lib/rigor/module_graph/cli.rb', line 578

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.



499
500
501
502
503
504
# File 'lib/rigor/module_graph/cli.rb', line 499

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.



464
465
466
467
468
469
470
471
472
473
474
# File 'lib/rigor/module_graph/cli.rb', line 464

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)


506
507
508
# File 'lib/rigor/module_graph/cli.rb', line 506

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

#open_in_browser(path) ⇒ Object



640
641
642
643
644
645
646
# File 'lib/rigor/module_graph/cli.rb', line 640

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



627
628
629
630
631
632
633
634
635
636
637
638
# File 'lib/rigor/module_graph/cli.rb', line 627

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



418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/rigor/module_graph/cli.rb', line 418

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



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 602

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



453
454
455
456
457
458
459
# File 'lib/rigor/module_graph/cli.rb', line 453

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

#run(argv) ⇒ Object



384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/rigor/module_graph/cli.rb', line 384

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

  runner = RigorRunner.new(rigor_cmd: @options[:rigor_cmd], cache: @options[:cache])
  edges, nodes = runner.analyse(paths)
  edges = apply_filters(
    edges,
    kinds: @options[:kinds],
    confidences: @options[:confidences],
    from: @options[:from],
    depth: @options[:depth],
    direction: @options[:direction],
    edge_scope: @options[:edge_scope]
  )
  groups = package_groups(edges)
  collapse = groups ? [] : effective_collapse(edges)

  payload, binary = render_payload(edges, nodes, collapse, groups)
  deliver(payload, binary: binary, edges: edges)
  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