dagward 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -44,6 +44,14 @@ Requirements: Node.js ≥ 20, a TypeScript project with a `tsconfig.json`. Monor
44
44
  - **`ARCHITECTURE.md`** — a human-readable snapshot of your system as it actually is: layers, module boundaries, existing cycles, and the edges that look accidental. Useful on its own, even if you never adopt the rules.
45
45
  - **`dagward.yml`** — a draft rule file. Nothing is enforced until you review, edit, and commit it. Confirming is a 10-minute job; writing from scratch would take days.
46
46
 
47
+ ## Visualize
48
+
49
+ ```bash
50
+ npx dagward graph && npx dagward viz
51
+ ```
52
+
53
+ `dagward viz` renders the graphs in `dagward-out/` to a single self-contained `viz.html` — no network, no CDN, works offline — and opens it in your default browser (`--no-open` to skip). Tabs switch between folder, file, and function level; dependencies point downward, type-only imports are dashed, and cycle members are flagged red.
54
+
47
55
  ## The rule file
48
56
 
49
57
  Rules are declarative YAML — language-agnostic by design, and readable by both humans and agents. MVP supports three rule types:
package/dist/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
4
5
  import { pathToFileURL } from "node:url";
@@ -9,21 +10,31 @@ import { buildFunctionGraph } from "./functionGraph.js";
9
10
  import { serializeGraph } from "./graph.js";
10
11
  import { ConfigError, loadProject } from "./project.js";
11
12
  import { renderArchitectureMd } from "./report.js";
13
+ import { renderVizHtml } from "./viz.js";
12
14
  const HELP = `dagward — multi-level dependency graphs for TypeScript projects
13
15
 
14
16
  Usage:
15
17
  dagward graph [dir] [options] Analyze the project and write graphs + report
16
18
  dagward init [dir] [options] Alias of \`graph\` (rule inference coming later)
19
+ dagward viz [dir] [options] Render dagward-out graphs to an interactive viz.html
17
20
 
18
21
  Options:
19
22
  --project <path> Explicit tsconfig.json (default: nearest to [dir])
20
23
  --out <dir> Output directory (default: <dir>/dagward-out)
24
+ --no-open viz: do not open viz.html in the browser
21
25
  --help Show this help
22
26
  --version Show version
23
27
 
24
28
  Outputs: graph.folders.json, graph.files.json, graph.functions.json, ARCHITECTURE.md
25
29
 
26
30
  Exit codes: 0 ok, 2 config error.`;
31
+ function timed(label, fn) {
32
+ const start = performance.now();
33
+ const result = fn();
34
+ const text = typeof label === "string" ? label : label(result);
35
+ console.error(`→ ${text} … ${Math.round(performance.now() - start)}ms`);
36
+ return result;
37
+ }
27
38
  function version() {
28
39
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
29
40
  return pkg.version;
@@ -34,6 +45,7 @@ export function main(argv) {
34
45
  options: {
35
46
  project: { type: "string" },
36
47
  out: { type: "string" },
48
+ "no-open": { type: "boolean" },
37
49
  help: { type: "boolean" },
38
50
  version: { type: "boolean" },
39
51
  },
@@ -48,7 +60,7 @@ export function main(argv) {
48
60
  return 0;
49
61
  }
50
62
  const [command, dirArg] = positionals;
51
- if (command !== "graph" && command !== "init") {
63
+ if (command !== "graph" && command !== "init" && command !== "viz") {
52
64
  console.error(command ? `Unknown command: ${command}` : HELP);
53
65
  return command ? 2 : 0;
54
66
  }
@@ -57,9 +69,12 @@ export function main(argv) {
57
69
  }
58
70
  const targetDir = path.resolve(dirArg ?? ".");
59
71
  const outDir = path.resolve(values.out ?? path.join(targetDir, "dagward-out"));
72
+ if (command === "viz") {
73
+ return runViz(outDir, values["no-open"] ?? false);
74
+ }
60
75
  let project;
61
76
  try {
62
- project = loadProject(targetDir, values.project ? path.resolve(values.project) : undefined);
77
+ project = timed((p) => `load project (${p.sourceFiles.length} files, tsconfig at ${p.rootDir})`, () => loadProject(targetDir, values.project ? path.resolve(values.project) : undefined));
63
78
  }
64
79
  catch (error) {
65
80
  if (error instanceof ConfigError) {
@@ -68,15 +83,16 @@ export function main(argv) {
68
83
  }
69
84
  throw error;
70
85
  }
71
- console.error(`Analyzing ${project.sourceFiles.length} files (tsconfig at ${project.rootDir})`);
72
- const { graph: files, skippedDynamicImports } = buildFileGraph(project);
73
- const folders = buildFolderGraph(files);
74
- const functions = buildFunctionGraph(project);
75
- fs.mkdirSync(outDir, { recursive: true });
76
- fs.writeFileSync(path.join(outDir, "graph.folders.json"), serializeGraph(folders));
77
- fs.writeFileSync(path.join(outDir, "graph.files.json"), serializeGraph(files));
78
- fs.writeFileSync(path.join(outDir, "graph.functions.json"), serializeGraph(functions));
79
- fs.writeFileSync(path.join(outDir, "ARCHITECTURE.md"), renderArchitectureMd({ folders, files, functions, skippedDynamicImports, version: version() }));
86
+ const { graph: files, skippedDynamicImports } = timed("file graph", () => buildFileGraph(project));
87
+ const folders = timed("folder graph", () => buildFolderGraph(files));
88
+ const functions = timed("function graph", () => buildFunctionGraph(project));
89
+ timed("write outputs", () => {
90
+ fs.mkdirSync(outDir, { recursive: true });
91
+ fs.writeFileSync(path.join(outDir, "graph.folders.json"), serializeGraph(folders));
92
+ fs.writeFileSync(path.join(outDir, "graph.files.json"), serializeGraph(files));
93
+ fs.writeFileSync(path.join(outDir, "graph.functions.json"), serializeGraph(functions));
94
+ fs.writeFileSync(path.join(outDir, "ARCHITECTURE.md"), renderArchitectureMd({ folders, files, functions, skippedDynamicImports, version: version() }));
95
+ });
80
96
  for (const graph of [folders, files, functions]) {
81
97
  const cycles = graph.cycles.length > 0 ? `${graph.cycles.length} cycle(s)!` : "no cycles";
82
98
  console.error(` ${graph.level}: ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${cycles}`);
@@ -84,6 +100,37 @@ export function main(argv) {
84
100
  console.error(`Wrote 4 files to ${outDir}`);
85
101
  return 0;
86
102
  }
103
+ function runViz(outDir, noOpen) {
104
+ const graphs = {};
105
+ for (const level of ["folders", "files", "functions"]) {
106
+ const file = path.join(outDir, `graph.${level}.json`);
107
+ try {
108
+ graphs[level] = JSON.parse(fs.readFileSync(file, "utf8"));
109
+ }
110
+ catch {
111
+ console.error(`Cannot read ${file}. Run \`dagward graph\` first.`);
112
+ return 2;
113
+ }
114
+ }
115
+ const htmlPath = path.join(outDir, "viz.html");
116
+ timed("write viz.html", () => fs.writeFileSync(htmlPath, renderVizHtml(graphs)));
117
+ console.error(`Wrote ${htmlPath}`);
118
+ if (!noOpen)
119
+ openInBrowser(htmlPath);
120
+ return 0;
121
+ }
122
+ // Never shell: true — the path goes through as a plain argv element, and
123
+ // rundll32 (not `cmd /c start`) avoids cmd.exe re-parsing it on Windows.
124
+ function openInBrowser(file) {
125
+ const [cmd, args] = process.platform === "darwin"
126
+ ? ["open", [file]]
127
+ : process.platform === "win32"
128
+ ? ["rundll32", ["url.dll,FileProtocolHandler", file]]
129
+ : ["xdg-open", [file]];
130
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
131
+ child.on("error", () => { }); // a missing opener is not a CLI failure
132
+ child.unref();
133
+ }
87
134
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
88
135
  process.exit(main(process.argv.slice(2)));
89
136
  }
package/dist/viz.js ADDED
@@ -0,0 +1,286 @@
1
+ const STYLE = `
2
+ * { margin: 0; box-sizing: border-box; }
3
+ html, body { height: 100%; }
4
+ body { font: 13px/1.4 ui-sans-serif, system-ui, sans-serif; display: flex; flex-direction: column; background: #f8fafc; color: #0f172a; }
5
+ header { display: flex; align-items: center; gap: 12px; padding: 8px 14px; background: #0f172a; color: #e2e8f0; }
6
+ .brand { font-weight: 700; margin-right: 8px; }
7
+ .tab { background: none; border: 1px solid #334155; color: #cbd5e1; border-radius: 6px; padding: 4px 10px; cursor: pointer; font: inherit; }
8
+ .tab.active { background: #1e293b; color: #fff; border-color: #64748b; }
9
+ .tab span { opacity: 0.6; font-size: 11px; }
10
+ #legend { margin-left: auto; font-size: 11px; color: #94a3b8; }
11
+ #canvas { flex: 1; touch-action: none; cursor: grab; }
12
+ .node { cursor: pointer; }
13
+ .node rect { fill: #fff; stroke: #94a3b8; }
14
+ .node text { font: 11px ui-monospace, Menlo, monospace; fill: #0f172a; pointer-events: none; }
15
+ .node.cyclic rect { stroke: #dc2626; fill: #fef2f2; }
16
+ .edge { fill: none; stroke: #94a3b8; stroke-width: 1.2; }
17
+ .edge.cyc { stroke: #dc2626; }
18
+ .k-type { stroke-dasharray: 4 3; }
19
+ .k-dynamic { stroke-dasharray: 1.5 3; }
20
+ .dimmed .node:not(.hi), .dimmed .edge:not(.hi) { opacity: 0.12; }
21
+ .edge.hi { stroke: #0f766e; stroke-width: 1.8; }
22
+ #details { position: fixed; right: 12px; top: 54px; background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; max-width: 380px; box-shadow: 0 4px 14px rgba(15, 23, 42, 0.12); font-size: 12px; word-break: break-all; }
23
+ #details div { margin: 2px 0; }
24
+ `;
25
+ // Client-side JS. Lives inside a TS template literal, so it must not contain
26
+ // backticks, \${, or a literal </script> sequence.
27
+ const SCRIPT = `
28
+ "use strict";
29
+ var DATA = JSON.parse(document.getElementById("data").textContent);
30
+ var NW = 170, NH = 26, HGAP = 14, VGAP = 64;
31
+ var svg = document.getElementById("canvas");
32
+ var world = document.getElementById("world");
33
+ var details = document.getElementById("details");
34
+ var layouts = {};
35
+ var current = null;
36
+ var view = { s: 1, tx: 0, ty: 0 };
37
+ var nodeEls = {}, incident = {}, neighbors = {};
38
+
39
+ // graph.cycles is exactly the set of non-trivial SCCs, so the condensation
40
+ // (collapse each cycle to one component) is acyclic by construction.
41
+ function condense(graph) {
42
+ var compOf = {};
43
+ graph.nodes.forEach(function (n) { compOf[n.id] = n.id; });
44
+ graph.cycles.forEach(function (c) {
45
+ c.nodes.forEach(function (id) { compOf[id] = "cycle:" + c.id; });
46
+ });
47
+ var deps = {};
48
+ graph.nodes.forEach(function (n) { deps[compOf[n.id]] = deps[compOf[n.id]] || {}; });
49
+ graph.edges.forEach(function (e) {
50
+ var a = compOf[e.from], b = compOf[e.to];
51
+ if (a !== b) deps[a][b] = true;
52
+ });
53
+ return { compOf: compOf, deps: deps };
54
+ }
55
+
56
+ // Longest-path layering: depth 0 depends on nothing; cycle members share a depth.
57
+ function layerDepths(deps) {
58
+ var depth = {};
59
+ function resolve(c) {
60
+ if (depth[c] !== undefined) return depth[c];
61
+ var d = 0;
62
+ Object.keys(deps[c]).forEach(function (t) { d = Math.max(d, resolve(t) + 1); });
63
+ depth[c] = d;
64
+ return d;
65
+ }
66
+ Object.keys(deps).forEach(resolve);
67
+ return depth;
68
+ }
69
+
70
+ function computeLayout(graph) {
71
+ var c = condense(graph);
72
+ var depth = layerDepths(c.deps);
73
+ var rows = {}, maxDepth = 0;
74
+ graph.nodes.forEach(function (n) {
75
+ var d = depth[c.compOf[n.id]];
76
+ (rows[d] = rows[d] || []).push(n.id);
77
+ maxDepth = Math.max(maxDepth, d);
78
+ });
79
+ var maxRowWidth = 0;
80
+ Object.keys(rows).forEach(function (d) {
81
+ rows[d].sort();
82
+ maxRowWidth = Math.max(maxRowWidth, rows[d].length * (NW + HGAP) - HGAP);
83
+ });
84
+ var pos = {};
85
+ Object.keys(rows).forEach(function (d) {
86
+ var row = rows[d];
87
+ var x0 = (maxRowWidth - (row.length * (NW + HGAP) - HGAP)) / 2;
88
+ row.forEach(function (id, i) {
89
+ pos[id] = { x: x0 + i * (NW + HGAP), y: (maxDepth - d) * (NH + VGAP) };
90
+ });
91
+ });
92
+ var cyclic = {};
93
+ graph.cycles.forEach(function (cy) { cy.nodes.forEach(function (id) { cyclic[id] = cy.id; }); });
94
+ return { pos: pos, cyclic: cyclic, width: maxRowWidth, height: maxDepth * (NH + VGAP) + NH };
95
+ }
96
+
97
+ function el(tag, attrs) {
98
+ var e = document.createElementNS("http://www.w3.org/2000/svg", tag);
99
+ for (var k in attrs) e.setAttribute(k, attrs[k]);
100
+ return e;
101
+ }
102
+
103
+ function edgePath(p1, p2, self) {
104
+ if (self) {
105
+ var rx = p1.x + NW, ry = p1.y + NH / 2;
106
+ return "M" + rx + " " + (ry - 6) + " C " + (rx + 34) + " " + (ry - 20) + ", " + (rx + 34) + " " + (ry + 20) + ", " + rx + " " + (ry + 6);
107
+ }
108
+ if (p1.y === p2.y) {
109
+ var leftToRight = p1.x < p2.x;
110
+ var x1 = p1.x + (leftToRight ? NW : 0), x2 = p2.x + (leftToRight ? 0 : NW);
111
+ var y = p1.y + NH / 2, mx = (x1 + x2) / 2;
112
+ return "M" + x1 + " " + y + " C " + mx + " " + (y - 34) + ", " + mx + " " + (y - 34) + ", " + x2 + " " + y;
113
+ }
114
+ var sx = p1.x + NW / 2, sy = p1.y + NH, tx = p2.x + NW / 2, ty = p2.y;
115
+ var my = (sy + ty) / 2;
116
+ return "M" + sx + " " + sy + " C " + sx + " " + my + ", " + tx + " " + my + ", " + tx + " " + ty;
117
+ }
118
+
119
+ function label(id) {
120
+ return id.length > 24 ? "\\u2026" + id.slice(-23) : id;
121
+ }
122
+
123
+ function reg(id, edgeEl, neighborId) {
124
+ (incident[id] = incident[id] || []).push(edgeEl);
125
+ (neighbors[id] = neighbors[id] || []).push(neighborId);
126
+ }
127
+
128
+ function highlight(id) {
129
+ svg.classList.add("dimmed");
130
+ nodeEls[id].classList.add("hi");
131
+ (incident[id] || []).forEach(function (p) { p.classList.add("hi"); });
132
+ (neighbors[id] || []).forEach(function (nb) { if (nodeEls[nb]) nodeEls[nb].classList.add("hi"); });
133
+ }
134
+
135
+ function clearHighlight() {
136
+ svg.classList.remove("dimmed");
137
+ world.querySelectorAll(".hi").forEach(function (e) { e.classList.remove("hi"); });
138
+ }
139
+
140
+ function showDetails(n) {
141
+ var graph = DATA[current], fanIn = 0, fanOut = 0;
142
+ graph.edges.forEach(function (e) {
143
+ if (e.from === n.id) fanOut++;
144
+ if (e.to === n.id) fanIn++;
145
+ });
146
+ details.hidden = false;
147
+ details.textContent = "";
148
+ var rows = [["id", n.id]];
149
+ if (n.file) rows.push(["file", n.file + (n.line ? ":" + n.line : "")]);
150
+ rows.push(["depends on", fanOut], ["depended on by", fanIn]);
151
+ var cyc = layouts[current].cyclic[n.id];
152
+ if (cyc !== undefined) rows.push(["cycle", "#" + cyc]);
153
+ rows.forEach(function (r) {
154
+ var d = document.createElement("div");
155
+ var b = document.createElement("b");
156
+ b.textContent = r[0] + ": ";
157
+ d.appendChild(b);
158
+ d.appendChild(document.createTextNode(r[1]));
159
+ details.appendChild(d);
160
+ });
161
+ }
162
+
163
+ function render(level) {
164
+ current = level;
165
+ var graph = DATA[level];
166
+ if (!layouts[level]) layouts[level] = computeLayout(graph);
167
+ var L = layouts[level];
168
+ world.textContent = "";
169
+ nodeEls = {}; incident = {}; neighbors = {};
170
+ graph.edges.forEach(function (e) {
171
+ var sameCycle = L.cyclic[e.from] !== undefined && L.cyclic[e.from] === L.cyclic[e.to];
172
+ var p = el("path", {
173
+ d: edgePath(L.pos[e.from], L.pos[e.to], e.from === e.to),
174
+ "class": "edge k-" + e.kind + (sameCycle ? " cyc" : ""),
175
+ "marker-end": "url(#arrow)",
176
+ });
177
+ world.appendChild(p);
178
+ reg(e.from, p, e.to);
179
+ if (e.from !== e.to) reg(e.to, p, e.from);
180
+ });
181
+ graph.nodes.forEach(function (n) {
182
+ var p = L.pos[n.id];
183
+ var g = el("g", {
184
+ "class": "node" + (L.cyclic[n.id] !== undefined ? " cyclic" : ""),
185
+ transform: "translate(" + p.x + "," + p.y + ")",
186
+ });
187
+ g.appendChild(el("rect", { width: NW, height: NH, rx: 6 }));
188
+ var t = el("text", { x: 8, y: 17 });
189
+ t.textContent = label(n.id);
190
+ g.appendChild(t);
191
+ var title = el("title", {});
192
+ title.textContent = n.id;
193
+ g.appendChild(title);
194
+ g.addEventListener("mouseenter", function () { highlight(n.id); });
195
+ g.addEventListener("mouseleave", clearHighlight);
196
+ g.addEventListener("click", function () { showDetails(n); });
197
+ world.appendChild(g);
198
+ nodeEls[n.id] = g;
199
+ });
200
+ }
201
+
202
+ function applyView() {
203
+ world.setAttribute("transform", "translate(" + view.tx + "," + view.ty + ") scale(" + view.s + ")");
204
+ }
205
+
206
+ function fit(L) {
207
+ var vw = svg.clientWidth, vh = svg.clientHeight;
208
+ view.s = Math.min(vw / (L.width + 80), vh / (L.height + 80), 1.5);
209
+ view.tx = (vw - L.width * view.s) / 2;
210
+ view.ty = (vh - L.height * view.s) / 2;
211
+ applyView();
212
+ }
213
+
214
+ svg.addEventListener("wheel", function (ev) {
215
+ ev.preventDefault();
216
+ var rect = svg.getBoundingClientRect();
217
+ var mx = ev.clientX - rect.left, my = ev.clientY - rect.top;
218
+ var s2 = Math.min(4, Math.max(0.05, view.s * Math.exp(-ev.deltaY * 0.0015)));
219
+ var f = s2 / view.s;
220
+ view.tx = mx - (mx - view.tx) * f;
221
+ view.ty = my - (my - view.ty) * f;
222
+ view.s = s2;
223
+ applyView();
224
+ }, { passive: false });
225
+
226
+ var drag = null;
227
+ svg.addEventListener("pointerdown", function (ev) {
228
+ if (ev.target.closest(".node")) return;
229
+ details.hidden = true;
230
+ drag = { x: ev.clientX, y: ev.clientY, tx: view.tx, ty: view.ty };
231
+ svg.setPointerCapture(ev.pointerId);
232
+ });
233
+ svg.addEventListener("pointermove", function (ev) {
234
+ if (!drag) return;
235
+ view.tx = drag.tx + ev.clientX - drag.x;
236
+ view.ty = drag.ty + ev.clientY - drag.y;
237
+ applyView();
238
+ });
239
+ svg.addEventListener("pointerup", function () { drag = null; });
240
+
241
+ function setLevel(level) {
242
+ document.querySelectorAll(".tab").forEach(function (b) {
243
+ b.classList.toggle("active", b.dataset.level === level);
244
+ });
245
+ details.hidden = true;
246
+ clearHighlight();
247
+ render(level);
248
+ fit(layouts[level]);
249
+ }
250
+
251
+ document.querySelectorAll(".tab").forEach(function (b) {
252
+ b.addEventListener("click", function () { setLevel(b.dataset.level); });
253
+ });
254
+ setLevel("folders");
255
+ `;
256
+ function escapeHtml(text) {
257
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
258
+ }
259
+ export function renderVizHtml(input) {
260
+ // <-escape so a node id containing </script> cannot terminate the data
261
+ // tag; the blob stays valid JSON.
262
+ const data = JSON.stringify(input).replaceAll("<", "\\u003c");
263
+ const tabs = ["folders", "files", "functions"]
264
+ .map((level) => {
265
+ const graph = input[level];
266
+ return `<button class="tab" data-level="${level}">${level} <span>${graph.nodes.length}·${graph.edges.length}</span></button>`;
267
+ })
268
+ .join("");
269
+ return `<!doctype html>
270
+ <html lang="en">
271
+ <head>
272
+ <meta charset="utf-8">
273
+ <meta name="viewport" content="width=device-width, initial-scale=1">
274
+ <title>dagward — ${escapeHtml(input.files.root)}</title>
275
+ <style>${STYLE}</style>
276
+ </head>
277
+ <body>
278
+ <header><span class="brand">dagward</span><nav>${tabs}</nav><span id="legend">dependencies point down · dashed = type-only · red = cycle · scroll zoom · drag pan</span></header>
279
+ <svg id="canvas"><defs><marker id="arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="#94a3b8"/></marker></defs><g id="world"></g></svg>
280
+ <div id="details" hidden></div>
281
+ <script id="data" type="application/json">${data}</script>
282
+ <script>${SCRIPT}</script>
283
+ </body>
284
+ </html>
285
+ `;
286
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dagward",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Deterministic architecture review for TypeScript projects: dependency graphs at folder, file, and function level.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {