[verified] feat: add semantic diagram lifecycle
This commit is contained in:
@@ -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="{"owner":"team-a"}" data-provenance="{"source":"catalog"}" data-extensions="{"x":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 <primary>/);
|
||||
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); }
|
||||
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)) };
|
||||
}
|
||||
Reference in New Issue
Block a user