dagward 0.1.0 → 0.2.0

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 init && 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:
@@ -126,6 +134,10 @@ With `--format agent`, a violation looks like this:
126
134
 
127
135
  The Claude Code hook feeds this back automatically after each edit. In practice, agents fix their own violations on the next turn without human intervention.
128
136
 
137
+ ## Annotations
138
+
139
+ Graph nodes can carry an optional `annotation` object — `summary`, `inputs`, `outputs`, `should`, `shouldNot`, `side` (frontend/backend/shared/tooling), `pure` — written by a human or an AI pass, never by dagward itself. Dagward preserves annotations by node id when regenerating graphs, so `dagward init` can run on every change without losing them. Enforcement remains purely deterministic; annotations are context for humans and agents reading the graph.
140
+
129
141
  ## Exemptions
130
142
 
131
143
  Real architectures have exceptions. Unexplained exceptions are how architectures rot — so every exemption requires a reason, and lives in git:
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";
@@ -6,24 +7,33 @@ import { parseArgs } from "node:util";
6
7
  import { buildFileGraph } from "./fileGraph.js";
7
8
  import { buildFolderGraph } from "./folderGraph.js";
8
9
  import { buildFunctionGraph } from "./functionGraph.js";
9
- import { serializeGraph } from "./graph.js";
10
+ import { carryAnnotations, 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
- dagward graph [dir] [options] Analyze the project and write graphs + report
16
- dagward init [dir] [options] Alias of \`graph\` (rule inference coming later)
17
+ dagward init [dir] [options] Analyze the project and write graphs + report (dir defaults to .)
18
+ dagward viz [dir] [options] Render dagward-out graphs to an interactive viz.html
17
19
 
18
20
  Options:
19
21
  --project <path> Explicit tsconfig.json (default: nearest to [dir])
20
22
  --out <dir> Output directory (default: <dir>/dagward-out)
23
+ --no-open viz: do not open viz.html in the browser
21
24
  --help Show this help
22
25
  --version Show version
23
26
 
24
27
  Outputs: graph.folders.json, graph.files.json, graph.functions.json, ARCHITECTURE.md
25
28
 
26
29
  Exit codes: 0 ok, 2 config error.`;
30
+ function timed(label, fn) {
31
+ const start = performance.now();
32
+ const result = fn();
33
+ const text = typeof label === "string" ? label : label(result);
34
+ console.error(`→ ${text} … ${Math.round(performance.now() - start)}ms`);
35
+ return result;
36
+ }
27
37
  function version() {
28
38
  const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
29
39
  return pkg.version;
@@ -34,6 +44,7 @@ export function main(argv) {
34
44
  options: {
35
45
  project: { type: "string" },
36
46
  out: { type: "string" },
47
+ "no-open": { type: "boolean" },
37
48
  help: { type: "boolean" },
38
49
  version: { type: "boolean" },
39
50
  },
@@ -48,18 +59,19 @@ export function main(argv) {
48
59
  return 0;
49
60
  }
50
61
  const [command, dirArg] = positionals;
51
- if (command !== "graph" && command !== "init") {
62
+ if (command !== "init" && command !== "viz") {
52
63
  console.error(command ? `Unknown command: ${command}` : HELP);
53
64
  return command ? 2 : 0;
54
65
  }
55
- if (command === "init") {
56
- console.error("Note: `init` currently behaves like `graph`; rule inference is coming later.");
57
- }
58
66
  const targetDir = path.resolve(dirArg ?? ".");
59
67
  const outDir = path.resolve(values.out ?? path.join(targetDir, "dagward-out"));
68
+ if (command === "viz") {
69
+ return runViz(outDir, values["no-open"] ?? false);
70
+ }
71
+ console.error(`Initializing dagward in ${targetDir}`);
60
72
  let project;
61
73
  try {
62
- project = loadProject(targetDir, values.project ? path.resolve(values.project) : undefined);
74
+ project = timed((p) => `load project (${p.sourceFiles.length} files, tsconfig at ${p.rootDir})`, () => loadProject(targetDir, values.project ? path.resolve(values.project) : undefined));
63
75
  }
64
76
  catch (error) {
65
77
  if (error instanceof ConfigError) {
@@ -68,15 +80,16 @@ export function main(argv) {
68
80
  }
69
81
  throw error;
70
82
  }
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() }));
83
+ const { graph: files, skippedDynamicImports } = timed("file graph", () => buildFileGraph(project));
84
+ const folders = timed("folder graph", () => buildFolderGraph(files));
85
+ const functions = timed("function graph", () => buildFunctionGraph(project));
86
+ timed("write outputs", () => {
87
+ fs.mkdirSync(outDir, { recursive: true });
88
+ writeGraph(path.join(outDir, "graph.folders.json"), folders);
89
+ writeGraph(path.join(outDir, "graph.files.json"), files);
90
+ writeGraph(path.join(outDir, "graph.functions.json"), functions);
91
+ fs.writeFileSync(path.join(outDir, "ARCHITECTURE.md"), renderArchitectureMd({ folders, files, functions, skippedDynamicImports, version: version() }));
92
+ });
80
93
  for (const graph of [folders, files, functions]) {
81
94
  const cycles = graph.cycles.length > 0 ? `${graph.cycles.length} cycle(s)!` : "no cycles";
82
95
  console.error(` ${graph.level}: ${graph.nodes.length} nodes, ${graph.edges.length} edges, ${cycles}`);
@@ -84,6 +97,50 @@ export function main(argv) {
84
97
  console.error(`Wrote 4 files to ${outDir}`);
85
98
  return 0;
86
99
  }
100
+ // Node annotations are authored externally (AI or human) but live on the
101
+ // graph nodes; regenerating must not wipe them.
102
+ function writeGraph(file, graph) {
103
+ try {
104
+ const prev = JSON.parse(fs.readFileSync(file, "utf8"));
105
+ if (Array.isArray(prev?.nodes))
106
+ carryAnnotations(prev, graph);
107
+ }
108
+ catch {
109
+ // absent or malformed previous output: nothing to preserve
110
+ }
111
+ fs.writeFileSync(file, serializeGraph(graph));
112
+ }
113
+ function runViz(outDir, noOpen) {
114
+ const graphs = {};
115
+ for (const level of ["folders", "files", "functions"]) {
116
+ const file = path.join(outDir, `graph.${level}.json`);
117
+ try {
118
+ graphs[level] = JSON.parse(fs.readFileSync(file, "utf8"));
119
+ }
120
+ catch {
121
+ console.error(`Cannot read ${file}. Run \`dagward init\` first.`);
122
+ return 2;
123
+ }
124
+ }
125
+ const htmlPath = path.join(outDir, "viz.html");
126
+ timed("write viz.html", () => fs.writeFileSync(htmlPath, renderVizHtml(graphs)));
127
+ console.error(`Wrote ${htmlPath}`);
128
+ if (!noOpen)
129
+ openInBrowser(htmlPath);
130
+ return 0;
131
+ }
132
+ // Never shell: true — the path goes through as a plain argv element, and
133
+ // rundll32 (not `cmd /c start`) avoids cmd.exe re-parsing it on Windows.
134
+ function openInBrowser(file) {
135
+ const [cmd, args] = process.platform === "darwin"
136
+ ? ["open", [file]]
137
+ : process.platform === "win32"
138
+ ? ["rundll32", ["url.dll,FileProtocolHandler", file]]
139
+ : ["xdg-open", [file]];
140
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
141
+ child.on("error", () => { }); // a missing opener is not a CLI failure
142
+ child.unref();
143
+ }
87
144
  if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
88
145
  process.exit(main(process.argv.slice(2)));
89
146
  }
package/dist/graph.js CHANGED
@@ -86,3 +86,16 @@ export function buildGraph(level, root, nodes, edges) {
86
86
  export function serializeGraph(graph) {
87
87
  return JSON.stringify(graph, null, 2) + "\n";
88
88
  }
89
+ // Copy node annotations from a previous graph onto a freshly built one, by
90
+ // node id. A node that was rebuilt with its own annotation keeps it.
91
+ export function carryAnnotations(prev, next) {
92
+ const previous = new Map(prev.nodes.filter((n) => n.annotation).map((n) => [n.id, n.annotation]));
93
+ for (const node of next.nodes) {
94
+ if (!node.annotation) {
95
+ const annotation = previous.get(node.id);
96
+ if (annotation)
97
+ node.annotation = annotation;
98
+ }
99
+ }
100
+ return next;
101
+ }
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.2.0",
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": {