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.
@@ -0,0 +1,63 @@
1
+ * { box-sizing: border-box; }
2
+ html, body {
3
+ margin: 0;
4
+ padding: 0;
5
+ height: 100%;
6
+ font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Hiragino Sans", "Yu Gothic", sans-serif;
7
+ color: #0f172a;
8
+ }
9
+ body { display: flex; flex-direction: column; }
10
+
11
+ header {
12
+ padding: 0.5rem 1rem;
13
+ background: #f8fafc;
14
+ border-bottom: 1px solid #cbd5e1;
15
+ flex: 0 0 auto;
16
+ }
17
+ header h1 { margin: 0 0 0.25rem; font-size: 1rem; font-weight: 600; }
18
+ header .subtitle { margin: 0 0 0.5rem; color: #64748b; font-size: 0.85rem; }
19
+
20
+ #controls {
21
+ display: flex;
22
+ flex-wrap: wrap;
23
+ gap: 0.75rem;
24
+ align-items: center;
25
+ }
26
+ fieldset {
27
+ margin: 0;
28
+ padding: 0.2rem 0.5rem;
29
+ border: 1px solid #cbd5e1;
30
+ border-radius: 3px;
31
+ }
32
+ fieldset legend { font-size: 0.7rem; color: #64748b; padding: 0 0.25rem; }
33
+ fieldset label {
34
+ font-size: 0.8rem;
35
+ margin-right: 0.5rem;
36
+ cursor: pointer;
37
+ user-select: none;
38
+ }
39
+ fieldset label input { margin-right: 0.15rem; }
40
+ #search {
41
+ padding: 0.25rem 0.5rem;
42
+ border: 1px solid #cbd5e1;
43
+ border-radius: 3px;
44
+ font-size: 0.85rem;
45
+ min-width: 14rem;
46
+ }
47
+ #fit {
48
+ padding: 0.25rem 0.6rem;
49
+ border: 1px solid #cbd5e1;
50
+ background: #fff;
51
+ border-radius: 3px;
52
+ font-size: 0.8rem;
53
+ cursor: pointer;
54
+ }
55
+ #counts {
56
+ color: #64748b;
57
+ font-size: 0.8rem;
58
+ margin-left: auto;
59
+ font-variant-numeric: tabular-nums;
60
+ }
61
+
62
+ main { flex: 1 1 auto; min-height: 0; }
63
+ #cy { width: 100%; height: 100%; background: #fff; }
@@ -0,0 +1,32 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <%# CSP: only self + inline (we vetted the inline JS). No
7
+ network fetches at view time — the vendored cytoscape and
8
+ our viewer.js are both inline below. -%>
9
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:">
10
+ <title><%= ERB::Util.html_escape(title) %></title>
11
+ <style><%= css %></style>
12
+ </head>
13
+ <body>
14
+ <header>
15
+ <h1><%= ERB::Util.html_escape(title) %></h1>
16
+ <% if subtitle %><p class="subtitle"><%= ERB::Util.html_escape(subtitle) %></p><% end %>
17
+ <div id="controls">
18
+ <fieldset id="filter-kind"><legend>kind</legend></fieldset>
19
+ <fieldset id="filter-confidence"><legend>confidence</legend></fieldset>
20
+ <input id="search" type="search" placeholder="search nodes...">
21
+ <button id="fit" type="button">fit</button>
22
+ <span id="counts"></span>
23
+ </div>
24
+ </header>
25
+ <main>
26
+ <div id="cy"></div>
27
+ </main>
28
+ <script type="application/json" id="rmg-data"><%= data_json %></script>
29
+ <script><%= cytoscape %></script>
30
+ <script><%= viewer %></script>
31
+ </body>
32
+ </html>
@@ -0,0 +1,166 @@
1
+ // Cytoscape viewer init. Reads the {nodes, edges, options}
2
+ // payload that Viewer::Html emitted into the inline JSON tag,
3
+ // then wires filter / search / click handlers.
4
+ //
5
+ // Kept short on purpose: total review surface for the
6
+ // interactivity layer is this file plus the vendored
7
+ // cytoscape.min.js (sha256-pinned). See docs/plan.md for the
8
+ // supply-chain rationale.
9
+ (function () {
10
+ "use strict";
11
+
12
+ const data = JSON.parse(document.getElementById("rmg-data").textContent);
13
+ const options = data.options || {};
14
+
15
+ const cy = cytoscape({
16
+ container: document.getElementById("cy"),
17
+ elements: { nodes: data.nodes, edges: data.edges },
18
+ style: [
19
+ { selector: "node",
20
+ style: {
21
+ "label": "data(name)",
22
+ "font-size": "10px",
23
+ "background-color": "#f8fafc",
24
+ "border-color": "#94a3b8",
25
+ "border-width": 1,
26
+ "shape": "round-rectangle",
27
+ "padding": "4px",
28
+ "text-valign": "center",
29
+ "text-halign": "center"
30
+ }
31
+ },
32
+ { selector: 'node[kind = "external"]',
33
+ style: { "background-color": "#e2e8f0", "color": "#64748b" }
34
+ },
35
+ { selector: "edge",
36
+ style: {
37
+ "width": 1,
38
+ "line-color": "#94a3b8",
39
+ "target-arrow-color": "#94a3b8",
40
+ "target-arrow-shape": "triangle",
41
+ "curve-style": "bezier",
42
+ "label": "data(kind)",
43
+ "font-size": "8px",
44
+ "color": "#64748b",
45
+ "text-rotation": "autorotate",
46
+ "text-background-color": "#fff",
47
+ "text-background-padding": "2px",
48
+ "text-background-opacity": 0.9
49
+ }
50
+ },
51
+ { selector: 'edge[kind = "inherits"]',
52
+ style: { "line-color": "#0f172a", "target-arrow-color": "#0f172a", "width": 2 }
53
+ },
54
+ { selector: 'edge[kind = "include"]',
55
+ style: { "line-color": "#1d4ed8", "target-arrow-color": "#1d4ed8" }
56
+ },
57
+ { selector: 'edge[kind = "prepend"]',
58
+ style: { "line-color": "#9333ea", "target-arrow-color": "#9333ea" }
59
+ },
60
+ { selector: 'edge[kind = "extend"]',
61
+ style: { "line-color": "#0f766e", "target-arrow-color": "#0f766e", "line-style": "dashed" }
62
+ },
63
+ { selector: 'edge[kind = "const_ref"]',
64
+ style: { "line-color": "#94a3b8", "target-arrow-color": "#94a3b8", "line-style": "dotted" }
65
+ },
66
+ { selector: 'edge[kind = "association"]',
67
+ style: { "line-color": "#0891b2", "target-arrow-color": "#0891b2" }
68
+ },
69
+ { selector: ".filtered-out", style: { "display": "none" } },
70
+ { selector: ".search-dim", style: { "opacity": 0.15 } }
71
+ ],
72
+ layout: { name: "cose", animate: false, nodeDimensionsIncludeLabels: true }
73
+ });
74
+
75
+ // Distinct kind / confidence values present in this dataset
76
+ // drive the checkbox fieldsets — never hard-coded.
77
+ function uniqValues(attr) {
78
+ return Array.from(new Set(data.edges.map(e => e.data[attr]))).sort();
79
+ }
80
+
81
+ function buildCheckboxes(fieldsetId, values) {
82
+ const fs = document.getElementById(fieldsetId);
83
+ values.forEach(v => {
84
+ const label = document.createElement("label");
85
+ const input = document.createElement("input");
86
+ input.type = "checkbox";
87
+ input.value = v;
88
+ input.checked = true;
89
+ input.addEventListener("change", applyFilters);
90
+ label.appendChild(input);
91
+ label.appendChild(document.createTextNode(" " + v));
92
+ fs.appendChild(label);
93
+ });
94
+ }
95
+
96
+ function selectedValues(fieldsetId) {
97
+ return new Set(
98
+ Array.from(document.querySelectorAll("#" + fieldsetId + " input:checked"))
99
+ .map(i => i.value)
100
+ );
101
+ }
102
+
103
+ function applyFilters() {
104
+ const okKinds = selectedValues("filter-kind");
105
+ const okConfs = selectedValues("filter-confidence");
106
+ cy.batch(() => {
107
+ cy.edges().forEach(e => {
108
+ const ok = okKinds.has(e.data("kind")) && okConfs.has(e.data("confidence"));
109
+ e.toggleClass("filtered-out", !ok);
110
+ });
111
+ cy.nodes().forEach(n => {
112
+ // Hide nodes with no visible incident edges so the graph
113
+ // doesn't carry orphaned constants the user can't relate
114
+ // to anything via the current filter.
115
+ const visibleEdges = n.connectedEdges(":not(.filtered-out)");
116
+ n.toggleClass("filtered-out", visibleEdges.length === 0);
117
+ });
118
+ });
119
+ updateCounts();
120
+ }
121
+
122
+ function applySearch() {
123
+ const q = document.getElementById("search").value.trim().toLowerCase();
124
+ cy.batch(() => {
125
+ if (q === "") {
126
+ cy.elements().removeClass("search-dim");
127
+ return;
128
+ }
129
+ cy.nodes().forEach(n => {
130
+ n.toggleClass("search-dim", !n.data("name").toLowerCase().includes(q));
131
+ });
132
+ cy.edges().forEach(e => {
133
+ const src = e.source().data("name").toLowerCase();
134
+ const tgt = e.target().data("name").toLowerCase();
135
+ e.toggleClass("search-dim", !(src.includes(q) || tgt.includes(q)));
136
+ });
137
+ });
138
+ }
139
+
140
+ function updateCounts() {
141
+ const nVisible = cy.nodes(":visible").length;
142
+ const eVisible = cy.edges(":visible").length;
143
+ document.getElementById("counts").textContent =
144
+ nVisible + " nodes, " + eVisible + " edges";
145
+ }
146
+
147
+ function handleNodeTap(evt) {
148
+ const n = evt.target;
149
+ const path = n.data("path");
150
+ if (!path) return;
151
+ const line = n.data("line");
152
+ const ref = line ? path + ":" + line : path;
153
+ if (options.open_with === "vscode") {
154
+ window.location.href = "vscode://file/" + ref;
155
+ } else if (navigator.clipboard && navigator.clipboard.writeText) {
156
+ navigator.clipboard.writeText(ref).catch(() => {});
157
+ }
158
+ }
159
+
160
+ buildCheckboxes("filter-kind", uniqValues("kind"));
161
+ buildCheckboxes("filter-confidence", uniqValues("confidence"));
162
+ document.getElementById("search").addEventListener("input", applySearch);
163
+ document.getElementById("fit").addEventListener("click", () => cy.fit());
164
+ cy.on("tap", "node", handleNodeTap);
165
+ updateCounts();
166
+ })();
@@ -5,6 +5,6 @@ module Rigor
5
5
  # an overview and Rigor::ModuleGraph::CLI for the CLI surface.
6
6
  module ModuleGraph
7
7
  # The installed gem version.
8
- VERSION = "0.1.1"
8
+ VERSION = "0.1.3"
9
9
  end
10
10
  end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "json"
5
+
6
+ module Rigor
7
+ module ModuleGraph
8
+ # Interactive viewer that replaces the static-Mermaid HTML
9
+ # for `view --output html`. The output is a self-contained
10
+ # HTML file: vendored `cytoscape.min.js` is inlined alongside
11
+ # our ~100-line init script and the node / edge dataset, so
12
+ # the artefact opens in any browser without a network round
13
+ # trip. See `docs/plan.md` "2D interactive viewer" for the
14
+ # supply-chain rationale.
15
+ module Viewer
16
+ module Html
17
+ module_function
18
+
19
+ TEMPLATE_DIR = File.expand_path("../templates", __dir__)
20
+ TEMPLATE_PATH = File.join(TEMPLATE_DIR, "viewer.html.erb")
21
+ CSS_PATH = File.join(TEMPLATE_DIR, "viewer.css")
22
+ VIEWER_JS_PATH = File.join(TEMPLATE_DIR, "viewer.js")
23
+ CYTOSCAPE_JS_PATH = File.join(TEMPLATE_DIR, "vendor", "cytoscape.min.js")
24
+
25
+ # Node kinds that map to top-level Cytoscape nodes.
26
+ # Method / attribute nodes are out of scope for the graph
27
+ # viewer (they belong to the class diagram, not the
28
+ # dependency graph).
29
+ CONSTANT_KINDS = %w[class module].freeze
30
+
31
+ # @param edges [Array<Edge>] dependency edges
32
+ # @param nodes [Array<Node>] node metadata (for click-through)
33
+ # @param title [String] page title
34
+ # @param subtitle [String, nil] optional subtitle line
35
+ # @param path_mode [:relative, :absolute, :none]
36
+ # how `data.path` is reported to click handlers. `:none`
37
+ # strips it entirely so HTML shared externally doesn't
38
+ # leak filesystem layout.
39
+ # @param open_with [Symbol, nil] when `:vscode`, node click
40
+ # opens `vscode://file/<path>:<line>` instead of writing
41
+ # to clipboard.
42
+ # @return [String] complete HTML document
43
+ def render(edges:, nodes:, title:, subtitle: nil, path_mode: :relative, open_with: nil)
44
+ data = build_data(
45
+ edges: edges, nodes: nodes,
46
+ path_mode: path_mode, open_with: open_with
47
+ )
48
+ template = ERB.new(File.read(TEMPLATE_PATH), trim_mode: "-")
49
+ template.result_with_hash(
50
+ title: title,
51
+ subtitle: subtitle,
52
+ data_json: safe_json(data),
53
+ css: File.read(CSS_PATH),
54
+ cytoscape: File.read(CYTOSCAPE_JS_PATH),
55
+ viewer: File.read(VIEWER_JS_PATH)
56
+ )
57
+ end
58
+
59
+ # Builds the `{nodes:, edges:, options:}` payload the
60
+ # inline init JS reads from
61
+ # `<script type="application/json" id="rmg-data">`.
62
+ def build_data(edges:, nodes:, path_mode:, open_with:)
63
+ node_meta = {}
64
+ nodes.each do |node|
65
+ next unless CONSTANT_KINDS.include?(node.kind)
66
+
67
+ key = fully_qualified(node)
68
+ # First definition wins; class re-opens still resolve
69
+ # to one Cytoscape node, matching the dedup contract
70
+ # in `Edge#dedup_key`.
71
+ node_meta[key] ||= {
72
+ # Cytoscape resolves `edge.source` / `edge.target`
73
+ # against `node.data.id`, so the constant name has
74
+ # to be the id (not just a display field).
75
+ id: key,
76
+ name: key,
77
+ kind: node.kind,
78
+ path: path_for(node.path, path_mode),
79
+ line: node.line
80
+ }
81
+ end
82
+
83
+ # Every edge endpoint becomes a node, even when the
84
+ # constant has no definition in the analysed paths
85
+ # (e.g. `ApplicationRecord` from a Rails gem). These
86
+ # get the `external` kind so the styling can dim them.
87
+ edges.flat_map { |e| [e.from, e.to] }.uniq.each do |name|
88
+ node_meta[name] ||= { id: name, name: name, kind: "external" }
89
+ end
90
+
91
+ {
92
+ nodes: node_meta.values.map { |n| { data: n } },
93
+ edges: edges.each_with_index.map do |edge, i|
94
+ {
95
+ data: {
96
+ id: "e#{i}",
97
+ source: edge.from,
98
+ target: edge.to,
99
+ kind: edge.kind,
100
+ confidence: edge.confidence
101
+ }
102
+ }
103
+ end,
104
+ options: { open_with: open_with&.to_s }
105
+ }
106
+ end
107
+
108
+ def fully_qualified(node)
109
+ owner = node.owner
110
+ owner && !owner.empty? ? "#{owner}::#{node.name}" : node.name
111
+ end
112
+
113
+ def path_for(path, mode)
114
+ return nil if path.nil? || mode == :none
115
+
116
+ case mode
117
+ when :absolute then File.expand_path(path)
118
+ when :relative then path
119
+ end
120
+ end
121
+
122
+ # JSON embedded in `<script>` must not contain `</` (would
123
+ # break out of the surrounding tag). `JSON.generate` does
124
+ # not escape it by default; rewriting the literal pair
125
+ # `</` → `<\/` is the standard safety pass.
126
+ def safe_json(value)
127
+ JSON.generate(value).gsub("</", "<\\/")
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -29,4 +29,6 @@ require_relative "rigor/module_graph/stats"
29
29
  require_relative "rigor/module_graph/packwerk_overlay"
30
30
  require_relative "rigor/module_graph/uml/class_diagram"
31
31
  require_relative "rigor/module_graph/html_view"
32
+ require_relative "rigor/module_graph/status_reporter"
33
+ require_relative "rigor/module_graph/viewer/html"
32
34
  require_relative "rigor/module_graph/plugin"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rigor-module-graph
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nozomi Hijikata
@@ -70,9 +70,16 @@ files:
70
70
  - lib/rigor/module_graph/plugin/rigor_plugin.rb
71
71
  - lib/rigor/module_graph/reachability.rb
72
72
  - lib/rigor/module_graph/stats.rb
73
+ - lib/rigor/module_graph/status_reporter.rb
74
+ - lib/rigor/module_graph/templates/vendor/CHECKSUMS
75
+ - lib/rigor/module_graph/templates/vendor/cytoscape.min.js
73
76
  - lib/rigor/module_graph/templates/view.html.erb
77
+ - lib/rigor/module_graph/templates/viewer.css
78
+ - lib/rigor/module_graph/templates/viewer.html.erb
79
+ - lib/rigor/module_graph/templates/viewer.js
74
80
  - lib/rigor/module_graph/uml/class_diagram.rb
75
81
  - lib/rigor/module_graph/version.rb
82
+ - lib/rigor/module_graph/viewer/html.rb
76
83
  - lib/rigor/module_graph/visibility_map.rb
77
84
  - lib/rigor/module_graph/zeitwerk_resolver.rb
78
85
  licenses:
@@ -88,7 +95,7 @@ rdoc_options:
88
95
  - "--main"
89
96
  - README.md
90
97
  - "--markup"
91
- - rdoc
98
+ - markdown
92
99
  require_paths:
93
100
  - lib
94
101
  required_ruby_version: !ruby/object:Gem::Requirement
@@ -105,7 +112,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
112
  - !ruby/object:Gem::Version
106
113
  version: '0'
107
114
  requirements: []
108
- rubygems_version: 4.0.3
115
+ rubygems_version: 4.0.10
109
116
  specification_version: 4
110
117
  summary: Class/module/constant dependency graph for Ruby projects, built on Rigor.
111
118
  test_files: []