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.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/docs/architecture.md +77 -0
- package/docs/cli.md +239 -0
- package/docs/getting-started.md +98 -0
- package/docs/model-reference.md +92 -0
- package/docs/model.md +75 -0
- package/package.json +76 -0
- package/policies/concept-owner-domain.yaml +8 -0
- package/policies/documentation-model-reference-resolution.yaml +8 -0
- package/policies/no-dangling-model-reference.yaml +8 -0
- package/policies/policy-spec.schema.json +23 -0
- package/schemas/context-pack.schema.json +83 -0
- package/schemas/fr-to-code-audit.schema.json +85 -0
- package/schemas/product/concept.schema.json +17 -0
- package/schemas/product/domain-interface.schema.json +18 -0
- package/schemas/product/domain.schema.json +24 -0
- package/schemas/product/evidence-anchor.schema.json +30 -0
- package/schemas/product/guarantee.schema.json +35 -0
- package/schemas/product/model.schema.json +20 -0
- package/schemas/product/relationship.schema.json +21 -0
- package/schemas/product/use-case.schema.json +27 -0
- package/scripts/audit-fr-to-code.mjs +162 -0
- package/scripts/check-generated-docs.mjs +58 -0
- package/scripts/check-generated-graph-svg.mjs +60 -0
- package/scripts/check-generated-graph.mjs +66 -0
- package/scripts/check-model.mjs +488 -0
- package/scripts/ddduck.mjs +542 -0
- package/scripts/generate-agent-readiness-report.mjs +23 -0
- package/scripts/generate-docs.mjs +205 -0
- package/scripts/generate-graph-svg.mjs +359 -0
- package/scripts/generate-graph.mjs +268 -0
- package/scripts/lib/agent-readiness-evals.mjs +433 -0
- package/scripts/lib/agent-readiness-report.mjs +79 -0
- package/scripts/lib/cli-contract.mjs +162 -0
- package/scripts/lib/context-pack.mjs +107 -0
- package/scripts/lib/ddduck-config.mjs +57 -0
- package/scripts/lib/fr-to-code-audit.mjs +144 -0
- package/scripts/lib/product-layout.mjs +93 -0
- package/scripts/lib/product-operation.mjs +431 -0
- package/scripts/lib/product-paths.mjs +43 -0
- package/scripts/lib/product-query.mjs +284 -0
- package/scripts/lib/product-root-resolver.mjs +167 -0
- package/scripts/lib/scan-ignore.mjs +8 -0
- package/scripts/lib/skill-installer.mjs +410 -0
- package/scripts/query-model.mjs +64 -0
- package/scripts/run-agent-readiness-evals.mjs +57 -0
- 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
|
+
}
|