ddduck 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/docs/architecture.md +77 -0
  4. package/docs/cli.md +239 -0
  5. package/docs/getting-started.md +98 -0
  6. package/docs/model-reference.md +92 -0
  7. package/docs/model.md +75 -0
  8. package/package.json +76 -0
  9. package/policies/concept-owner-domain.yaml +8 -0
  10. package/policies/documentation-model-reference-resolution.yaml +8 -0
  11. package/policies/no-dangling-model-reference.yaml +8 -0
  12. package/policies/policy-spec.schema.json +23 -0
  13. package/schemas/context-pack.schema.json +83 -0
  14. package/schemas/fr-to-code-audit.schema.json +85 -0
  15. package/schemas/product/concept.schema.json +17 -0
  16. package/schemas/product/domain-interface.schema.json +18 -0
  17. package/schemas/product/domain.schema.json +24 -0
  18. package/schemas/product/evidence-anchor.schema.json +30 -0
  19. package/schemas/product/guarantee.schema.json +35 -0
  20. package/schemas/product/model.schema.json +20 -0
  21. package/schemas/product/relationship.schema.json +21 -0
  22. package/schemas/product/use-case.schema.json +27 -0
  23. package/scripts/audit-fr-to-code.mjs +162 -0
  24. package/scripts/check-generated-docs.mjs +58 -0
  25. package/scripts/check-generated-graph-svg.mjs +60 -0
  26. package/scripts/check-generated-graph.mjs +66 -0
  27. package/scripts/check-model.mjs +488 -0
  28. package/scripts/ddduck.mjs +542 -0
  29. package/scripts/generate-agent-readiness-report.mjs +23 -0
  30. package/scripts/generate-docs.mjs +205 -0
  31. package/scripts/generate-graph-svg.mjs +359 -0
  32. package/scripts/generate-graph.mjs +268 -0
  33. package/scripts/lib/agent-readiness-evals.mjs +433 -0
  34. package/scripts/lib/agent-readiness-report.mjs +79 -0
  35. package/scripts/lib/cli-contract.mjs +162 -0
  36. package/scripts/lib/context-pack.mjs +107 -0
  37. package/scripts/lib/ddduck-config.mjs +57 -0
  38. package/scripts/lib/fr-to-code-audit.mjs +144 -0
  39. package/scripts/lib/product-layout.mjs +93 -0
  40. package/scripts/lib/product-operation.mjs +431 -0
  41. package/scripts/lib/product-paths.mjs +43 -0
  42. package/scripts/lib/product-query.mjs +284 -0
  43. package/scripts/lib/product-root-resolver.mjs +167 -0
  44. package/scripts/lib/scan-ignore.mjs +8 -0
  45. package/scripts/lib/skill-installer.mjs +410 -0
  46. package/scripts/query-model.mjs +64 -0
  47. package/scripts/run-agent-readiness-evals.mjs +57 -0
  48. package/skills/update-ddduck-specs/SKILL.md +98 -0
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { mkdirSync, writeFileSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { detectProductLayout, loadProductNodes } from "./lib/product-layout.mjs";
7
+ import { resolveContainedOutput } from "./lib/product-paths.mjs";
8
+ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
9
+
10
+ const outputPath = path.join("generated", "docs", "model-overview.md");
11
+
12
+ export function buildModelOverview(rootPath) {
13
+ const layout = detectProductLayout(rootPath);
14
+ const graph = loadGraph(layout);
15
+ return renderModelOverview(assembleModelView(graph, findModelId(graph)));
16
+ }
17
+
18
+ export function writeModelOverview(rootPath) {
19
+ const output = buildModelOverview(rootPath);
20
+ const absoluteOutputPath = resolveContainedOutput(rootPath, outputPath);
21
+ mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
22
+ writeFileSync(absoluteOutputPath, output);
23
+ return outputPath;
24
+ }
25
+
26
+ function loadGraph(layout) {
27
+ const loaded = loadProductNodes(layout);
28
+ return {
29
+ nodes: new Map([...loaded.nodes].filter(([, node]) => node.kind !== "Guarantee" || node.status === "active")),
30
+ };
31
+ }
32
+
33
+ function assembleModelView(graph, modelId) {
34
+ const model = resolveNode(graph, modelId);
35
+ const domains = asArray(model.domains)
36
+ .map((domainId) => {
37
+ const domain = resolveNode(graph, domainId);
38
+ return {
39
+ ...domain,
40
+ concepts: resolveExistingNodes(graph, domain.concepts),
41
+ interfaces: resolveExistingNodes(graph, domain.interfaces),
42
+ guarantees: resolveExistingNodes(graph, domain.guarantees),
43
+ };
44
+ })
45
+ .sort(byId);
46
+
47
+ const relationships = asArray(model.relationships)
48
+ .filter((nodeId) => graph.nodes.has(nodeId))
49
+ .map((relationshipId) => resolveNode(graph, relationshipId))
50
+ .sort(byId);
51
+
52
+ const decisions = asArray(model.decisions).toSorted();
53
+ const useCases = resolveExistingNodes(graph, model.useCases);
54
+ const interfaces = [...graph.nodes.values()].filter((node) => node.kind === "DomainInterface").sort(byId);
55
+
56
+ return { model, domains, relationships, decisions, useCases, interfaces };
57
+ }
58
+
59
+ function renderModelOverview(view) {
60
+ const lines = [
61
+ "<!-- GENERATED FILE: do not edit by hand. Regenerate by running ddduck generate --root ../.. from this file's directory. -->",
62
+ "",
63
+ `# ${view.model.name} (\`${view.model.id}\`)`,
64
+ "",
65
+ `Name status: \`${view.model.nameStatus ?? "stable"}\``,
66
+ "",
67
+ view.model.purpose,
68
+ "",
69
+ "## Domains",
70
+ "",
71
+ ];
72
+
73
+ for (const domain of view.domains) {
74
+ lines.push(`### ${domain.name} (\`${domain.id}\`)`, "", domain.purpose, "");
75
+ renderNodeList(lines, "Concepts", domain.concepts);
76
+ renderNodeList(lines, "Interfaces", domain.interfaces);
77
+ renderNodeList(lines, "Active guarantees", domain.guarantees);
78
+ }
79
+
80
+ lines.push("## Use Cases", "");
81
+ for (const useCase of view.useCases) {
82
+ lines.push(`### ${useCase.name} (\`${useCase.id}\`)`, "", useCase.goal, "");
83
+ renderGuaranteeReferences(lines, "Requires", useCase.preconditions?.requires);
84
+ renderGuaranteeReferences(lines, "Preserves", useCase.success?.preserves);
85
+ renderGuaranteeReferences(lines, "Establishes", useCase.success?.establishes);
86
+ renderNodeReferences(lines, "Interfaces", useCase.interfaces);
87
+ lines.push("");
88
+ }
89
+
90
+ lines.push("## Interfaces", "");
91
+ for (const domainInterface of view.interfaces) {
92
+ lines.push(
93
+ `### ${domainInterface.name} (\`${domainInterface.id}\`)`,
94
+ "",
95
+ `Operation kind: \`${domainInterface.operationKind}\``,
96
+ "",
97
+ );
98
+ renderGuaranteeReferences(lines, "Guarantees", domainInterface.guarantees);
99
+ lines.push("");
100
+ }
101
+
102
+ lines.push("## Relationships", "");
103
+ for (const relationship of view.relationships) {
104
+ lines.push(
105
+ `- \`${relationship.id}\`: \`${relationship.from}\` -> \`${relationship.to}\` (` +
106
+ `\`${relationship.relationshipType}\`) - ${relationship.description ?? "Unspecified"}`,
107
+ );
108
+ }
109
+
110
+ if (view.relationships.length > 0) lines.push("");
111
+ lines.push("## Decisions", "");
112
+ for (const decision of view.decisions) lines.push(`- \`${decision}\``);
113
+ lines.push("");
114
+ return lines.join("\n");
115
+ }
116
+
117
+ function renderNodeList(lines, title, nodes) {
118
+ lines.push(`#### ${title}`, "");
119
+ if (nodes.length === 0) lines.push("- None.");
120
+ for (const node of nodes) lines.push(`- \`${node.id}\` - ${nodeLabel(node)}`);
121
+ lines.push("");
122
+ }
123
+
124
+ function renderGuaranteeReferences(lines, title, ids) {
125
+ const references = asArray(ids);
126
+ lines.push(`- ${title}: ${references.length ? references.map((id) => `\`${id}\``).join(", ") : "none"}`);
127
+ }
128
+
129
+ function renderNodeReferences(lines, title, ids) {
130
+ const references = asArray(ids);
131
+ lines.push(`- ${title}: ${references.length ? references.map((id) => `\`${id}\``).join(", ") : "none"}`);
132
+ }
133
+
134
+ function resolveNode(graph, id) {
135
+ const node = graph.nodes.get(id);
136
+ if (!node) {
137
+ throw new Error(`missing model node ${id}`);
138
+ }
139
+ return node;
140
+ }
141
+
142
+ function nodeLabel(node) {
143
+ return node.name ?? node.statement ?? node.goal ?? node.description ?? "Unspecified";
144
+ }
145
+
146
+ function resolveExistingNodes(graph, ids) {
147
+ return asArray(ids)
148
+ .filter((id) => graph.nodes.has(id))
149
+ .map((id) => resolveNode(graph, id))
150
+ .sort(byId);
151
+ }
152
+
153
+ function findModelId(graph) {
154
+ const models = [...graph.nodes.values()].filter((node) => node.kind === "Model");
155
+ if (models.length !== 1) throw new Error(`expected one Model node, found ${models.length}`);
156
+ return models[0].id;
157
+ }
158
+
159
+ function byId(left, right) {
160
+ return left.id.localeCompare(right.id);
161
+ }
162
+
163
+ function asArray(value) {
164
+ if (Array.isArray(value)) {
165
+ return value;
166
+ }
167
+ if (value === undefined || value === null) {
168
+ return [];
169
+ }
170
+ return [value];
171
+ }
172
+
173
+ function parseArgs(args) {
174
+ const parsed = {};
175
+
176
+ for (let index = 0; index < args.length; index += 1) {
177
+ const arg = args[index];
178
+ if (arg === "--root") {
179
+ parsed.root = args[index + 1];
180
+ index += 1;
181
+ continue;
182
+ }
183
+ if (arg === "--verbose") {
184
+ parsed.verbose = true;
185
+ continue;
186
+ }
187
+
188
+ throw new Error(`Unknown argument: ${arg}`);
189
+ }
190
+
191
+ return parsed;
192
+ }
193
+
194
+ function isMainModule() {
195
+ return process.argv[1] === fileURLToPath(import.meta.url);
196
+ }
197
+
198
+ if (isMainModule()) {
199
+ const options = parseArgs(process.argv.slice(2));
200
+ const root = resolveProductRoot({ explicitRoot: options.root });
201
+ const writtenPath = writeModelOverview(root);
202
+ if (options.verbose) {
203
+ console.log(`wrote ${writtenPath}`);
204
+ }
205
+ }
@@ -0,0 +1,359 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Experimental: render generated/graph/model-graph.json to an SVG using the
4
+ // Graphviz WASM engine (@hpcc-js/wasm). Pure JS + WASM, no system binary, so it
5
+ // stays offline and deterministic. A hosted render API (e.g. Kroki) is a
6
+ // possible future fallback but is intentionally not the default.
7
+
8
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { resolveContainedOutput } from "./lib/product-paths.mjs";
12
+ import { resolveProductRoot } from "./lib/product-root-resolver.mjs";
13
+
14
+ export const jsonInputPath = path.join("generated", "graph", "model-graph.json");
15
+
16
+ // Graphviz layout engines worth comparing for a model graph. `dot` is the
17
+ // hierarchical default; the force-directed and radial engines often read better
18
+ // once the ownership tree gets wide. Output goes to `model-graph.<engine>.svg`.
19
+ export const LAYOUT_ENGINES = ["dot", "twopi", "circo", "fdp", "sfdp", "neato"];
20
+
21
+ // Only `dot` and `fdp` do cluster-aware layout. The other engines still *draw*
22
+ // cluster boxes but place nodes without respecting the groups, so the boxes
23
+ // overlap — for those we emit a flat (unclustered) graph instead.
24
+ const CLUSTER_ENGINES = new Set(["dot", "fdp"]);
25
+
26
+ export function engineSupportsClusters(engine) {
27
+ return CLUSTER_ENGINES.has(engine);
28
+ }
29
+
30
+ // The canonical diagram: dot layout, clustered, with a legend. This is what the
31
+ // pipeline generates by default and pins in the freshness check.
32
+ export const svgOutputPath = path.join("generated", "graph", "model-graph.svg");
33
+ export const canonicalEngine = "dot";
34
+
35
+ export function svgOutputPathFor(engine) {
36
+ return path.join("generated", "graph", `model-graph.${engine}.svg`);
37
+ }
38
+
39
+ // Visual vocabulary keyed by the graph's node/edge `kind`. Shapes and fills are
40
+ // chosen so the ownership spine (Model → Domain → members) reads at a glance and
41
+ // each node kind is distinguishable without a legend.
42
+ const NODE_STYLE = {
43
+ Model: { shape: "doublecircle", fillcolor: "#1f2933", fontcolor: "white" },
44
+ Domain: { shape: "box", style: "rounded,filled", fillcolor: "#b8c4d0" },
45
+ Concept: { shape: "ellipse", fillcolor: "#e4ebf2" },
46
+ DomainInterface: { shape: "component", fillcolor: "#d6e2c4" },
47
+ UseCase: { shape: "note", fillcolor: "#f2e6c4" },
48
+ Guarantee: { shape: "hexagon", fillcolor: "#f2cdc4" },
49
+ Relationship: { shape: "diamond", fillcolor: "#e0d4ec" },
50
+ };
51
+ const DEFAULT_NODE_STYLE = { shape: "ellipse", fillcolor: "#eeeeee" };
52
+
53
+ // Structural ownership is the backbone (solid); every reference edge is dashed
54
+ // so the eye separates "what contains what" from "what points at what".
55
+ const EDGE_STYLE = {
56
+ owns: { style: "solid", color: "#52606d" },
57
+ relationship: { style: "solid", color: "#1f2933" },
58
+ requires: { style: "dashed", color: "#7b8794" },
59
+ preserves: { style: "dashed", color: "#7b8794" },
60
+ establishes: { style: "dashed", color: "#7b8794" },
61
+ uses: { style: "dashed", color: "#7b8794" },
62
+ guarantees: { style: "dashed", color: "#7b8794" },
63
+ };
64
+ const DEFAULT_EDGE_STYLE = { style: "solid", color: "#7b8794" };
65
+
66
+ // Quote and escape a value for use as a DOT string literal.
67
+ function dotString(value) {
68
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
69
+ }
70
+
71
+ // Render an attribute map as `key=value` pairs (values are DOT-escaped).
72
+ function dotAttrs(attrs) {
73
+ return Object.entries(attrs)
74
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
75
+ .map(([key, value]) => `${key}=${dotString(value)}`)
76
+ .join(", ");
77
+ }
78
+
79
+ // The node kind is decoded by the legend (shape + fill per kind), so the label
80
+ // carries only the name — no per-node `(Kind)` sublabel to repeat what the
81
+ // legend already says.
82
+ function nodeLabel(node) {
83
+ return node.name ?? node.id;
84
+ }
85
+
86
+ function nodeStatement(node) {
87
+ const style = NODE_STYLE[node.kind] ?? DEFAULT_NODE_STYLE;
88
+ const attrs = {
89
+ label: nodeLabel(node),
90
+ shape: style.shape,
91
+ style: style.style ?? "filled",
92
+ fillcolor: style.fillcolor,
93
+ fontcolor: style.fontcolor,
94
+ tooltip: node.purpose ?? undefined,
95
+ };
96
+ return ` ${dotString(node.id)} [${dotAttrs(attrs)}];`;
97
+ }
98
+
99
+ // A DOT subgraph is only drawn as a bounding box when its name starts with
100
+ // `cluster`. Sanitize the domain id into a valid, collision-free cluster name.
101
+ function clusterId(domainId) {
102
+ return `cluster_${domainId.replace(/[^A-Za-z0-9]+/g, "_")}`;
103
+ }
104
+
105
+ // Group each Domain node and the nodes it owns (via `ownerDomain`) into its own
106
+ // cluster; everything else (Model, use cases, relationships) stays top level.
107
+ // Only `dot` (and partly `fdp`) draw the boxes — other engines ignore them
108
+ // harmlessly.
109
+ function partitionByDomain(nodes) {
110
+ const domainIds = new Set(nodes.filter((node) => node.kind === "Domain").map((node) => node.id));
111
+ const clusters = new Map();
112
+ const ungrouped = [];
113
+
114
+ const clusterFor = (domainId) => {
115
+ if (!clusters.has(domainId)) clusters.set(domainId, { domain: null, members: [] });
116
+ return clusters.get(domainId);
117
+ };
118
+
119
+ for (const node of nodes) {
120
+ if (node.kind === "Domain") {
121
+ clusterFor(node.id).domain = node;
122
+ } else if (node.ownerDomain && domainIds.has(node.ownerDomain)) {
123
+ clusterFor(node.ownerDomain).members.push(node);
124
+ } else {
125
+ ungrouped.push(node);
126
+ }
127
+ }
128
+ return { clusters, ungrouped };
129
+ }
130
+
131
+ // A self-contained legend cluster: one swatch per node kind (drawn with its real
132
+ // shape and fill so the mapping is exact) plus a one-line note decoding the edge
133
+ // styles. Built from NODE_STYLE so it can never drift from the real vocabulary.
134
+ // Only meaningful under a ranked (dot/fdp) layout, so it rides with `clusters`.
135
+ function legendStatements() {
136
+ const swatches = Object.keys(NODE_STYLE).map((kind) => {
137
+ const style = NODE_STYLE[kind];
138
+ const attrs = {
139
+ label: kind,
140
+ shape: style.shape,
141
+ style: style.style ?? "filled",
142
+ fillcolor: style.fillcolor,
143
+ fontcolor: style.fontcolor,
144
+ };
145
+ return ` ${dotString(`legend:${kind}`)} [${dotAttrs(attrs)}];`;
146
+ });
147
+ const swatchIds = Object.keys(NODE_STYLE).map((kind) => dotString(`legend:${kind}`));
148
+ const swatchChain = swatchIds.join(" -> ");
149
+
150
+ return [
151
+ " subgraph cluster_legend {",
152
+ ' label="Legend — solid: owns · dark: relationship · dashed: reference"; labeljust="l";',
153
+ ' fontname="Helvetica"; fontsize=12; style="rounded,filled"; color="#cbd2d9"; fillcolor="#ffffff";',
154
+ " node [fontsize=10];",
155
+ ...swatches,
156
+ // Keep the swatches on a single rank (a horizontal strip): a portrait
157
+ // `ratio` stretch inflates ranksep, which would otherwise fling a vertically
158
+ // chained legend far down the canvas. The invisible, non-constraining chain
159
+ // only fixes their left-to-right order.
160
+ ` { rank=same; ${swatchIds.join("; ")}; }`,
161
+ ` ${swatchChain} [style=invis, constraint=false];`,
162
+ " }",
163
+ ];
164
+ }
165
+
166
+ function nodeSection(nodes) {
167
+ const { clusters, ungrouped } = partitionByDomain(nodes);
168
+ const lines = ungrouped.map(nodeStatement);
169
+
170
+ for (const domainId of [...clusters.keys()].sort()) {
171
+ const { domain, members } = clusters.get(domainId);
172
+ lines.push(
173
+ ` subgraph ${clusterId(domainId)} {`,
174
+ ` label=${dotString(domain?.name ?? domainId)}; labeljust="l";`,
175
+ ` style="rounded,filled"; color="#b8c4d0"; fillcolor="#f5f7fa"; fontname="Helvetica";`,
176
+ ...(domain ? [` ${nodeStatement(domain)}`] : []),
177
+ ...members.map((member) => ` ${nodeStatement(member)}`),
178
+ " }",
179
+ );
180
+ }
181
+ return lines;
182
+ }
183
+
184
+ function edge(from, to, attrs) {
185
+ return ` ${dotString(from)} -> ${dotString(to)} [${dotAttrs(attrs)}];`;
186
+ }
187
+
188
+ // A `relationship` edge carries a standalone Relationship node in `edge.source`.
189
+ // If we drew it as a direct from->to line the node would have no edges of its
190
+ // own and force-directed layouts would fling it to the margins. Routing the edge
191
+ // *through* the node (from -> rel -> to) anchors it between its endpoints while
192
+ // keeping it visible and faithful to the model.
193
+ function routesThroughSourceNode(edge, nodeIds) {
194
+ return (
195
+ edge.kind === "relationship" && nodeIds.has(edge.source) && edge.source !== edge.from && edge.source !== edge.to
196
+ );
197
+ }
198
+
199
+ function edgeStatements(graphEdge, nodeIds) {
200
+ const style = EDGE_STYLE[graphEdge.kind] ?? DEFAULT_EDGE_STYLE;
201
+ const base = { style: style.style, color: style.color, fontcolor: style.color };
202
+
203
+ if (routesThroughSourceNode(graphEdge, nodeIds)) {
204
+ return [
205
+ edge(graphEdge.from, graphEdge.source, {
206
+ ...base,
207
+ label: graphEdge.label ?? graphEdge.kind,
208
+ arrowhead: "none",
209
+ }),
210
+ edge(graphEdge.source, graphEdge.to, base),
211
+ ];
212
+ }
213
+
214
+ return [edge(graphEdge.from, graphEdge.to, { ...base, label: graphEdge.label ?? graphEdge.kind })];
215
+ }
216
+
217
+ // Pure translation from the model graph JSON to a deterministic DOT string.
218
+ // `clusters` groups each domain and its owned nodes into a titled box; disable it
219
+ // for engines that draw but do not lay out clusters (they would overlap).
220
+ export function graphToDot(modelGraph, { clusters = true, legend = clusters } = {}) {
221
+ const nodes = modelGraph.nodes ?? [];
222
+ const edges = modelGraph.edges ?? [];
223
+ const nodeIds = new Set(nodes.map((node) => node.id));
224
+ const title = modelGraph.modelName ?? modelGraph.modelId ?? "model";
225
+
226
+ return [
227
+ `digraph ${dotString(title)} {`,
228
+ " rankdir=TB;",
229
+ // overlap/sep are honored by the force-directed and radial engines (ignored
230
+ // by dot) so those variants stop piling nodes on top of each other.
231
+ // ratio="1.3" targets a portrait aspect (height ≈ 1.3× width) so the diagram
232
+ // reads well embedded in a vertically-scrolled document; dot reaches it by
233
+ // spacing ranks, honored here and ignored by the non-ranked engines.
234
+ ' graph [fontname="Helvetica", labelloc="t", fontsize=18, ' +
235
+ `label=${dotString(title)}, overlap="false", sep="+16", splines="true", ratio="1.3"];`,
236
+ ' node [fontname="Helvetica", fontsize=11];',
237
+ ' edge [fontname="Helvetica", fontsize=11];',
238
+ ...(clusters ? nodeSection(nodes) : nodes.map(nodeStatement)),
239
+ ...(legend ? legendStatements() : []),
240
+ ...edges.flatMap((graphEdge) => edgeStatements(graphEdge, nodeIds)),
241
+ "}",
242
+ "",
243
+ ].join("\n");
244
+ }
245
+
246
+ // Load the Graphviz WASM instance once and share it across renders. Loading is
247
+ // the only async step; `.dot()` is synchronous, so a single cached instance
248
+ // serves every call without re-initializing the WASM module.
249
+ let graphvizInstance;
250
+ function loadGraphviz() {
251
+ if (!graphvizInstance) {
252
+ graphvizInstance = import("@hpcc-js/wasm/graphviz").then(({ Graphviz }) => Graphviz.load());
253
+ }
254
+ return graphvizInstance;
255
+ }
256
+
257
+ // Render a DOT string to SVG via the Graphviz WASM engine. `engine` selects the
258
+ // layout algorithm. Isolated so the backend can be swapped without touching the
259
+ // translation above.
260
+ export async function renderDotToSvg(dot, engine = "dot") {
261
+ if (!LAYOUT_ENGINES.includes(engine)) {
262
+ throw new Error(`unknown layout engine ${engine}; expected one of ${LAYOUT_ENGINES.join(", ")}`);
263
+ }
264
+ const graphviz = await loadGraphviz();
265
+ return graphviz[engine](dot);
266
+ }
267
+
268
+ function readModelGraph(rootPath) {
269
+ const inputPath = resolveContainedOutput(rootPath, jsonInputPath);
270
+ try {
271
+ return JSON.parse(readFileSync(inputPath, "utf8"));
272
+ } catch (cause) {
273
+ throw new Error(`cannot read ${jsonInputPath}; run \`generate-graph\` first (${cause.message})`, { cause });
274
+ }
275
+ }
276
+
277
+ function writeSvg(rootPath, relativePath, svg) {
278
+ const absoluteOutputPath = resolveContainedOutput(rootPath, relativePath);
279
+ mkdirSync(path.dirname(absoluteOutputPath), { recursive: true });
280
+ writeFileSync(absoluteOutputPath, svg);
281
+ return relativePath;
282
+ }
283
+
284
+ // Build the canonical diagram bytes (dot, clustered, legend). Shared by the
285
+ // writer and the freshness check so both agree byte-for-byte.
286
+ export async function buildModelGraphSvg(modelGraph) {
287
+ const dot = graphToDot(modelGraph, { clusters: true, legend: true });
288
+ return renderDotToSvg(dot, canonicalEngine);
289
+ }
290
+
291
+ export async function writeModelGraphSvgCanonical(rootPath) {
292
+ const svg = await buildModelGraphSvg(readModelGraph(rootPath));
293
+ return writeSvg(rootPath, svgOutputPath, svg);
294
+ }
295
+
296
+ export async function writeModelGraphSvg(rootPath, engine = "dot") {
297
+ const dot = graphToDot(readModelGraph(rootPath), { clusters: engineSupportsClusters(engine) });
298
+ return writeSvg(rootPath, svgOutputPathFor(engine), await renderDotToSvg(dot, engine));
299
+ }
300
+
301
+ // Render one SVG per layout engine so the variants can be compared side by side.
302
+ // Clusters are emitted only for the engines that lay them out (dot, fdp).
303
+ export async function writeModelGraphSvgVariants(rootPath, engines = LAYOUT_ENGINES) {
304
+ const modelGraph = readModelGraph(rootPath);
305
+ const writtenPaths = [];
306
+ for (const engine of engines) {
307
+ const dot = graphToDot(modelGraph, { clusters: engineSupportsClusters(engine) });
308
+ writtenPaths.push(writeSvg(rootPath, svgOutputPathFor(engine), await renderDotToSvg(dot, engine)));
309
+ }
310
+ return writtenPaths;
311
+ }
312
+
313
+ function parseArgs(args) {
314
+ const parsed = {};
315
+ for (let index = 0; index < args.length; index += 1) {
316
+ const arg = args[index];
317
+ if (arg === "--root") {
318
+ parsed.root = args[index + 1];
319
+ index += 1;
320
+ continue;
321
+ }
322
+ if (arg === "--layout") {
323
+ parsed.layout = args[index + 1];
324
+ index += 1;
325
+ continue;
326
+ }
327
+ if (arg === "--all-layouts") {
328
+ parsed.allLayouts = true;
329
+ continue;
330
+ }
331
+ if (arg === "--verbose") {
332
+ parsed.verbose = true;
333
+ continue;
334
+ }
335
+ throw new Error(`Unknown argument: ${arg}`);
336
+ }
337
+ return parsed;
338
+ }
339
+
340
+ function isMainModule() {
341
+ return process.argv[1] === fileURLToPath(import.meta.url);
342
+ }
343
+
344
+ if (isMainModule()) {
345
+ const options = parseArgs(process.argv.slice(2));
346
+ const root = resolveProductRoot({ explicitRoot: options.root });
347
+ // Default: just the canonical model-graph.svg. --layout <engine> writes one
348
+ // per-engine variant; --all-layouts writes every variant for comparison.
349
+ const writtenPaths = options.allLayouts
350
+ ? await writeModelGraphSvgVariants(root)
351
+ : options.layout
352
+ ? [await writeModelGraphSvg(root, options.layout)]
353
+ : [await writeModelGraphSvgCanonical(root)];
354
+ if (options.verbose) {
355
+ for (const writtenPath of writtenPaths) {
356
+ console.log(`wrote ${writtenPath}`);
357
+ }
358
+ }
359
+ }