[verified] feat: add semantic diagram lifecycle

This commit is contained in:
2026-09-03 19:11:50 +00:00
parent 5146b14d43
commit db4129168b
37 changed files with 1626 additions and 58 deletions
+40
View File
@@ -38,6 +38,46 @@ tasks:
CLI_ARGS: "--action=build {{ .CLI_ARGS }}"
silent: true
import:
desc: Loss-aware Draw.io import to Diagram IR v2; requires --file and --output.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=import {{ .CLI_ARGS }}"
silent: true
edit:
desc: Apply an atomic stable-ID edit batch; requires --file and --spec.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=edit {{ .CLI_ARGS }}"
silent: true
views:
desc: Project linked audience views to a multi-page Draw.io file.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=views {{ .CLI_ARGS }}"
silent: true
sync:
desc: Three-way stable-ID synchronization; requires --base, --file, and --spec.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=sync {{ .CLI_ARGS }}"
silent: true
story:
desc: Publish a self-contained accessible offline architecture story.
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=story {{ .CLI_ARGS }}"
silent: true
validate:
desc: |
Validate a .drawio file (XML well-formedness + required structure).
+1 -1
View File
@@ -14,7 +14,7 @@
},
"dependencies": {
"@maxgraph/core": "^0.23.0",
"js-yaml": "^4.1.0",
"js-yaml": "^4.3.1",
"jsdom": "^25.0.0",
"pako": "^2.1.0"
},
+5 -5
View File
@@ -12,8 +12,8 @@ importers:
specifier: ^0.23.0
version: 0.23.0
js-yaml:
specifier: ^4.1.0
version: 4.3.0
specifier: ^4.3.1
version: 4.3.1
jsdom:
specifier: ^25.0.0
version: 25.0.1
@@ -373,8 +373,8 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
jsdom@25.0.1:
@@ -779,7 +779,7 @@ snapshots:
is-potential-custom-element-name@1.0.1: {}
js-yaml@4.3.0:
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
+3 -4
View File
@@ -1,9 +1,9 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { readFileSync } from "node:fs";
import * as yaml from "js-yaml";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { atomicWrite } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _pageIndex = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("build requires --output <path.drawio>");
@@ -11,8 +11,7 @@ export function run(filePath: string, _pageIndex = 0, outputPath?: string): Reco
const ir = validateDiagramIR(parsed);
const normalized = normalizeDiagramIR(ir);
const xml = diagramIRToDrawio(normalized);
const resolvedOutput = resolve(outputPath);
writeFileSync(resolvedOutput, xml, "utf8");
const resolvedOutput = atomicWrite(outputPath, xml, [filePath]);
return {
action: "build",
file: filePath,
+2
View File
@@ -0,0 +1,2 @@
import { doctorReport } from "../../services/semantic-lifecycle/publishing.js";
export function run(_filePath = "", _page = 0, _output?: string): Record<string, unknown> { return { action: "doctor", ...doctorReport() }; }
+17
View File
@@ -0,0 +1,17 @@
import type { EditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { applyEditBatch } from "../../services/semantic-lifecycle/edit-batch.js";
import { assertOutputSafe, atomicWrite, loadIR, loadStructured, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!options.spec) throw new Error("edit requires --spec <batch.yaml>");
if (!options.dryRun && !outputPath) throw new Error("edit requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.spec]);
const source = loadIR(filePath); const batch = loadStructured(options.spec) as EditBatch;
const result = applyEditBatch(source, batch, { dryRun: options.dryRun });
let output: string | undefined;
if (!options.dryRun && outputPath) {
const content = /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath);
output = atomicWrite(outputPath, content, [filePath, options.spec]);
}
return { action: "edit", output, applied: result.applied, dryRun: result.dryRun, preview: options.dryRun ? result.preview : undefined };
}
+9
View File
@@ -0,0 +1,9 @@
import { importDrawioToIR } from "../../services/semantic-lifecycle/import-drawio.js";
import { atomicWrite, structuredText } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, outputPath?: string): Record<string, unknown> {
if (!outputPath) throw new Error("import requires --output <model.yaml|model.json>");
if (!/\.(?:ya?ml|json)$/i.test(outputPath)) throw new Error("import output must use a .yaml, .yml, or .json extension");
const result = importDrawioToIR(filePath);
const output = atomicWrite(outputPath, structuredText(result.ir, outputPath), [filePath]);
return { action: "import", file: filePath, output, lossReport: result.lossReport };
}
+6
View File
@@ -0,0 +1,6 @@
import { queryDiagram } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
const properties = Object.fromEntries((options.property ?? []).map((entry) => { const at = entry.indexOf("="); if (at < 1) throw new Error(`--property requires key=value: ${entry}`); const raw = entry.slice(at + 1); let value: unknown = raw; try { value = JSON.parse(raw); } catch {} return [entry.slice(0, at), value]; }));
return { action: "query", ...queryDiagram(loadIR(filePath), { kind: options.kind, properties, from: options.from, to: options.to }) };
}
+4
View File
@@ -0,0 +1,4 @@
import { createStoryHtml } from "../../services/semantic-lifecycle/publishing.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!outputPath) throw new Error("story requires --output <story.html>"); const ir = loadIR(filePath); const scenario = options.fail ? simulateFailure(ir, options.fail) : undefined; const output = atomicWrite(outputPath, createStoryHtml(ir, { scenario }), [filePath]); return { action: "story", output, accessible: true, offline: true, scenario: scenario ? { failed: scenario.failed, impacted: scenario.impacted } : undefined }; }
+10
View File
@@ -0,0 +1,10 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { assertOutputSafe, atomicWrite, loadIR, structuredText, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
import { syncDiagramIR } from "../../services/semantic-lifecycle/sync.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!options.base || !options.spec) throw new Error("sync requires --base <base> and --spec <incoming>"); if (!options.dryRun && !outputPath) throw new Error("sync requires --output unless --dry-run is used");
if (outputPath) assertOutputSafe(outputPath, [filePath, options.base, options.spec]);
const result = syncDiagramIR(loadIR(options.base), loadIR(filePath), loadIR(options.spec), { prune: options.prune });
let output: string | undefined; if (!options.dryRun && outputPath) output = atomicWrite(outputPath, /\.(drawio|xml)$/i.test(outputPath) ? diagramIRToDrawio(result.ir) : structuredText(result.ir, outputPath), [filePath, options.base, options.spec]);
return { action: "sync", output, dryRun: options.dryRun === true, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined };
}
+8
View File
@@ -0,0 +1,8 @@
import { POLICY_IDS, runPolicies } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, loadStructured, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
let rules: readonly string[] = POLICY_IDS; if (options.spec) { const raw = loadStructured(options.spec); rules = Array.isArray(raw) ? raw.map(String) : ((raw as { rules?: unknown[] }).rules ?? []).map((item) => typeof item === "string" ? item : String((item as { id?: unknown }).id)); }
const unknown = rules.filter((rule) => !(POLICY_IDS as readonly string[]).includes(rule));
if (unknown.length) throw new Error(`Unknown policy identifier: ${unknown.join(", ")}`);
const report = runPolicies(loadIR(filePath), rules); return { action: "test", ...report, failed: report.errors > 0 || (options.strict === true && report.warnings > 0) };
}
+16
View File
@@ -0,0 +1,16 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { projectLinkedViews, type ViewName } from "../../services/semantic-lifecycle/analysis.js";
import { atomicWrite, loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, outputPath?: string, options: LifecycleActionOptions = {}): Record<string, unknown> {
if (!outputPath) throw new Error("views requires --output <views.drawio>");
const source = loadIR(filePath);
const names = options.views?.split(",").filter(Boolean) as ViewName[] | undefined;
const allowed = new Set<ViewName>(["executive", "system", "deployment", "dataflow", "security"]);
const unknown = names?.filter((name) => !allowed.has(name)) ?? [];
if (unknown.length) throw new Error(`Unknown linked view: ${unknown.join(", ")}`);
const views = projectLinkedViews(source, names);
const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes, edges: view.edges, layout: { type: "manual" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) };
const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]);
return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) };
}
+3
View File
@@ -0,0 +1,3 @@
import { simulateFailure } from "../../services/semantic-lifecycle/analysis.js";
import { loadIR, type LifecycleActionOptions } from "../../services/semantic-lifecycle/lifecycle-io.js";
export function run(filePath: string, _page = 0, _output?: string, options: LifecycleActionOptions = {}): Record<string, unknown> { if (!options.fail) throw new Error("what-if requires --fail <node-id>"); return { action: "what-if", ...simulateFailure(loadIR(filePath), options.fail) }; }
+71 -32
View File
@@ -7,13 +7,22 @@ const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;s
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
type Preservation = {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
};
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function stableJson(value: unknown): string {
@@ -26,38 +35,65 @@ function stableJson(value: unknown): string {
return JSON.stringify(value);
}
function semanticExtensions(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!value) return undefined;
const result = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$drawio"));
return Object.keys(result).length ? result : undefined;
}
function preservation(value: Record<string, unknown> | undefined): Preservation {
const raw = value?.$drawio;
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw as Preservation : {};
}
function dataAttributes(values: Record<string, unknown>): string {
return Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
.join("");
return Object.entries(values).filter(([, value]) => value !== undefined).map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`).join("");
}
function rawAttributes(values: Record<string, string> | undefined, prefix?: string): string {
return Object.entries(values ?? {}).filter(([key]) => !prefix || key.startsWith(prefix)).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(prefix ? key.slice(prefix.length) : key)}="${escapeXml(value)}"`).join("");
}
function unprefixedAttributes(values: Record<string, string> | undefined): string {
return Object.entries(values ?? {}).filter(([key]) => !key.includes(":")).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => ` ${escapeXml(key)}="${escapeXml(value)}"`).join("");
}
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
return {
x: horizontal ? 40 + index * 200 : 40,
y: horizontal ? 80 : 80 + index * 120,
width: node.width ?? 120,
height: node.height ?? 60,
};
return { x: horizontal ? 40 + index * 200 : 40, y: horizontal ? 80 : 80 + index * 120, width: node.width ?? 120, height: node.height ?? 60 };
}
function wrapHolder(cell: string, id: string, label: string, ext: Record<string, unknown> | undefined, p: Preservation): string {
if (!p.holderTag) return cell;
const metadata = dataAttributes({ kind: undefined, properties: undefined, provenance: undefined, extensions: semanticExtensions(ext) });
return `<${p.holderTag} id="${escapeXml(id)}" label="${escapeXml(label)}"${rawAttributes(p.holderAttributes)}${metadata}>${cell}</${p.holderTag}>`;
}
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
const geometry = geometryForNode(node, index, page);
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
const parent = node.parentId ?? "1";
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
const p = preservation(node.extensions);
const renderedLabel = p.attributes?.["semantic:label"] ?? node.label;
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: semanticExtensions(node.extensions) });
const geometryChildren = (p.geometryChildXml ?? []).join("");
const relative = p.geometryAttributes?.relative === undefined ? "" : ` relative="${escapeXml(p.geometryAttributes.relative)}"`;
const geometryXml = `<mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}"${relative} as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}${geometryChildren ? `>${geometryChildren}</mxGeometry>` : "/>"}`;
const styleAttribute = p.styleAbsent === true ? "" : ` style="${escapeXml(style)}"`;
const cell = `<mxCell id="${escapeXml(node.id)}" value="${escapeXml(renderedLabel)}"${styleAttribute} vertex="1" parent="${escapeXml(parent)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometryXml}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, node.id, renderedLabel, node.extensions, p)}`;
}
function serializeEdge(edge: DiagramEdge): string {
const points = edge.waypoints?.length
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
: "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
const p = preservation(edge.extensions);
const points = edge.waypoints?.length ? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>` : "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: semanticExtensions(edge.extensions) });
const geometryChildren = `${points}${(p.geometryChildXml ?? []).join("")}`;
const relative = p.geometryAttributes?.relative === undefined ? "1" : p.geometryAttributes.relative;
const geometry = `<mxGeometry relative="${escapeXml(relative)}" as="${escapeXml(p.geometryAttributes?.as ?? "geometry")}"${rawAttributes(p.attributes, "geometry:")}>${geometryChildren}</mxGeometry>`;
const cell = `<mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}${unprefixedAttributes(p.attributes)}>${geometry}${(p.childXml ?? []).join("")}</mxCell>`;
return ` ${wrapHolder(cell, edge.id, edge.label ?? "", edge.extensions, p)}`;
}
function contentExtent(page: DiagramPage): { width: number; height: number } {
@@ -75,14 +111,11 @@ function contentExtent(page: DiagramPage): { width: number; height: number } {
};
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
return {
width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)),
height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)),
};
return { width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)), height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)) };
}
function serializePage(input: DiagramPage): string {
const page = routePageEdges(layoutPage(input));
const page = input.layout?.type === "manual" ? input : routePageEdges(layoutPage(input));
const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
const edgeCells = page.edges.map(serializeEdge);
const extent = contentExtent(page);
@@ -90,12 +123,18 @@ function serializePage(input: DiagramPage): string {
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
const p = preservation(page.extensions);
const metadata = dataAttributes({ properties: page.properties, extensions: semanticExtensions(page.extensions) });
const diagramChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
const modelChildren = (p.modelChildXml ?? []).map((xml) => ` ${xml}`);
const modelAttributes = p.modelAttributes ?? {};
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}${unprefixedAttributes(p.attributes)}>\n${diagramChildren.length ? `${diagramChildren.join("\n")}\n` : ""} <mxGraphModel grid="${escapeXml(modelAttributes.grid ?? "1")}" gridSize="${escapeXml(modelAttributes.gridSize ?? String(grid))}" page="${escapeXml(modelAttributes.page ?? "1")}" pageWidth="${escapeXml(modelAttributes.pageWidth ?? String(pageWidth))}" pageHeight="${escapeXml(modelAttributes.pageHeight ?? String(pageHeight))}"${rawAttributes(p.attributes, "model:")}>\n${modelChildren.length ? `${modelChildren.join("\n")}\n` : ""} <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells, ...(p.unknownCells ?? []).map((xml) => ` ${xml}`)].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
const normalized = normalizeDiagramIR(ir);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
const p = preservation(normalized.extensions);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: semanticExtensions(normalized.extensions) });
const rootChildren = (p.childXml ?? []).map((xml) => ` ${xml}`);
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="${escapeXml(p.host ?? "drawio-tools")}"${metadata}${unprefixedAttributes(p.attributes)}>\n${rootChildren.length ? `${rootChildren.join("\n")}\n` : ""}${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
}
+9
View File
@@ -34,6 +34,15 @@ test("CLI rejects partially numeric page indexes", () => {
assert.match(result.stderr, /non-negative integer/);
});
test("CLI help documents lifecycle options and file-free doctor", () => {
const result = spawnSync(process.execPath, ["--import", "tsx", CLI, "--help"], { cwd: resolve("."), encoding: "utf8" });
assert.equal(result.status, 0, result.stdout + result.stderr);
for (const option of ["--spec", "--base", "--strict", "--prune", "--dry-run", "--fail", "--views", "--property", "--from", "--to"]) {
assert.match(result.stdout, new RegExp(option));
}
assert.match(result.stdout, /doctor.*does not require --file/i);
});
test("CLI validate exits non-zero when the validation summary is invalid", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-cli-validate-test-"));
const input = join(dir, "empty.drawio");
+45 -5
View File
@@ -22,13 +22,14 @@
import { parseArgs } from "node:util";
import * as yaml from "js-yaml";
import type { LifecycleActionOptions } from "../services/semantic-lifecycle/lifecycle-io.js";
// ---------------------------------------------------------------------------
// Action registry
// ---------------------------------------------------------------------------
type ActionModule = {
run: (filePath: string, pageIndex?: number, outputPath?: string) => Record<string, unknown>;
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
@@ -46,6 +47,15 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
"page-negative-space-summary":() => import("../actions/page-negative-space-summary/action.js"),
"validate": () => import("../actions/validate/action.js"),
"quality": () => import("../actions/quality/action.js"),
"import": () => import("../actions/import/action.js"),
"edit": () => import("../actions/edit/action.js"),
"views": () => import("../actions/views/action.js"),
"query": () => import("../actions/query/action.js"),
"test": () => import("../actions/test/action.js"),
"what-if": () => import("../actions/what-if/action.js"),
"sync": () => import("../actions/sync/action.js"),
"story": () => import("../actions/story/action.js"),
"doctor": () => import("../actions/doctor/action.js"),
};
// ---------------------------------------------------------------------------
@@ -53,7 +63,7 @@ const ACTIONS: Record<string, () => Promise<ActionModule>> = {
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean };
let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string };
try {
({ values } = parseArgs({
args: process.argv.slice(2),
@@ -62,6 +72,17 @@ async function main(): Promise<void> {
action: { type: "string", short: "a" },
page: { type: "string", short: "p" },
output: { type: "string", short: "o" },
spec: { type: "string" },
base: { type: "string" },
strict: { type: "boolean" },
prune: { type: "boolean" },
"dry-run": { type: "boolean" },
fail: { type: "string" },
views: { type: "string" },
kind: { type: "string" },
property: { type: "string", multiple: true },
from: { type: "string" },
to: { type: "string" },
help: { type: "boolean", short: "h" },
},
strict: true,
@@ -74,11 +95,25 @@ async function main(): Promise<void> {
if (values.help) {
console.log(`drawio-tools --file <path> --action <action> [--page <index>] [--output <path>]
Lifecycle options:
--spec <path> Edit batch, policy list, or incoming sync model
--base <path> Previous generated model for three-way sync
--strict Treat policy warnings as failures
--prune Remove identities absent from incoming sync model
--dry-run Return edit/sync preview without writing
--fail <node-id> Failed node for what-if or story overlay
--views <csv> executive,system,deployment,dataflow,security
--kind <kind> Query node kind
--property k=v Repeatable exact property query
--from <node-id> Directed path start (use with --to)
--to <node-id> Directed path destination (use with --from)
Doctor does not require --file.
Actions: ${Object.keys(ACTIONS).join(", ")}`);
process.exit(0);
}
if (!values.file) {
if (!values.file && values.action !== "doctor") {
printError("Missing required argument: --file");
process.exit(1);
}
@@ -88,7 +123,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
}
const actionName = values.action;
const filePath = values.file;
const filePath = values.file ?? "";
// Parse optional --page argument (0-based index, default 0)
let pageIndex = 0;
@@ -113,7 +148,11 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
try {
const mod = await ACTIONS[actionName]();
const result = mod.run(filePath, pageIndex, values.output);
const result = mod.run(filePath, pageIndex, values.output, {
spec: values.spec, base: values.base, strict: values.strict, prune: values.prune,
dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind,
property: values.property, from: values.from, to: values.to,
});
process.stdout.write(
yaml.dump(result, { noRefs: true, sortKeys: false, lineWidth: 120 })
);
@@ -121,6 +160,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
if (actionName === "validate" && summary && typeof summary === "object" && (summary as Record<string, unknown>).valid === false) {
process.exitCode = 1;
}
if (result.failed === true) process.exitCode = 1;
} catch (err) {
const errorOutput = {
error: true,
+108
View File
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import test from "node:test";
const CLI = resolve("src/cli/commands.ts");
const run = (args: string[]) => spawnSync(process.execPath, ["--import", "tsx", CLI, ...args], { cwd: resolve("."), encoding: "utf8" });
test("CLI imports Draw.io to machine-readable IR only with explicit atomic output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-import-"));
const source = join(dir, "source.drawio"); const output = join(dir, "model.json");
try {
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const missing = run(["--action", "import", "--file", source]);
assert.equal(missing.status, 1);
assert.match(missing.stdout, /requires --output/i);
const result = run(["--action", "import", "--file", source, "--output", output]);
assert.equal(result.status, 0, result.stdout + result.stderr);
const parsed = JSON.parse(readFileSync(output, "utf8"));
assert.equal(parsed.version, 2);
assert.equal(parsed.pages[0].nodes[0].id, "api");
assert.match(result.stdout, /lossy: false/);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("failed CLI edit is atomic and traversal aliases cannot clobber source", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-edit-"));
const source = join(dir, "source.json"); const spec = join(dir, "bad.yaml"); const output = join(dir, "output.json");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8"); writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { parentId: missing } }\n", "utf8"); writeFileSync(output, "DO NOT CLOBBER", "utf8");
const failed = run(["--action", "edit", "--file", source, "--spec", spec, "--output", output]);
assert.equal(failed.status, 1);
assert.equal(readFileSync(source, "utf8"), sourceText);
assert.equal(readFileSync(output, "utf8"), "DO NOT CLOBBER");
const alias = join(dir, "nested", "..", "source.json");
const clobber = run(["--action", "edit", "--file", source, "--spec", spec, "--output", alias]);
assert.equal(clobber.status, 1);
assert.match(clobber.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects symlink input aliases targeting the real output", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-symlink-alias-"));
const source = join(dir, "source.json"); const inputLink = join(dir, "input.json"); const spec = join(dir, "edit.yaml");
const sourceText = JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }, null, 2) + "\n";
try {
writeFileSync(source, sourceText, "utf8");
symlinkSync(source, inputLink);
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const result = run(["--action", "edit", "--file", inputLink, "--spec", spec, "--output", source]);
assert.equal(result.status, 1);
assert.match(result.stdout, /overwrite an input/i);
assert.equal(readFileSync(source, "utf8"), sourceText);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI lifecycle flags route deterministic views, policies, what-if, sync, story, and doctor", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-actions-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const story = join(dir, "story.html"); const base = join(dir, "base.json"); const incoming = join(dir, "incoming.json"); const synced = join(dir, "synced.json");
const ir = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service", properties: { environment: "production" } }, { id: "db", label: "DB", kind: "database" }], edges: [{ id: "write", source: "api", target: "db", properties: { isolates_failure: true } }] }] };
try {
writeFileSync(model, JSON.stringify(ir), "utf8"); writeFileSync(base, JSON.stringify(ir), "utf8"); writeFileSync(incoming, JSON.stringify(ir), "utf8");
const viewResult = run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]);
assert.equal(viewResult.status, 0, viewResult.stdout + viewResult.stderr); assert.equal(existsSync(views), true);
const policy = run(["--action", "test", "--file", model, "--strict"]); assert.equal(policy.status, 1); assert.match(policy.stdout, /warnings:/);
const whatif = run(["--action", "what-if", "--file", model, "--fail", "api"]); assert.equal(whatif.status, 0); assert.match(whatif.stdout, /impacted: \[\]/);
const sync = run(["--action", "sync", "--file", model, "--base", base, "--spec", incoming, "--prune", "--output", synced]); assert.equal(sync.status, 0, sync.stdout + sync.stderr);
const storyResult = run(["--action", "story", "--file", model, "--fail", "api", "--output", story]); assert.equal(storyResult.status, 0); assert.doesNotMatch(readFileSync(story, "utf8"), /https?:\/\//);
const doctor = run(["--action", "doctor"]); assert.equal(doctor.status, 0, doctor.stdout + doctor.stderr); assert.match(doctor.stdout, /networkRequired: false/);
const first = readFileSync(views, "utf8"); run(["--action", "views", "--file", model, "--views", "system,security", "--output", views]); assert.equal(readFileSync(views, "utf8"), first);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI edits serialize Draw.io output and rejects unsupported import extensions", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-formats-"));
const model = join(dir, "model.json"); const spec = join(dir, "edit.yaml"); const drawio = join(dir, "edited.drawio");
const source = join(dir, "source.drawio"); const unsupported = join(dir, "model.txt");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(spec, "pageId: p\noperations:\n - { type: update, id: api, changes: { label: Updated } }\n", "utf8");
const edited = run(["--action", "edit", "--file", model, "--spec", spec, "--output", drawio]);
assert.equal(edited.status, 0, edited.stdout + edited.stderr);
assert.match(readFileSync(drawio, "utf8"), /^<\?xml[^]*<mxfile/);
writeFileSync(source, `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`, "utf8");
const imported = run(["--action", "import", "--file", source, "--output", unsupported]);
assert.equal(imported.status, 1);
assert.match(imported.stdout, /yaml.*json|json.*yaml/i);
assert.equal(existsSync(unsupported), false);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
test("CLI rejects unknown linked views and policy identifiers", () => {
const dir = mkdtempSync(join(tmpdir(), "lifecycle-cli-invalid-"));
const model = join(dir, "model.json"); const views = join(dir, "views.drawio"); const rules = join(dir, "rules.yaml");
try {
writeFileSync(model, JSON.stringify({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API" }], edges: [] }] }), "utf8");
writeFileSync(rules, "rules: [not-a-real-policy]\n", "utf8");
const badView = run(["--action", "views", "--file", model, "--views", "not-a-view", "--output", views]);
assert.equal(badView.status, 1); assert.match(badView.stdout, /unknown.*view/i); assert.equal(existsSync(views), false);
const badPolicy = run(["--action", "test", "--file", model, "--spec", rules]);
assert.equal(badPolicy.status, 1); assert.match(badPolicy.stdout, /unknown.*policy/i);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
+1 -1
View File
@@ -79,7 +79,7 @@ export interface DiagramIRV2 {
export type DiagramIR = DiagramIRV1 | DiagramIRV2;
const SAFE_ID = /^[A-Za-z][A-Za-z0-9_.:-]*$/;
const SAFE_ID = /^[^\s"'<>]+$/;
const RESERVED_IDS = new Set(["0", "1"]);
const LAYOUT_TYPES = new Set<DiagramLayoutType>(["linear", "layered", "tree", "grid", "manual"]);
const ROOT_V1_FIELDS = new Set(["version", "title", "direction", "nodes", "edges"]);
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { projectLinkedViews, queryDiagram, runPolicies, simulateFailure } from "./analysis.js";
const IR: DiagramIRV2 = { version: 2, provenance: { source: "catalog" }, pages: [{ id: "system", title: "System", nodes: [
{ id: "internet", label: "Internet", kind: "external", properties: { importance: 10, trust_boundary: "public" }, provenance: { source: "inventory" } },
{ id: "api", label: "API", kind: "service", properties: { importance: 8, environment: "production", trust_boundary: "private" } },
{ id: "worker", label: "Worker", kind: "service", properties: { owner: "ops", runtime: "node" } },
{ id: "db", label: "DB", kind: "database", properties: { owner: "data", trust_boundary: "data" } },
{ id: "vendor", label: "Vendor", kind: "external" },
{ id: "orphan", label: "Orphan", kind: "service" },
], edges: [
{ id: "bad", source: "internet", target: "db" },
{ id: "request", source: "internet", target: "api", properties: { protocol: "HTTPS" } },
{ id: "dispatch", source: "api", target: "worker", kind: "async", properties: { isolates_failure: true } },
{ id: "write", source: "api", target: "db", kind: "write", properties: { protocol: "TLS" } },
{ id: "external-call", source: "api", target: "vendor" },
{ id: "cycle", source: "worker", target: "api" },
] }] };
test("linked view projection is deterministic, preserves model identity, and explains fallbacks", () => {
const views = projectLinkedViews(IR, ["executive", "system", "deployment", "dataflow", "security"]);
assert.deepEqual(views.map((view) => view.id), ["executive", "system", "deployment", "dataflow", "security"]);
assert.deepEqual(views[0].nodes.slice(0, 2).map((node) => node.id), ["internet", "api"]);
assert.deepEqual(views[0].nodes[0].provenance, { source: "inventory" });
assert.equal(views[1].fallback, false);
assert.ok(views[2].nodes.some((node) => node.id === "worker"));
assert.ok(views[3].nodes.some((node) => node.id === "db"));
assert.equal(projectLinkedViews({ ...IR, pages: [{ ...IR.pages[0], nodes: IR.pages[0].nodes.map((node) => ({ id: node.id, label: node.label })) }] }, ["security"])[0].fallbackReason?.length! > 0, true);
assert.deepEqual(projectLinkedViews(IR), projectLinkedViews(IR));
});
test("semantic query filters kind/properties and chooses deterministic shortest path", () => {
assert.deepEqual(queryDiagram(IR, { kind: "service", properties: { owner: "ops" } }).nodes.map((node) => node.id), ["worker"]);
const path = queryDiagram(IR, { from: "internet", to: "worker" });
assert.deepEqual(path.path, ["internet", "api", "worker"]);
assert.deepEqual(path.edges.map((edge) => edge.id), ["request", "dispatch"]);
});
test("architecture policies distinguish errors and warnings for all lifecycle rules", () => {
const report = runPolicies(IR);
assert.ok(report.findings.some((finding) => finding.rule === "no-direct-internet-to-database" && finding.severity === "error"));
for (const rule of ["no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"]) {
assert.ok(report.findings.some((finding) => finding.rule === rule && finding.severity === "warning"), rule);
}
assert.equal(report.errors, 1);
assert.ok(report.warnings >= 6);
});
test("what-if outgoing reachability stops at failure-isolating edges", () => {
const result = simulateFailure(IR, "internet");
assert.deepEqual(result.impacted, ["api", "db", "vendor"]);
assert.deepEqual(result.paths.api, ["internet", "api"]);
assert.equal(result.paths.worker, undefined);
assert.throws(() => simulateFailure(IR, "missing"), /unknown node/i);
});
test("semantic operations fail closed on ambiguous page-local IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
pages: [
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
],
};
assert.throws(() => queryDiagram(ambiguous, { kind: "service" }), /ambiguous.*shared.*one.*two/i);
assert.throws(() => projectLinkedViews(ambiguous), /ambiguous.*shared.*one.*two/i);
assert.throws(() => runPolicies(ambiguous), /ambiguous.*shared.*one.*two/i);
assert.throws(() => simulateFailure(ambiguous, "shared"), /ambiguous.*shared.*one.*two/i);
});
@@ -0,0 +1,133 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type ViewName = "executive" | "system" | "deployment" | "dataflow" | "security";
export interface LinkedView { id: ViewName; title: string; nodes: DiagramNode[]; edges: DiagramEdge[]; sourcePageIds: string[]; fallback: boolean; fallbackReason?: string; hint?: string }
export interface QueryOptions { kind?: string; properties?: Record<string, unknown>; from?: string; to?: string }
export interface QueryResult { nodes: DiagramNode[]; edges: DiagramEdge[]; path?: string[] }
export interface PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string }
export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] }
function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir);
const pagesByNodeId = new Map<string, string[]>();
for (const page of ir.pages) {
for (const node of page.nodes) pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
}
for (const [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
return { nodes: ir.pages.flatMap((page) => page.nodes), edges: ir.pages.flatMap((page) => page.edges) };
}
function induced(nodes: DiagramNode[], edges: DiagramEdge[], selected: Set<string>): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
return { nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(node)), edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)).map((edge) => structuredClone(edge)) };
}
export function projectLinkedViews(ir: DiagramIRV2, requested: ViewName[] = ["executive", "system", "deployment", "dataflow", "security"]): LinkedView[] {
const { nodes, edges } = flatten(ir);
const all = new Set(nodes.map((node) => node.id));
const degree = new Map(nodes.map((node) => [node.id, 0]));
for (const edge of edges) { degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }
return requested.map((name) => {
let selected = new Set<string>();
let reason: string | undefined;
let hint: string | undefined;
if (name === "system") selected = all;
else if (name === "executive") {
const hasImportance = nodes.some((node) => typeof node.properties?.importance === "number");
selected = new Set([...nodes].sort((a, b) => Number(b.properties?.importance ?? 0) - Number(a.properties?.importance ?? 0) || (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id)).slice(0, 12).map((node) => node.id));
if (!hasImportance) { reason = "no properties.importance metadata; ranked by connection degree"; hint = "set properties.importance on executive-significant components"; }
} else if (name === "deployment") {
selected = new Set(nodes.filter((node) => ["environment", "region", "runtime", "host", "deployment"].some((key) => node.properties?.[key] !== undefined)).map((node) => node.id));
if (!selected.size) { selected = all; reason = "no deployment metadata"; hint = "set properties.environment, region, runtime, host, or deployment"; }
} else if (name === "dataflow") {
const relevant = edges.filter((edge) => ["data", "read", "write", "async"].includes(edge.kind ?? "") || /data|event|read|write|publish|consume/i.test(edge.label ?? ""));
selected = new Set(relevant.flatMap((edge) => [edge.source, edge.target]));
if (!selected.size) { selected = all; reason = "no data-flow edges"; hint = "set edge kind to data, read, write, or async"; }
} else {
selected = new Set(nodes.filter((node) => ["external", "gateway", "database", "actor"].includes(node.kind ?? "") || node.properties?.trust_boundary !== undefined).map((node) => node.id));
for (const edge of edges) {
const source = nodes.find((node) => node.id === edge.source)?.properties?.trust_boundary;
const target = nodes.find((node) => node.id === edge.target)?.properties?.trust_boundary;
if (source !== target && (source !== undefined || target !== undefined)) { selected.add(edge.source); selected.add(edge.target); }
}
if (!selected.size) { selected = all; reason = "no trust-boundary or security-kind metadata"; hint = "set properties.trust_boundary or kind external/database/gateway/actor"; }
}
const projection = induced(nodes, edges, selected);
return { id: name, title: name[0].toUpperCase() + name.slice(1), ...projection, sourcePageIds: ir.pages.map((page) => page.id), fallback: reason !== undefined, ...(reason ? { fallbackReason: reason, hint } : {}) };
});
}
export function queryDiagram(ir: DiagramIRV2, options: QueryOptions): QueryResult {
const { nodes, edges } = flatten(ir);
if ((options.from === undefined) !== (options.to === undefined)) throw new Error("Path query requires both from and to");
if (options.from && options.to) {
const ids = new Set(nodes.map((node) => node.id));
if (!ids.has(options.from) || !ids.has(options.to)) throw new Error("Path query references an unknown node");
const queue = [options.from];
const previous = new Map<string, { node: string; edge: DiagramEdge } | null>([[options.from, null]]);
while (queue.length) {
const current = queue.shift()!;
if (current === options.to) break;
for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) {
if (!previous.has(edge.target)) { previous.set(edge.target, { node: current, edge }); queue.push(edge.target); }
}
}
if (!previous.has(options.to)) return { nodes: [], edges: [], path: [] };
const path: string[] = [];
const pathEdges: DiagramEdge[] = [];
let current = options.to;
while (true) {
path.push(current);
const prior = previous.get(current)!;
if (!prior) break;
pathEdges.push(prior.edge);
current = prior.node;
}
path.reverse(); pathEdges.reverse();
const selected = new Set(path);
return { nodes: nodes.filter((node) => selected.has(node.id)), edges: pathEdges, path };
}
const selectedNodes = nodes.filter((node) => (!options.kind || node.kind === options.kind) && Object.entries(options.properties ?? {}).every(([key, value]) => JSON.stringify(node.properties?.[key]) === JSON.stringify(value)));
const selected = new Set(selectedNodes.map((node) => node.id));
return { nodes: selectedNodes, edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)) };
}
function cycles(nodes: DiagramNode[], edges: DiagramEdge[]): string[][] {
const adjacency = new Map<string, string[]>();
for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort());
const visiting = new Set<string>(); const done = new Set<string>(); const stack: string[] = []; const found: string[][] = [];
const visit = (id: string): void => {
if (visiting.has(id)) { const start = stack.indexOf(id); found.push([...stack.slice(start), id]); return; }
if (done.has(id)) return;
visiting.add(id); stack.push(id);
for (const target of adjacency.get(id) ?? []) visit(target);
stack.pop(); visiting.delete(id); done.add(id);
};
for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) visit(node.id);
return found;
}
export const POLICY_IDS = ["no-direct-internet-to-database", "no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"] as const;
export function runPolicies(ir: DiagramIRV2, enabled: readonly string[] = POLICY_IDS): PolicyReport {
const { nodes, edges } = flatten(ir); const byId = new Map(nodes.map((node) => [node.id, node])); const findings: PolicyFinding[] = [];
const add = (rule: string, severity: "error" | "warning", subject: string, message: string, hint: string): void => { findings.push({ rule, severity, subject, message, hint }); };
if (enabled.includes("no-direct-internet-to-database")) for (const edge of edges) { const source = byId.get(edge.source); const target = byId.get(edge.target); if ((source?.kind === "external" || source?.kind === "actor" || /internet/i.test(source?.label ?? "")) && target?.kind === "database") add("no-direct-internet-to-database", "error", edge.id, "internet-facing component connects directly to a database", "insert an authenticated service boundary"); }
if (enabled.includes("no-cycles")) for (const cycle of cycles(nodes, edges)) add("no-cycles", "warning", cycle.join(" -> "), "cyclic dependency detected", "break the cycle or add an asynchronous boundary");
if (enabled.includes("no-orphans") && nodes.length > 1) for (const node of nodes) if (!node.properties?.intentional_orphan && !edges.some((edge) => edge.source === node.id || edge.target === node.id)) add("no-orphans", "warning", node.id, "component is disconnected", "connect it or set properties.intentional_orphan=true");
if (enabled.includes("every-service-has-owner")) for (const node of nodes) if (["service", "gateway", "database", "queue"].includes(node.kind ?? "") && !node.properties?.owner) add("every-service-has-owner", "warning", node.id, "service has no owner", "set properties.owner");
if (enabled.includes("production-has-observability")) for (const node of nodes) if (["prod", "production"].includes(String(node.properties?.environment ?? "").toLowerCase()) && !node.properties?.observability) add("production-has-observability", "warning", node.id, "production component lacks observability metadata", "set properties.observability");
if (enabled.includes("external-dependencies-have-timeouts")) for (const edge of edges) if (byId.get(edge.target)?.kind === "external" && !edge.properties?.timeout) add("external-dependencies-have-timeouts", "warning", edge.id, "external dependency has no timeout", "set edge properties.timeout");
if (enabled.includes("trust-boundaries-use-protocol")) for (const edge of edges) { const a = byId.get(edge.source)?.properties?.trust_boundary; const b = byId.get(edge.target)?.properties?.trust_boundary; if (a !== b && !edge.properties?.protocol && !edge.label) add("trust-boundaries-use-protocol", "warning", edge.id, "unlabelled connection crosses a trust boundary", "set edge properties.protocol"); }
findings.sort((a, b) => a.rule.localeCompare(b.rule) || a.subject.localeCompare(b.subject));
return { errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length, findings };
}
export function simulateFailure(ir: DiagramIRV2, failed: string): { failed: string; impacted: string[]; paths: Record<string, string[]> } {
const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`);
const queue = [failed]; const paths: Record<string, string[]> = { [failed]: [failed] }; const impacted = new Set<string>();
while (queue.length) { const current = queue.shift()!; for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) { if (edge.properties?.isolates_failure === true || paths[edge.target]) continue; paths[edge.target] = [...paths[current], edge.target]; impacted.add(edge.target); queue.push(edge.target); } }
return { failed, impacted: [...impacted].sort(), paths };
}
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { applyEditBatch, type EditBatch } from "./edit-batch.js";
const SOURCE: DiagramIRV2 = { version: 2, pages: [{ id: "main", title: "Main", layout: { type: "manual" }, nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: { x: 0, y: 0, width: 400, height: 300 } },
{ id: "api", label: "API", kind: "service", parentId: "zone", geometry: { x: 20, y: 40, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 500, y: 40, width: 120, height: 60 } },
], edges: [{ id: "query", source: "api", target: "db", label: "SQL" }] }] };
test("transactional edits apply typed add/update/move/connect operations with stable IDs", () => {
const batch: EditBatch = { pageId: "main", preconditions: [{ type: "exists", id: "api" }, { type: "not-exists", id: "worker" }], operations: [
{ type: "add", node: { id: "worker", label: "Worker", kind: "service", geometry: { x: 200, y: 40, width: 120, height: 60 } } },
{ type: "update", id: "api", changes: { label: "Public API", properties: { owner: "platform" } } },
{ type: "move", id: "worker", parentId: "zone", geometry: { x: 180, y: 140, width: 120, height: 60 } },
{ type: "connect", edge: { id: "dispatch", source: "api", target: "worker", kind: "async" } },
] };
const result = applyEditBatch(SOURCE, batch);
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "api")?.label, "Public API");
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.parentId, "zone");
assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "dispatch")?.target, "worker");
assert.deepEqual(SOURCE.pages[0].nodes.map((node) => node.id), ["zone", "api", "db"]);
});
test("edit batches reject unknown precondition types", () => {
const batch = { pageId: "main", preconditions: [{ type: "surprise", id: "missing" }], operations: [] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown precondition type.*surprise/i);
});
test("edit batches reject unknown operation types instead of deleting", () => {
const batch = { pageId: "main", operations: [{ type: "surprise", id: "api" }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /unknown operation type.*surprise/i);
assert.ok(SOURCE.pages[0].nodes.some((node) => node.id === "api"));
});
test("edit batches reject id changes in runtime update payloads", () => {
const batch = { pageId: "main", operations: [{ type: "update", id: "api", changes: { id: "renamed" } }] } as unknown as EditBatch;
assert.throws(() => applyEditBatch(SOURCE, batch), /update changes.*id/i);
});
test("edit batches are atomic, support dry-run, and require explicit cascade", () => {
const before = JSON.stringify(SOURCE);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone" }] }), /cascade/i);
assert.equal(JSON.stringify(SOURCE), before);
assert.throws(() => applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "update", id: "api", changes: { parentId: "missing" } }] }), /unknown parent/i);
assert.equal(JSON.stringify(SOURCE), before);
const dry = applyEditBatch(SOURCE, { pageId: "main", operations: [{ type: "delete", id: "zone", cascade: true }] }, { dryRun: true });
assert.deepEqual(dry.ir, SOURCE);
assert.deepEqual(dry.preview.pages[0].nodes.map((node) => node.id), ["db"]);
});
@@ -0,0 +1,95 @@
import type { DiagramEdge, DiagramGeometry, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type EditPrecondition =
| { type: "exists"; id: string }
| { type: "not-exists"; id: string }
| { type: "property-equals"; id: string; property: string; value: unknown };
export type EditOperation =
| { type: "add"; node: DiagramNode }
| { type: "update"; id: string; changes: Partial<Omit<DiagramNode, "id">> }
| { type: "move"; id: string; parentId?: string; geometry: DiagramGeometry }
| { type: "delete"; id: string; cascade?: boolean }
| { type: "connect"; edge: DiagramEdge };
export interface EditBatch {
pageId: string;
preconditions?: EditPrecondition[];
operations: EditOperation[];
}
export interface EditBatchResult {
ir: DiagramIRV2;
preview: DiagramIRV2;
applied: number;
dryRun: boolean;
}
function getItem(page: DiagramIRV2["pages"][number], id: string): DiagramNode | DiagramEdge | undefined {
return page.nodes.find((node) => node.id === id) ?? page.edges.find((edge) => edge.id === id);
}
export function applyEditBatch(source: DiagramIRV2, batch: EditBatch, options: { dryRun?: boolean } = {}): EditBatchResult {
validateDiagramIR(source);
const candidate = structuredClone(source);
const page = candidate.pages.find((item) => item.id === batch.pageId);
if (!page) throw new Error(`Unknown page: ${batch.pageId}`);
for (const condition of batch.preconditions ?? []) {
const item = getItem(page, condition.id);
if (condition.type === "exists" && !item) throw new Error(`Precondition failed: ${condition.id} does not exist`);
if (condition.type === "not-exists" && item) throw new Error(`Precondition failed: ${condition.id} already exists`);
if (condition.type === "property-equals") {
const actual = item?.properties?.[condition.property];
if (!item || JSON.stringify(actual) !== JSON.stringify(condition.value)) throw new Error(`Precondition failed: ${condition.id}.${condition.property} differs`);
} else if (condition.type !== "exists" && condition.type !== "not-exists") {
throw new Error(`Unknown precondition type: ${(condition as { type?: unknown }).type}`);
}
}
for (const operation of batch.operations) {
if (operation.type === "add") {
if (getItem(page, operation.node.id)) throw new Error(`ID already exists: ${operation.node.id}`);
page.nodes.push(structuredClone(operation.node));
} else if (operation.type === "connect") {
if (getItem(page, operation.edge.id)) throw new Error(`ID already exists: ${operation.edge.id}`);
page.edges.push(structuredClone(operation.edge));
} else if (operation.type === "update") {
if (Object.hasOwn(operation.changes, "id")) throw new Error("Update changes must not include id");
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
Object.assign(node, structuredClone(operation.changes));
} else if (operation.type === "move") {
const node = page.nodes.find((item) => item.id === operation.id);
if (!node) throw new Error(`Unknown node: ${operation.id}`);
node.geometry = structuredClone(operation.geometry);
if (operation.parentId === undefined) delete node.parentId;
else node.parentId = operation.parentId;
} else if (operation.type === "delete") {
const nodeIndex = page.nodes.findIndex((item) => item.id === operation.id);
const edgeIndex = page.edges.findIndex((item) => item.id === operation.id);
if (nodeIndex < 0 && edgeIndex < 0) throw new Error(`Unknown item: ${operation.id}`);
if (edgeIndex >= 0) {
page.edges.splice(edgeIndex, 1);
continue;
}
const descendants = new Set<string>();
const collect = (parentId: string): void => {
for (const node of page.nodes) if (node.parentId === parentId && !descendants.has(node.id)) { descendants.add(node.id); collect(node.id); }
};
collect(operation.id);
const affected = new Set([operation.id, ...descendants]);
const connected = page.edges.filter((edge) => affected.has(edge.source) || affected.has(edge.target));
if (!operation.cascade && (descendants.size || connected.length)) throw new Error(`Delete ${operation.id} requires cascade for descendants or connected edges`);
page.nodes = page.nodes.filter((node) => !affected.has(node.id));
page.edges = page.edges.filter((edge) => !affected.has(edge.source) && !affected.has(edge.target));
} else {
throw new Error(`Unknown operation type: ${(operation as { type?: unknown }).type}`);
}
}
validateDiagramIR(candidate);
const dryRun = options.dryRun === true;
return { ir: dryRun ? source : candidate, preview: candidate, applied: batch.operations.length, dryRun };
}
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { JSDOM } from "jsdom";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { importDrawioToIR } from "./import-drawio.js";
test("loss-aware import preserves multi-page semantics and round-trips deterministically", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-import-"));
const source = join(dir, "source.drawio");
try {
writeFileSync(source, `<?xml version="1.0"?><mxfile host="desktop" custom-root="keep"><diagram id="system" name="System" custom-page="p"><mxGraphModel pageWidth="900" pageHeight="700" custom-model="m"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="zone" value="Zone" vertex="1" parent="1" style="swimlane;startSize=30;" custom-cell="c"><mxGeometry x="20" y="20" width="500" height="260" as="geometry" custom-geo="g"/></mxCell><mxCell id="api" value="API" vertex="1" parent="zone" style="fillColor=#fff;" data-kind="service" data-properties="{&quot;owner&quot;:&quot;team-a&quot;}" data-provenance="{&quot;source&quot;:&quot;catalog&quot;}" data-extensions="{&quot;x&quot;:1}"><mxGeometry x="40" y="60" width="120" height="60" as="geometry"/><Custom payload="keep"/></mxCell><mxCell id="call" value="HTTPS" edge="1" parent="1" source="api" target="zone" style="edgeStyle=orthogonalEdgeStyle;" data-kind="request"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="220" y="100"/></Array></mxGeometry></mxCell><mxCell id="note" value="raw" parent="1" custom="yes"/></root></mxGraphModel></diagram><diagram id="deploy" name="Deployment"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="host" value="Host" vertex="1" parent="1"><mxGeometry x="10" y="10" width="100" height="50" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`, "utf8");
const imported = importDrawioToIR(source);
assert.equal(imported.ir.pages.length, 2);
const page = imported.ir.pages[0];
assert.equal(page.id, "system");
assert.equal(page.nodes.find((node) => node.id === "api")?.parentId, "zone");
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.geometry, { x: 40, y: 60, width: 120, height: 60 });
assert.deepEqual(page.nodes.find((node) => node.id === "api")?.properties, { owner: "team-a" });
assert.deepEqual(page.edges[0].waypoints, [{ x: 220, y: 100 }]);
assert.equal(imported.lossReport.lossy, false);
assert.ok(JSON.stringify(imported.ir.extensions).includes("custom-root"));
assert.ok(JSON.stringify(page.extensions).includes("note"));
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<mxfile host="desktop"/);
const roundTrip = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.deepEqual(roundTrip.ir.pages.map((item) => item.id), ["system", "deploy"]);
assert.deepEqual(roundTrip.ir.pages[0].nodes, page.nodes);
assert.deepEqual(roundTrip.ir.pages[0].edges, page.edges);
assert.equal(diagramIRToDrawio(roundTrip.ir), rebuilt);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("import and serialization preserve model flags, node geometry children, and absent node style without false losslessness", () => {
const xml = `<mxfile><diagram id="p" name="P"><mxGraphModel grid="0" page="0"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="10" y="20" width="100" height="50" relative="1" as="geometry"><mxPoint x="3" y="4" as="offset"/></mxGeometry></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
const document = new JSDOM(rebuilt, { contentType: "text/xml" }).window.document;
const model = document.querySelector("mxGraphModel")!;
const node = document.querySelector("mxCell#n")!;
const geometry = node.querySelector(":scope > mxGeometry")!;
const offset = geometry.querySelector(":scope > mxPoint[as='offset']")!;
assert.equal(model.getAttribute("grid"), "0");
assert.equal(model.getAttribute("page"), "0");
assert.equal(node.hasAttribute("style"), false);
assert.equal(geometry.getAttribute("relative"), "1");
assert.deepEqual([offset.getAttribute("x"), offset.getAttribute("y")], ["3", "4"]);
assert.equal(importDrawioToIR(rebuilt, { sourceIsXml: true }).lossReport.lossy, false);
});
test("import rejects duplicate semantic node IDs across pages", () => {
const xml = `<mxfile><diagram id="one" name="One"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="First" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram><diagram id="two" name="Two"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="Second" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /duplicate semantic node id.*api.*pages.*one.*two/i);
});
test("loss-aware import fails closed on dangling endpoints", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell><mxCell id="bad" edge="1" source="a" target="missing"><mxGeometry relative="1" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.throws(() => importDrawioToIR(xml, { sourceIsXml: true }), /dangling endpoint.*bad.*missing/i);
});
test("loss-aware import preserves valid Draw.io IDs that begin with digits", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="42-node" value="Numeric" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
assert.equal(importDrawioToIR(xml, { sourceIsXml: true }).ir.pages[0].nodes[0].id, "42-node");
});
test("loss-aware import preserves unknown mxfile, diagram, and model children", () => {
const xml = `<mxfile><meta-marker keep="root"/><diagram id="p" name="P"><diagram-marker keep="page"/><mxGraphModel><model-marker keep="model"/><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="n" value="N" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true });
assert.equal(imported.lossReport.lossy, false);
const rebuilt = diagramIRToDrawio(imported.ir);
assert.match(rebuilt, /<meta-marker keep="root"\s*\/>/);
assert.match(rebuilt, /<diagram-marker keep="page"\s*\/>/);
assert.match(rebuilt, /<model-marker keep="model"\s*\/>/);
const second = importDrawioToIR(rebuilt, { sourceIsXml: true });
assert.equal(second.lossReport.lossy, false);
assert.equal(diagramIRToDrawio(second.ir), rebuilt);
});
test("loss-aware import preserves deliberately empty labels", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="icon" value="" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const imported = importDrawioToIR(xml, { sourceIsXml: true }).ir;
assert.equal(imported.pages[0].nodes[0].label, "icon");
assert.match(JSON.stringify(imported.pages[0].nodes[0].extensions), /semantic:label/);
assert.match(diagramIRToDrawio(imported), /id="icon" value=""/);
});
test("loss-aware import envelopes malformed semantic attributes instead of dropping them", () => {
const xml = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="api" value="API" vertex="1" parent="1" data-properties="not-json"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell></root></mxGraphModel>`;
const result = importDrawioToIR(xml, { sourceIsXml: true });
assert.match(JSON.stringify(result.ir.pages[0].nodes[0].extensions), /data-properties.*not-json/);
});
@@ -0,0 +1,237 @@
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js";
export interface PreservationEnvelope {
host?: string;
attributes?: Record<string, string>;
holderTag?: string;
holderAttributes?: Record<string, string>;
childXml?: string[];
geometryAttributes?: Record<string, string>;
geometryChildXml?: string[];
styleAbsent?: boolean;
modelAttributes?: Record<string, string>;
modelChildXml?: string[];
unknownCells?: string[];
}
export interface ImportLossReport {
lossy: boolean;
preserved: string[];
warnings: string[];
}
export interface ImportDrawioOptions {
sourceIsXml?: boolean;
}
const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]);
const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]);
const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]);
const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]);
const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]);
function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document {
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
const Parser = new JSDOM("<root/>", { contentType: "text/xml" }).window.DOMParser;
const parsed = new Parser().parseFromString(xml, "text/xml");
if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) {
throw new Error(`Invalid XML: expected complete <${expected}> document`);
}
return parsed;
}
function serializeElement(element: Element): string {
const XMLSerializer = new JSDOM("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
return new XMLSerializer().serializeToString(element);
}
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) {
if (!known.has(attribute.name)) result[attribute.name] = attribute.value;
}
return Object.keys(result).length ? result : undefined;
}
function selectedAttrs(element: Element, names: Set<string>): Record<string, string> | undefined {
const result: Record<string, string> = {};
for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!;
return Object.keys(result).length ? result : undefined;
}
function jsonObject(element: Element, name: string): Record<string, unknown> | undefined {
const raw = element.getAttribute(name);
if (!raw) return undefined;
try {
const value: unknown = JSON.parse(raw);
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
} catch {
// Invalid semantic JSON remains preserved as an unknown attribute below.
}
return undefined;
}
function numberAttr(element: Element | null, name: string, fallback = 0): number {
const raw = element?.getAttribute(name);
if (raw === null || raw === undefined || raw === "") return fallback;
const value = Number(raw);
if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`);
return value;
}
function mergePreservation(extensions: Record<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | undefined {
const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0)));
if (!Object.keys(clean).length) return extensions;
return { ...(extensions ?? {}), $drawio: clean };
}
function cellRows(model: Element): Array<{ holder: Element; cell: Element }> {
const root = Array.from(model.children).find((child) => child.tagName === "root");
if (!root) throw new Error("Draw.io page is missing <root>");
const rows: Array<{ holder: Element; cell: Element }> = [];
for (const child of Array.from(root.children)) {
if (child.tagName === "mxCell") rows.push({ holder: child, cell: child });
else if (child.tagName === "object" || child.tagName === "UserObject") {
const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell");
if (cell) rows.push({ holder: child, cell });
}
}
return rows;
}
function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage {
const document = parseDocument(modelXml, "mxGraphModel");
const model = document.documentElement;
const rows = cellRows(model);
const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""));
const root = Array.from(model.children).find((child) => child.tagName === "root")!;
const knownElements = new Set(rows.map(({ holder }) => holder));
const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child));
const nodes: DiagramNode[] = [];
const edges: DiagramEdge[] = [];
for (const { holder, cell } of rows) {
const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? "";
if (!id || id === "0" || id === "1") continue;
const semantic = holder === cell ? cell : holder;
const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null;
const envelope: PreservationEnvelope = {
attributes: attrs(cell, CELL_KNOWN),
holderTag: holder === cell ? undefined : holder.tagName,
holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])),
childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)),
geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined,
geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined,
styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined,
};
for (const name of ["data-properties", "data-provenance", "data-extensions"]) {
const owner = semantic.hasAttribute(name) ? semantic : cell;
if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) {
envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! };
}
}
const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined;
if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) };
const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id;
if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel };
const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope);
const label = rawLabel.trim() === "" ? id : rawLabel;
if (cell.getAttribute("vertex") === "1") {
nodes.push({
id,
label,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"),
...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}),
geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") },
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else if (cell.getAttribute("edge") === "1") {
const source = cell.getAttribute("source") ?? "";
const target = cell.getAttribute("target") ?? "";
const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint));
if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`);
const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : [];
edges.push({
id, source, target, label: rawLabel,
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation",
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
...(points.length ? { waypoints: points } : {}),
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
...(extensions ? { extensions } : {}),
});
} else {
unknownCells.push(serializeElement(holder));
}
}
const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), {
attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) },
modelAttributes: selectedAttrs(model, MODEL_KNOWN),
childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined,
modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement),
unknownCells,
});
if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`);
return {
id: pageElement?.getAttribute("id") || `page-${index + 1}`,
title: pageElement?.getAttribute("name") || `Page ${index + 1}`,
nodes,
edges,
layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) },
...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}),
...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}),
...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}),
...(pageExtensions ? { extensions: pageExtensions } : {}),
};
}
export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } {
const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8"));
const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] };
let pages: DiagramPage[];
let root: Element | undefined;
if (/^<mxfile/i.test(raw)) {
const document = parseDocument(raw, "mxfile");
root = document.documentElement;
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
if (!diagrams.length) throw new Error("No <diagram> elements found in mxfile");
pages = diagrams.map((diagram, index) => {
const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel");
return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report);
});
} else {
pages = [parsePage(raw, undefined, 0, report)];
}
const nodePages = new Map<string, string>();
for (const page of pages) for (const node of page.nodes) {
const priorPage = nodePages.get(node.id);
if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`);
nodePages.set(node.id, page.id);
}
const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), {
host: root.getAttribute("host") ?? undefined,
attributes: attrs(root, ROOT_KNOWN),
childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement),
}) : undefined;
const ir: DiagramIRV2 = {
version: 2,
...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}),
pages,
...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}),
...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}),
...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}),
...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}),
...(extensions ? { extensions } : {}),
};
validateDiagramIR(ir);
return { ir, lossReport: report };
}
@@ -0,0 +1,35 @@
import { lstatSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { basename, dirname, extname, resolve } from "node:path";
import * as yaml from "js-yaml";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { normalizeDiagramIR, validateDiagramIR } from "../../model/diagram-ir.js";
import { importDrawioToIR } from "./import-drawio.js";
export interface LifecycleActionOptions { spec?: string; base?: string; strict?: boolean; prune?: boolean; dryRun?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string }
export function loadStructured(path: string): unknown { return yaml.load(readFileSync(path, "utf8"), { schema: yaml.JSON_SCHEMA }); }
export function loadIR(path: string): DiagramIRV2 { return /\.(drawio|xml)$/i.test(path) ? importDrawioToIR(path).ir : normalizeDiagramIR(validateDiagramIR(loadStructured(path))); }
export function structuredText(value: unknown, path: string): string { return extname(path).toLowerCase() === ".json" ? `${JSON.stringify(value, null, 2)}\n` : yaml.dump(value, { noRefs: true, sortKeys: false, lineWidth: 120 }); }
function canonicalExisting(path: string): string {
const absolute = resolve(path);
try { return realpathSync(absolute); } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return absolute;
throw error;
}
}
export function assertOutputSafe(path: string, inputs: string[] = []): string {
const output = resolve(path);
try { if (lstatSync(output).isSymbolicLink()) throw new Error("Refusing to overwrite a symbolic link"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
const canonicalOutput = canonicalExisting(output);
if (inputs.some((input) => canonicalExisting(input) === canonicalOutput)) throw new Error("Refusing to overwrite an input path (including traversal aliases)");
return output;
}
export function atomicWrite(path: string, content: string, inputs: string[] = []): string {
const output = assertOutputSafe(path, inputs);
const temporary = resolve(dirname(output), `.${basename(output)}.${process.pid}.${Date.now()}.tmp`);
try { writeFileSync(temporary, content, { encoding: "utf8", flag: "wx" }); renameSync(temporary, output); } finally { rmSync(temporary, { force: true }); }
return output;
}
@@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { createStoryHtml, doctorReport } from "./publishing.js";
const ir: DiagramIRV2 = { version: 2, title: "Platform", pages: [{ id: "p", title: "P", nodes: [
{ id: "api", label: "API <primary>", kind: "service", properties: { owner: "team" }, provenance: { source: "catalog" }, geometry: { x: 10, y: 20, width: 120, height: 60 } },
{ id: "db", label: "DB", kind: "database", geometry: { x: 220, y: 20, width: 120, height: 60 } },
], edges: [{ id: "write", source: "api", target: "db", label: "SQL" }] }] };
test("story HTML is deterministic, offline, accessible, keyboard navigable, and supports a what-if overlay", () => {
const html = createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } });
assert.equal(createStoryHtml(ir, { scenario: { failed: "api", impacted: ["db"] } }), html);
assert.match(html, /^<!doctype html>/i);
assert.match(html, /http-equiv="Content-Security-Policy"/i);
assert.match(html, /default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'/i);
assert.doesNotMatch(html, /https?:\/\//i);
assert.doesNotMatch(html, /<script[^>]+src=|<link[^>]+href=/i);
assert.match(html, /aria-labelledby=/);
assert.match(html, /Text alternative/);
assert.match(html, /ArrowRight/);
assert.match(html, /data-status="failed"/);
assert.match(html, /API &lt;primary&gt;/);
assert.match(html, /catalog/);
});
test("doctor reports optional backend availability without launching commands", () => {
let launches = 0;
const report = doctorReport({ path: "/definitely/missing", onLaunch: () => launches++ });
assert.equal(launches, 0);
assert.equal(report.networkRequired, false);
assert.equal(report.backends.drawio.status, "missing");
assert.equal(report.backends.graphviz.mandatory, false);
assert.equal(report.capabilities.semanticLifecycle, true);
});
test("story fails closed on ambiguous multi-page node IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
pages: [
{ id: "one", title: "One", nodes: [{ id: "shared", label: "First" }], edges: [] },
{ id: "two", title: "Two", nodes: [{ id: "shared", label: "Second" }], edges: [] },
],
};
assert.throws(() => createStoryHtml(ambiguous), /ambiguous.*shared.*one.*two/i);
});
@@ -0,0 +1,49 @@
import { accessSync, constants, existsSync } from "node:fs";
import { delimiter, join } from "node:path";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
function escapeHtml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;"); }
function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`; } return JSON.stringify(value); }
export interface StoryOptions { title?: string; scenario?: { failed: string; impacted: string[] } }
export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): string {
validateDiagramIR(ir);
const title = options.title ?? ir.title ?? "Architecture Story";
const pagesByNodeId = new Map<string, string[]>();
for (const page of ir.pages) for (const node of page.nodes) {
pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
}
for (const [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous story node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
const nodes = ir.pages.flatMap((page) => page.nodes);
const edges = ir.pages.flatMap((page) => page.edges);
const positions = new Map(nodes.map((node, index) => [node.id, { x: node.geometry?.x ?? 60 + (index % 4) * 220, y: node.geometry?.y ?? 70 + Math.floor(index / 4) * 130, width: node.geometry?.width ?? 160, height: node.geometry?.height ?? 70 }]));
const failed = options.scenario?.failed; const impacted = new Set(options.scenario?.impacted ?? []);
const edgeSvg = edges.map((edge) => { const a = positions.get(edge.source); const b = positions.get(edge.target); if (!a || !b) return ""; return `<line x1="${a.x + a.width}" y1="${a.y + a.height / 2}" x2="${b.x}" y2="${b.y + b.height / 2}" marker-end="url(#arrow)"/><text x="${(a.x + a.width + b.x) / 2}" y="${(a.y + b.y) / 2}">${escapeHtml(edge.label ?? "")}</text>`; }).join("");
const steps = nodes.map((node) => ({ id: node.id, label: node.label, detail: `${node.kind ?? "component"}${node.properties?.owner ? ` · owner: ${String(node.properties.owner)}` : ""}${node.provenance ? ` · provenance: ${stableJson(node.provenance)}` : ""}` }));
const nodeSvg = nodes.map((node) => { const p = positions.get(node.id)!; const status = node.id === failed ? "failed" : impacted.has(node.id) ? "impacted" : "normal"; return `<g class="node ${status}" data-id="${escapeHtml(node.id)}" data-status="${status}" tabindex="0" role="button" aria-label="${escapeHtml(`${node.label}, ${node.kind ?? "component"}`)}"><rect x="${p.x}" y="${p.y}" width="${p.width}" height="${p.height}"/><text x="${p.x + p.width / 2}" y="${p.y + p.height / 2}" text-anchor="middle">${escapeHtml(node.label)}</text></g>`; }).join("");
const alternatives = steps.map((step) => `<li><strong>${escapeHtml(step.label)}</strong> — ${escapeHtml(step.detail)}</li>`).join("") + edges.map((edge) => `<li>${escapeHtml(edge.source)} → ${escapeHtml(edge.target)}${edge.label ? ` — ${escapeHtml(edge.label)}` : ""}</li>`).join("");
const data = stableJson(steps).replaceAll("</", "<\\/");
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' data:; font-src 'none'; connect-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>body{font:16px system-ui;margin:auto;max-width:1200px;padding:1rem;color:#17202a;background:#f7f8fa}button{padding:.5rem 1rem;margin-right:.5rem}svg{width:100%;min-height:500px;background:#fff;border:1px solid #667}.node rect{fill:#dae8fc;stroke:#315d87;stroke-width:2}.node.failed rect{fill:#f8cecc;stroke:#8c2f2b;stroke-width:4}.node.impacted rect{fill:#ffe6cc;stroke:#9a5c00;stroke-width:3}.node.active rect{stroke:#005fcc;stroke-width:5}line{stroke:#45525e;stroke-width:2}text{fill:#17202a}#narration{padding:1rem;border-left:4px solid #005fcc;background:#fff}</style></head><body><header><h1>${escapeHtml(title)}</h1><p>${nodes.length} components and ${edges.length} relationships.</p><button id="prev" type="button">Previous</button><button id="next" type="button">Next</button><button id="overview" type="button">Overview</button><span id="counter" aria-live="polite"></span><div id="narration" aria-live="polite">Overview</div></header><main><svg viewBox="0 0 1000 600" role="img" aria-labelledby="diagram-title diagram-desc"><title id="diagram-title">${escapeHtml(title)}</title><desc id="diagram-desc">Architecture containing ${nodes.length} components and ${edges.length} directed relationships.</desc><defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z"/></marker></defs>${edgeSvg}${nodeSvg}</svg><details><summary>Text alternative</summary><ol>${alternatives}</ol></details></main><script>const STEPS=${data};let i=-1;const nodes=[...document.querySelectorAll('.node')];function show(n){i=n;nodes.forEach(x=>x.classList.remove('active'));if(i>=0&&i<STEPS.length){const s=STEPS[i];nodes.find(x=>x.dataset.id===s.id)?.classList.add('active');narration.textContent=s.label+' — '+s.detail;counter.textContent=(i+1)+' / '+STEPS.length}else{narration.textContent='Overview';counter.textContent='Overview'}}next.onclick=()=>show(Math.min(STEPS.length-1,i+1));prev.onclick=()=>show(Math.max(-1,i-1));overview.onclick=()=>show(-1);nodes.forEach((node,index)=>{node.onclick=()=>show(index);node.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();show(index)}}});document.addEventListener('keydown',e=>{if(e.key==='ArrowRight')next.click();if(e.key==='ArrowLeft')prev.click()});show(-1);</script></body></html>`;
}
export interface DoctorBackend { status: "available" | "missing"; path?: string; mandatory: false }
export interface DoctorReport { networkRequired: false; backends: Record<string, DoctorBackend>; capabilities: { semanticLifecycle: true; nativeDrawio: true; storyHtml: true } }
function executable(name: string, pathValue: string): string | undefined {
for (const directory of pathValue.split(delimiter).filter(Boolean)) {
const candidate = join(directory, name);
if (!existsSync(candidate)) continue;
try { accessSync(candidate, constants.X_OK); return candidate; } catch { /* not executable */ }
}
return undefined;
}
export function doctorReport(options: { path?: string; onLaunch?: () => void } = {}): DoctorReport {
const pathValue = options.path ?? process.env.PATH ?? "";
const backend = (name: string): DoctorBackend => { const path = executable(name, pathValue); return path ? { status: "available", path, mandatory: false } : { status: "missing", mandatory: false }; };
return { networkRequired: false, backends: { drawio: backend("drawio"), graphviz: backend("dot") }, capabilities: { semanticLifecycle: true, nativeDrawio: true, storyHtml: true } };
}
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { syncDiagramIR } from "./sync.js";
const base: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [
{ id: "api", label: "API", kind: "service", properties: { owner: "a" }, geometry: { x: 10, y: 10, width: 100, height: 50 }, style: "fillColor=#fff;" },
{ id: "old", label: "Old", kind: "service", geometry: { x: 200, y: 10, width: 100, height: 50 } },
], edges: [{ id: "uses", source: "api", target: "old", label: "uses" }] }] };
test("three-way sync preserves manual geometry/style, adds incoming IDs, and reports semantic conflicts", () => {
const manual = structuredClone(base);
manual.pages[0].nodes[0].geometry = { x: 90, y: 80, width: 140, height: 70 };
manual.pages[0].nodes[0].style = "fillColor=#00ff00;";
manual.pages[0].nodes[0].label = "API manual";
const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [
{ id: "api", label: "API incoming", kind: "service", properties: { owner: "b" } },
{ id: "new", label: "New", kind: "service", geometry: { x: 300, y: 10, width: 100, height: 50 } },
], edges: [{ id: "new-edge", source: "api", target: "new" }] }] };
const result = syncDiagramIR(base, manual, incoming);
const api = result.ir.pages[0].nodes.find((node) => node.id === "api")!;
assert.deepEqual(api.geometry, { x: 90, y: 80, width: 140, height: 70 });
assert.equal(api.style, "fillColor=#00ff00;");
assert.equal(api.label, "API manual");
assert.ok(result.added.includes("new"));
assert.ok(result.conflicts.some((conflict) => conflict.id === "api" && conflict.field === "label"));
const removed = result.ir.pages[0].nodes.find((node) => node.id === "old")!;
assert.equal(removed.properties?.lifecycleStatus, "removed");
assert.match(removed.style!, /opacity=45/);
});
test("sync reports divergent add/add conflicts instead of silently choosing incoming", () => {
const manual = structuredClone(base);
manual.pages[0].nodes.push({ id: "worker", label: "Manual worker", kind: "service" });
const incoming = structuredClone(base);
incoming.pages[0].nodes.push({ id: "worker", label: "Incoming worker", kind: "database" });
const result = syncDiagramIR(base, manual, incoming);
const conflict = result.conflicts.find((item) => item.id === "worker" && item.field === "$add");
assert.ok(conflict);
assert.equal(conflict.base, undefined);
assert.equal((conflict.manual as { label: string }).label, "Manual worker");
assert.equal((conflict.incoming as { label: string }).label, "Incoming worker");
assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.label, "Manual worker");
});
test("sync reports divergent edge add/add conflicts", () => {
const manual = structuredClone(base);
manual.pages[0].edges.push({ id: "new-link", source: "api", target: "old", label: "Manual" });
const incoming = structuredClone(base);
incoming.pages[0].edges.push({ id: "new-link", source: "old", target: "api", label: "Incoming" });
const result = syncDiagramIR(base, manual, incoming);
assert.ok(result.conflicts.some((item) => item.id === "new-link" && item.field === "$add"));
assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "new-link")?.source, "api");
});
test("sync only prunes removals when explicitly requested and is deterministic", () => {
const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service" }], edges: [] }] };
const kept = syncDiagramIR(base, base, incoming);
assert.ok(kept.ir.pages[0].nodes.some((node) => node.id === "old"));
const pruned = syncDiagramIR(base, base, incoming, { prune: true });
assert.equal(pruned.ir.pages[0].nodes.some((node) => node.id === "old"), false);
assert.equal(pruned.ir.pages[0].edges.length, 0);
assert.deepEqual(syncDiagramIR(base, base, incoming, { prune: true }), pruned);
});
test("sync preserves pages manually added after the base when incoming omits them", () => {
const manualPage = { id: "notes", title: "Notes", nodes: [{ id: "note", label: "Note" }], edges: [] };
const manual: DiagramIRV2 = { ...structuredClone(base), pages: [...structuredClone(base.pages), manualPage] };
const incoming = structuredClone(base);
const result = syncDiagramIR(base, manual, incoming);
assert.deepEqual(result.ir.pages.find((page) => page.id === "notes"), manualPage);
assert.equal(result.removed.includes("notes"), false);
});
test("sync retains removed pages unless pruning is explicit", () => {
const oldPage = { id: "legacy", title: "Legacy", nodes: [{ id: "legacy-node", label: "Legacy" }], edges: [] };
const withPage: DiagramIRV2 = { ...base, pages: [...base.pages, oldPage] };
const incoming: DiagramIRV2 = { version: 2, pages: [structuredClone(base.pages[0])] };
const kept = syncDiagramIR(withPage, withPage, incoming);
const retainedPage = kept.ir.pages.find((page) => page.id === "legacy");
assert.ok(retainedPage);
assert.equal(retainedPage.properties?.lifecycleStatus, "removed");
assert.ok(kept.removed.includes("legacy"));
const pruned = syncDiagramIR(withPage, withPage, incoming, { prune: true });
assert.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false);
assert.ok(pruned.removed.includes("legacy"));
});
@@ -0,0 +1,113 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export interface SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown }
export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] }
const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
function conflictValue<T extends DiagramNode | DiagramEdge>(pageId: string, id: string, field: keyof T, base: T, manual: T, incoming: T, conflicts: SyncConflict[]): unknown {
const b = base[field]; const m = manual[field]; const n = incoming[field];
if (!equal(m, b) && !equal(n, b) && !equal(m, n)) { conflicts.push({ pageId, id, field: String(field), base: b, manual: m, incoming: n }); return structuredClone(m); }
return structuredClone(!equal(m, b) ? m : n);
}
function fadedStyle(style: string | undefined, edge = false): string {
const base = style ?? (edge ? "edgeStyle=orthogonalEdgeStyle;" : "whiteSpace=wrap;html=1;");
return `${base}${base.endsWith(";") ? "" : ";"}dashed=1;opacity=${edge ? 35 : 45};strokeColor=#b85450;`;
}
function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage, prune: boolean, conflicts: SyncConflict[], added: string[], removed: string[]): DiagramPage {
const baseNodes = new Map(base.nodes.map((item) => [item.id, item]));
const manualNodes = new Map(manual.nodes.map((item) => [item.id, item]));
const incomingNodes = new Map(incoming.nodes.map((item) => [item.id, item]));
const nodes: DiagramNode[] = [];
for (const incomingNode of incoming.nodes) {
const baseNode = baseNodes.get(incomingNode.id); const manualNode = manualNodes.get(incomingNode.id);
if (!baseNode) {
if (manualNode && !equal(manualNode, incomingNode)) {
conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$add", base: undefined, manual: structuredClone(manualNode), incoming: structuredClone(incomingNode) });
nodes.push(structuredClone(manualNode));
} else nodes.push(structuredClone(incomingNode));
added.push(incomingNode.id);
continue;
}
if (!manualNode) { nodes.push(structuredClone(incomingNode)); continue; }
const node = structuredClone(incomingNode);
for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramNode>(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts);
if (value === undefined) delete node[field]; else (node as unknown as Record<string, unknown>)[field] = value;
}
if (manualNode.geometry) node.geometry = structuredClone(manualNode.geometry); else delete node.geometry;
if (manualNode.style !== undefined) node.style = manualNode.style; else delete node.style;
if (manualNode.width !== undefined) node.width = manualNode.width;
if (manualNode.height !== undefined) node.height = manualNode.height;
nodes.push(node);
}
for (const manualNode of manual.nodes) {
if (incomingNodes.has(manualNode.id)) continue;
if (!baseNodes.has(manualNode.id)) { nodes.push(structuredClone(manualNode)); continue; }
removed.push(manualNode.id);
if (!prune) nodes.push({ ...structuredClone(manualNode), style: fadedStyle(manualNode.style), properties: { ...(manualNode.properties ?? {}), lifecycleStatus: "removed" } });
}
const keptNodeIds = new Set(nodes.map((node) => node.id));
const baseEdges = new Map(base.edges.map((item) => [item.id, item]));
const manualEdges = new Map(manual.edges.map((item) => [item.id, item]));
const incomingEdges = new Map(incoming.edges.map((item) => [item.id, item]));
const edges: DiagramEdge[] = [];
for (const incomingEdge of incoming.edges) {
if (!keptNodeIds.has(incomingEdge.source) || !keptNodeIds.has(incomingEdge.target)) continue;
const baseEdge = baseEdges.get(incomingEdge.id); const manualEdge = manualEdges.get(incomingEdge.id);
if (!baseEdge) {
if (manualEdge && !equal(manualEdge, incomingEdge)) {
conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$add", base: undefined, manual: structuredClone(manualEdge), incoming: structuredClone(incomingEdge) });
edges.push(structuredClone(manualEdge));
} else edges.push(structuredClone(incomingEdge));
added.push(incomingEdge.id);
continue;
}
if (!manualEdge) { edges.push(structuredClone(incomingEdge)); continue; }
const edge = structuredClone(incomingEdge);
for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) {
const value = conflictValue<DiagramEdge>(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts);
if (value === undefined) delete edge[field]; else (edge as unknown as Record<string, unknown>)[field] = value;
}
if (manualEdge.style !== undefined) edge.style = manualEdge.style; else delete edge.style;
if (manualEdge.waypoints !== undefined) edge.waypoints = structuredClone(manualEdge.waypoints); else delete edge.waypoints;
edges.push(edge);
}
for (const manualEdge of manual.edges) {
if (incomingEdges.has(manualEdge.id)) continue;
if (!baseEdges.has(manualEdge.id)) { if (keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push(structuredClone(manualEdge)); continue; }
removed.push(manualEdge.id);
if (!prune && keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push({ ...structuredClone(manualEdge), style: fadedStyle(manualEdge.style, true), properties: { ...(manualEdge.properties ?? {}), lifecycleStatus: "removed" } });
}
return { ...structuredClone(incoming), nodes, edges, ...(manual.width !== undefined ? { width: manual.width } : {}), ...(manual.height !== undefined ? { height: manual.height } : {}) };
}
export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming: DiagramIRV2, options: { prune?: boolean } = {}): SyncResult {
validateDiagramIR(base); validateDiagramIR(manual); validateDiagramIR(incoming);
const conflicts: SyncConflict[] = []; const added: string[] = []; const removed: string[] = [];
const basePages = new Map(base.pages.map((page) => [page.id, page])); const manualPages = new Map(manual.pages.map((page) => [page.id, page]));
const pages = incoming.pages.map((page) => {
const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id);
if (!basePage || !manualPage) { added.push(page.id); return structuredClone(page); }
return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed);
});
const incomingPageIds = new Set(incoming.pages.map((page) => page.id));
for (const manualPage of manual.pages) {
if (incomingPageIds.has(manualPage.id)) continue;
if (!basePages.has(manualPage.id)) {
if (options.prune !== true) pages.push(structuredClone(manualPage));
continue;
}
removed.push(manualPage.id);
if (options.prune !== true) pages.push({
...structuredClone(manualPage),
properties: { ...(manualPage.properties ?? {}), lifecycleStatus: "removed" },
});
}
const ir: DiagramIRV2 = { ...structuredClone(incoming), pages };
validateDiagramIR(ir);
return { ir, added: [...new Set(added)].sort(), removed: [...new Set(removed)].sort(), conflicts: conflicts.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.id.localeCompare(b.id) || a.field.localeCompare(b.field)) };
}