Fix review findings: importer DoS, sync data loss, views/profile rendering, docs

Source importers: lstat before open so FIFOs no longer hang the directory
walk; replace the quadratic Rust macro regex with a linear scan.
Three-way sync: report page add/add and delete-vs-modify as conflicts
instead of silently overwriting or resurrecting; canonical comparison so
key order is not a change; the sync action withholds output and fails on
conflicts unless --force. Story publishing escapes <, >, & and U+2028/9
inside the embedded JSON.
Views: drop parentId of unselected containers, re-layout instead of
manual geometry, validate before serialising; flatten() merges identical
nodes repeated across pages so C4 output works with every analysis
action. Dark theme edge labels get a background; tube-map corridors sit
above the stations; C4 containers keep the swimlane style and orphan
relationships land on the matching page; router keeps container header
bands as obstacles; sequence self-messages loop on one side.
Docs: SKILL.md lists all 23 CLI actions and how each capability family
is invoked, task wrappers for query/test/what-if/doctor, capability
tables and --page scope corrected, maintenance snippet uses the real
synchronous action signature, duplicated rule bullets moved to the rule
references, coverage matrix wording made verifiable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:01:50 +03:00
co-authored by Claude Code
parent 7610235781
commit 6584df0666
32 changed files with 801 additions and 209 deletions
+5 -3
View File
@@ -12,11 +12,13 @@ tasks:
run:
desc: |
Run any drawio-tools action against a .drawio file.
Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>]
Actions: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation,
Run any drawio-tools action against a .drawio file or Diagram IR model.
Usage: task cli:run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
Authoring: build
Analysis: summary, page-summary, page-hierarchy, page-connectors-summary, page-connectors-validation,
page-labels-validation, page-shape-bbox-validation, page-orphans, page-recommendations,
page-hierarchy-full, page-negative-space-summary, quality, validate
Lifecycle: import, edit, views, query, test, what-if, sync, story, doctor
cmds:
- |
./.scripts/cli/api/run.sh {{ .CLI_ARGS }}
+45 -3
View File
@@ -20,8 +20,8 @@ tasks:
run:
desc: |
Run any drawio-tools action against a .drawio file.
Usage: task run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>]
Run any drawio-tools action against a .drawio file or Diagram IR model (see `task cli:run` for the action list).
Usage: task run -- --file="/path/to/diagram.drawio" --action=<action> [--page <index>] [--output <path>]
cmds:
- task: cli:run
vars:
@@ -62,14 +62,56 @@ tasks:
CLI_ARGS: "--action=views {{ .CLI_ARGS }}"
silent: true
query:
desc: |
Filter a Diagram IR v2 model by kind/property, or find a directed path.
Usage: task query -- --file="model.yaml" --kind=service | --property=k=v | --from=<id> --to=<id>
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=query {{ .CLI_ARGS }}"
silent: true
test:
desc: |
Run built-in architecture policy rules against a Diagram IR v2 model.
Usage: task test -- --file="model.yaml" [--spec="policy-rules.yaml"] [--strict]
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=test {{ .CLI_ARGS }}"
silent: true
what-if:
desc: |
Calculate outgoing failure impact from one node; requires --file and --fail.
Usage: task what-if -- --file="model.yaml" --fail=<node-id>
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=what-if {{ .CLI_ARGS }}"
silent: true
sync:
desc: Three-way stable-ID synchronization; requires --base, --file, and --spec.
desc: |
Three-way stable-ID synchronization; requires --base, --file, and --spec.
Usage: task sync -- --base="before.yaml" --file="edited.yaml" --spec="after.yaml" --output="synced.drawio" [--prune] [--dry-run]
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=sync {{ .CLI_ARGS }}"
silent: true
doctor:
desc: |
Report optional local backend availability without launching anything; no --file needed.
Usage: task doctor
cmds:
- task: cli:run
vars:
CLI_ARGS: "--action=doctor {{ .CLI_ARGS }}"
silent: true
story:
desc: Publish a self-contained accessible offline architecture story.
cmds:
+7 -3
View File
@@ -1,10 +1,14 @@
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> {
export type SyncActionOptions = LifecycleActionOptions & { force?: boolean };
export function run(filePath: string, _page = 0, outputPath?: string, options: SyncActionOptions = {}): 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 };
// Unresolved conflicts fail the action (exit code 1) and block writing unless --force accepts the manual-preferred merge.
const failed = result.conflicts.length > 0 && options.force !== true;
const summary = result.conflicts.length === 0 ? "sync merged without conflicts" : failed ? `${result.conflicts.length} unresolved conflict(s); output not written (resolve them or pass --force to write the manual-preferred merge)` : `${result.conflicts.length} conflict(s) overridden by --force; manual values kept`;
let output: string | undefined; if (!options.dryRun && outputPath && !failed) 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, failed, summary, added: result.added, removed: result.removed, conflicts: result.conflicts, preview: options.dryRun ? result.ir : undefined };
}
+80
View File
@@ -0,0 +1,80 @@
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 { loadGraphStates } from "../../services/maxgraph-loader/graph-loader.js";
import { parseAllPages } from "../../services/drawio-parser/parser.js";
import { projectC4 } from "../../services/profiles/c4.js";
import { run as validate } from "../validate/action.js";
import { run as views } from "./action.js";
const SPEC = `version: 2
pages:
- id: main
title: Main
layout: { type: layered }
nodes:
- { id: zone, label: Zone, kind: container }
- { id: api, label: API, kind: service, parentId: zone, properties: { importance: 9 } }
- { id: worker, label: Worker, kind: service, parentId: zone, properties: { importance: 8 } }
- { id: db, label: DB, kind: database, parentId: zone, properties: { importance: 7 } }
- { id: user, label: User, kind: actor, properties: { importance: 10 } }
edges:
- { id: e1, source: user, target: api }
- { id: e2, source: api, target: worker }
- { id: e3, source: api, target: db, kind: write }
`;
function pageVertexIds(file: string, pageName: string): Set<string> {
const page = parseAllPages(file).find((item) => item.pageName === pageName)!;
return new Set(loadGraphStates(page.graphModelXml).vertexBounds.keys());
}
test("views drop parents that were projected away so every selected vertex renders", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-views-test-"));
const spec = join(dir, "spec.yaml");
const output = join(dir, "views.drawio");
try {
writeFileSync(spec, SPEC, "utf8");
// The security view selects user (actor) and db (database) but not their container "zone".
const result = views(spec, 0, output, { views: "security,dataflow" }) as { views: Array<{ id: string; fallback: boolean }> };
assert.deepEqual(result.views.map((view) => view.id), ["security", "dataflow"]);
assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 });
assert.deepEqual([...pageVertexIds(output, "Security")].sort(), ["db", "user"]);
assert.deepEqual([...pageVertexIds(output, "Dataflow")].sort(), ["api", "db"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("views render C4 profile output whose elements span several pages", () => {
const dir = mkdtempSync(join(tmpdir(), "drawio-views-c4-test-"));
const source = join(dir, "c4.yaml");
const output = join(dir, "views.drawio");
try {
const c4 = projectC4({
title: "Shop",
elements: [
{ id: "customer", label: "Customer", type: "person" },
{ id: "shop", label: "Shop System", type: "system" },
{ id: "web", label: "Web App", type: "container", parentId: "shop" },
{ id: "api", label: "API", type: "container", parentId: "shop" },
{ id: "ctrl", label: "Controller", type: "component", parentId: "api" },
],
relationships: [
{ id: "r1", source: "customer", target: "web", label: "uses" },
{ id: "r2", source: "web", target: "api", label: "calls" },
{ id: "r3", source: "customer", target: "shop", label: "shops" },
],
});
writeFileSync(source, JSON.stringify(c4), "utf8");
views(source, 0, output, { views: "system,executive" });
assert.deepEqual(validate(output).summary, { pages: 2, valid: true, invalidPages: 0 });
assert.deepEqual([...pageVertexIds(output, "System")].sort(), ["api", "ctrl", "customer", "shop", "web"]);
assert.deepEqual([...pageVertexIds(output, "Executive")].sort(), ["api", "ctrl", "customer", "shop", "web"]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+10 -2
View File
@@ -1,5 +1,6 @@
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } 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> {
@@ -10,7 +11,14 @@ export function run(filePath: string, _page = 0, outputPath?: string, options: L
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 } })) };
// A view unions nodes from several source pages, so their page-relative positions cannot coexist: keep only
// the sizes and let the layered layout place everything (waypoints are dropped for the same reason).
const relayout = (node: DiagramNode): DiagramNode => {
const { geometry, ...rest } = node;
return geometry ? { ...rest, width: geometry.width, height: geometry.height } : rest;
};
const ir: DiagramIRV2 = { version: 2, title: source.title, provenance: source.provenance, pages: views.map((view) => ({ id: view.id, title: view.title, nodes: view.nodes.map(relayout), edges: view.edges.map(({ waypoints: _waypoints, ...edge }) => edge), layout: { type: "layered" }, properties: { linkedView: true, sourcePageIds: view.sourcePageIds, fallback: view.fallback, fallbackReason: view.fallbackReason, hint: view.hint } })) };
validateDiagramIR(ir);
const output = atomicWrite(outputPath, diagramIRToDrawio(ir), [filePath]);
return { action: "views", output, views: views.map(({ id, fallback, fallbackReason, hint }) => ({ id, fallback, fallbackReason, hint })) };
}
+5 -3
View File
@@ -29,7 +29,7 @@ import type { LifecycleActionOptions } from "../services/semantic-lifecycle/life
// ---------------------------------------------------------------------------
type ActionModule = {
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions) => Record<string, unknown>;
run: (filePath: string, pageIndex?: number, outputPath?: string, options?: LifecycleActionOptions & { force?: boolean }) => Record<string, unknown>;
};
const ACTIONS: Record<string, () => Promise<ActionModule>> = {
@@ -63,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; spec?: string; base?: string; strict?: boolean; prune?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string };
let values: { file?: string; action?: string; page?: string; output?: string; help?: boolean; spec?: string; base?: string; strict?: boolean; prune?: boolean; force?: boolean; "dry-run"?: boolean; fail?: string; views?: string; kind?: string; property?: string[]; from?: string; to?: string };
try {
({ values } = parseArgs({
args: process.argv.slice(2),
@@ -76,6 +76,7 @@ async function main(): Promise<void> {
base: { type: "string" },
strict: { type: "boolean" },
prune: { type: "boolean" },
force: { type: "boolean" },
"dry-run": { type: "boolean" },
fail: { type: "string" },
views: { type: "string" },
@@ -100,6 +101,7 @@ Lifecycle options:
--base <path> Previous generated model for three-way sync
--strict Treat policy warnings as failures
--prune Remove identities absent from incoming sync model
--force Write sync output despite unresolved conflicts (manual values kept)
--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
@@ -149,7 +151,7 @@ Actions: ${Object.keys(ACTIONS).join(", ")}`);
try {
const mod = await ACTIONS[actionName]();
const result = mod.run(filePath, pageIndex, values.output, {
spec: values.spec, base: values.base, strict: values.strict, prune: values.prune,
spec: values.spec, base: values.base, strict: values.strict, prune: values.prune, force: values.force,
dryRun: values["dry-run"], fail: values.fail, views: values.views, kind: values.kind,
property: values.property, from: values.from, to: values.to,
});
@@ -1,6 +1,11 @@
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 { run as validateConnectors } from "../../actions/page-connectors-validation/action.js";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js";
import { routePageEdges } from "./orthogonal-router.js";
@@ -106,3 +111,61 @@ test("routing resolves nested node geometry to page coordinates", () => {
assert.equal(segmentCrossesRect(path[index], path[index + 1], blocker), false);
}
});
test("routes into and out of an enclosing container avoid its header band", () => {
const page: DiagramPage = {
id: "containers",
title: "Containers",
layout: { type: "layered", direction: "horizontal" },
nodes: [
{ id: "vpc", label: "VPC", kind: "container" },
{ id: "web", label: "Web", parentId: "vpc" },
{ id: "app", label: "App", parentId: "vpc" },
{ id: "db", label: "Database", parentId: "vpc" },
{ id: "user", label: "User" },
{ id: "ext", label: "External Payment Provider", properties: {} },
],
edges: [
{ id: "e1", source: "user", target: "web", label: "HTTPS" },
{ id: "e2", source: "web", target: "app" },
{ id: "e3", source: "app", target: "db", label: "SQL" },
{ id: "e4", source: "app", target: "ext", label: "REST" },
{ id: "e5", source: "user", target: "ext", label: "redirect" },
],
};
const xml = diagramIRToDrawio({ version: 2, pages: [page] });
assert.equal(xml, diagramIRToDrawio({ version: 2, pages: [page] }));
const dir = mkdtempSync(join(tmpdir(), "router-header-band-"));
const file = join(dir, "containers.drawio");
try {
writeFileSync(file, xml, "utf8");
const validation = validateConnectors(file) as { summary: { headerEdgeViolations: number; connectorShapeOverlaps: number }; issues: unknown[] };
assert.equal(validation.summary.headerEdgeViolations, 0, JSON.stringify(validation.issues));
assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("router treats only the header band of an endpoint's ancestor as an obstacle", () => {
const zone: DiagramGeometry = { x: 100, y: 100, width: 400, height: 260 };
const page: DiagramPage = {
id: "band",
title: "Band",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: zone },
{ id: "inside", label: "Inside", parentId: "zone", geometry: { x: 140, y: 120, width: 120, height: 60 } },
{ id: "above", label: "Above", geometry: { x: 140, y: 0, width: 120, height: 40 } },
],
edges: [{ id: "down", source: "above", target: "inside" }],
};
const routed = routePageEdges(page);
// "inside" sits at (240, 220) in page coordinates; the edge runs from the bottom of "above" to its top.
const path = [{ x: 200, y: 40 }, ...routed.edges[0].waypoints!, { x: 300, y: 220 }];
const header: DiagramGeometry = { ...zone, height: 30 };
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], header), false, JSON.stringify(path));
}
assert.ok(path.some((point) => point.y > zone.y + 30 && point.x < zone.x), "route enters the container body from the side rather than through the header");
});
@@ -1,4 +1,4 @@
import type { DiagramEdge, DiagramGeometry, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
import type { DiagramEdge, DiagramGeometry, DiagramNode, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
interface Obstacle extends DiagramGeometry {
id: string;
@@ -102,6 +102,15 @@ function absoluteGeometryById(page: DiagramPage): Map<string, DiagramGeometry> {
return result;
}
// Height of a container's label band. Mirrors ir-to-drawio's default container style (swimlane with
// startSize=30) and draw.io's own swimlane default (40) when a custom style omits startSize.
function headerHeight(node: DiagramNode): number {
if (node.style === undefined) return node.kind === "container" ? 30 : 0;
const explicit = /(?:^|;)startSize=(\d+(?:\.\d+)?)/.exec(node.style);
if (explicit) return Number(explicit[1]);
return /(?:^|;)swimlane(?:;|$)/.test(node.style) ? 40 : 0;
}
function ancestorIds(page: DiagramPage, id: string): Set<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>();
@@ -134,9 +143,16 @@ function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string,
}
const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]);
// Containers enclosing an endpoint may be crossed, except for their header band: a route through the
// label bar (or along its bottom line) merges visually with the container border.
const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id))
.map((node) => ({ id: node.id, ...geometries.get(node.id)! }));
.filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id))
.flatMap((node) => {
const geometry = geometries.get(node.id)!;
if (!endpointAncestors.has(node.id)) return [{ id: node.id, ...geometry }];
const header = headerHeight(node);
return header > 0 ? [{ id: node.id, ...geometry, height: header }] : [];
});
const sourceStub = outwardStub(endpoints.source, source, padding);
const targetStub = outwardStub(endpoints.target, target, padding);
const middleX = snap((sourceStub.x + targetStub.x) / 2);
+27 -1
View File
@@ -1,5 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
import { projectC4 } from "./c4.js";
@@ -39,7 +40,11 @@ test("C4 projects a common model into deterministic linked pages with stable ide
api: ["c4-containers-shop", "c4-components-api"],
orders: ["c4-components-api"],
});
assert.ok(first.pages.flatMap((page) => page.nodes).every((node) => /rounded=0/.test(node.style ?? "")));
for (const node of first.pages.flatMap((page) => page.nodes)) {
if (node.kind === "container") assert.equal(node.style, undefined, `${node.id} must take the swimlane container default`);
else assert.match(node.style ?? "", /rounded=0/);
}
assert.equal(first.pages[1].nodes.find((node) => node.id === "shop")?.kind, "container");
assert.equal(validateDiagramIR(first), first);
});
@@ -131,3 +136,24 @@ test("C4 deterministically retains adversarial cross-level relationships with pr
}), `${relationship.id} has dangling projected endpoints`);
}
});
test("C4 places a container-to-system relationship on the containers page and renders containers as swimlanes", () => {
const model = {
title: "Shop",
elements: [
{ id: "shop", label: "Shop System", type: "system" as const },
{ id: "mail", label: "Mail System", type: "system" as const },
{ id: "api", label: "API", type: "container" as const, parentId: "shop" },
{ id: "ctrl", label: "Controller", type: "component" as const, parentId: "api" },
],
relationships: [{ id: "sends", source: "api", target: "mail", label: "sends" }],
};
const ir = projectC4(model);
assert.deepEqual(ir, projectC4(model));
assert.deepEqual(ir.pages.filter((page) => page.edges.some((edge) => edge.id === "sends")).map((page) => page.id), ["c4-containers-shop"]);
assert.equal(ir.pages.find((page) => page.id === "c4-components-api")?.nodes.some((node) => node.id === "mail"), false);
const xml = diagramIRToDrawio(ir);
assert.match(xml, /id="shop" value="Shop System" style="swimlane;[^"]*startSize=30/);
assert.match(xml, /id="api" value="API" style="swimlane;[^"]*startSize=30/);
assert.match(xml, /id="mail" value="Mail System" style="rounded=0;whiteSpace=wrap;html=1"/);
});
+12 -3
View File
@@ -55,12 +55,14 @@ export function projectC4(model: C4Model): DiagramIRV2 {
? pageFor("c4-containers", element.id)
: element.type === "container" && model.elements.some((item) => item.type === "component" && item.parentId === element.id)
? pageFor("c4-components", element.id) : undefined;
const isContainer = model.elements.some((child) => child.parentId === element.id && included.has(child.id));
return {
id: element.id,
label: element.label,
kind: model.elements.some((child) => child.parentId === element.id && included.has(child.id)) ? "container" : `c4-${element.type}`,
kind: isContainer ? "container" : `c4-${element.type}`,
...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}),
style: "rounded=0;whiteSpace=wrap;html=1",
// Elements shown with their children take the serializer's swimlane default so the label sits in a header band.
...(isContainer ? {} : { style: "rounded=0;whiteSpace=wrap;html=1" }),
properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) },
provenance: cloneRecord(element.provenance),
};
@@ -92,12 +94,19 @@ export function projectC4(model: C4Model): DiagramIRV2 {
if (children.length) pageSpecs.push({ id: pageFor("c4-components", container.id), title: `${container.label} — Components`, elements: [container, ...children] });
}
// Cross-level relationships land on the page that natively shows their deepest endpoint (a container's
// relationship to a system belongs on the containers page, not on the component drill-down).
const levelOf = (type: C4ElementType): number => type === "component" ? 2 : type === "container" ? 1 : 0;
const pageLevel = (id: string): number => levelOf(id.startsWith("c4-components-") ? "component" : id.startsWith("c4-containers-") ? "container" : "system");
for (const relationship of model.relationships) {
if (pageSpecs.some((page) => page.elements.some((element) => element.id === relationship.source)
&& page.elements.some((element) => element.id === relationship.target))) continue;
const [source, target] = [byId.get(relationship.source)!, byId.get(relationship.target)!];
const deepest = levelOf(target.type) > levelOf(source.type) ? target : source;
const candidates = pageSpecs.filter((page) => page.elements.some((element) =>
element.id === relationship.source || element.id === relationship.target));
const page = candidates.at(-1) ?? pageSpecs[0];
const page = candidates.find((candidate) => pageLevel(candidate.id) === levelOf(deepest.type) && candidate.elements.some((element) => element.id === deepest.id))
?? candidates.at(-1) ?? pageSpecs[0];
const included = new Set(page.elements.map((element) => element.id));
for (const endpoint of [relationship.source, relationship.target]) {
if (!included.has(endpoint)) {
@@ -131,3 +131,37 @@ test("sequence page width includes nested self-call waypoint extents plus margin
assert.equal(waypointRight, 530);
assert.ok(ir.pages[0].width! >= waypointRight + 40);
});
test("self-messages loop on the right of the lifeline and never cross the activation bar", () => {
const input = {
title: "Login",
participants: [{ id: "u", label: "User" }, { id: "w", label: "Web" }, { id: "a", label: "Auth" }],
messages: [
{ id: "m1", from: "u", to: "w", label: "login" },
{ id: "m2", from: "w", to: "a", label: "verify" },
{ id: "m3", from: "a", to: "a", label: "hash" },
{ id: "m4", from: "a", to: "a", label: "done", type: "return" as const },
{ id: "m5", from: "a", to: "w", label: "ok", type: "return" as const },
{ id: "m6", from: "w", to: "u", label: "token", type: "return" as const },
],
};
const ir = createSequenceDiagram(input);
const lifelineX = 40 + 2 * 200 + 60;
for (const id of ["m3", "m4"]) {
const source = ir.pages[0].nodes.find((node) => node.id === `${id}-source-anchor`)!.geometry!;
const target = ir.pages[0].nodes.find((node) => node.id === `${id}-target-anchor`)!.geometry!;
const edge = ir.pages[0].edges.find((edge) => edge.id === id)!;
assert.ok(source.x > lifelineX && target.x > lifelineX, `${id} anchors must both sit right of the lifeline`);
assert.equal(target.y, source.y + 30, `${id} returns 30px below its departure`);
assert.ok(edge.waypoints!.every((point) => point.x > lifelineX), `${id} waypoints stay on the right side`);
}
const dir = mkdtempSync(join(tmpdir(), "sequence-self-message-"));
const file = join(dir, "self.drawio");
try {
writeFileSync(file, diagramIRToDrawio(ir), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number }; issues: unknown[] };
assert.equal(validation.summary.connectorShapeOverlaps, 0, JSON.stringify(validation.issues));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+7 -3
View File
@@ -66,12 +66,16 @@ export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 {
const fromX = xById.get(message.from)! + 60;
const toX = xById.get(message.to)! + 60;
const direction = Math.sign(toX - fromX) || 1;
const self = message.from === message.to;
const clearX = (id: string, centerX: number, side: number) => centerX + side * (10 + maxDepthById.get(id)! * 10);
const sourceX = clearX(message.from, fromX, direction);
const targetX = clearX(message.to, toX, -direction);
// A self-message loops out to the right and returns 30px lower on the same side, so its target
// anchor sits right of the lifeline too; otherwise the return leg would cross the activation bar.
const targetX = clearX(message.to, toX, self ? direction : -direction);
const targetY = self ? y + 30 : y;
return [
{ id: `${message.id}-source-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: sourceX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.from, messageId: message.id, role: "source" } },
{ id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: y - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } },
{ id: `${message.id}-target-anchor`, label: "\u200B", kind: "sequence-message-anchor", geometry: { x: targetX - 1, y: targetY - 1, width: 2, height: 2 }, style: "opacity=0;fillOpacity=0;strokeOpacity=0;connectable=1", properties: { participantId: message.to, messageId: message.id, role: "target" } },
];
});
const nodes = [...participantNodes, ...lifelineNodes, ...activationNodes, ...anchorNodes];
@@ -82,7 +86,7 @@ export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 {
const direction = Math.sign(toX - fromX) || 1;
const sourceX = fromX + direction * (10 + maxDepthById.get(message.from)! * 10);
const targetX = toX - direction * (10 + maxDepthById.get(message.to)! * 10);
const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }, { x: targetX, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }];
const waypoints = message.from === message.to ? [{ x: sourceX + 60, y }, { x: sourceX + 60, y: y + 30 }] : [{ x: sourceX, y }, { x: targetX, y }];
return { id: message.id, source: `${message.id}-source-anchor`, target: `${message.id}-target-anchor`, label: message.label, kind: `sequence-${message.type ?? "call"}`, waypoints, properties: { sequence: index + 1, activationDepth: messageDepths[index], semanticSource: message.from, semanticTarget: message.to }, provenance: message.provenance === undefined ? undefined : structuredClone(message.provenance) };
});
const activationRight = Math.max(0, ...activationNodes.map((node) => node.geometry.x + node.geometry.width));
@@ -119,3 +119,31 @@ test("tube lines sharing stations have distinct complete routes including statio
rmSync(dir, { recursive: true, force: true });
}
});
test("five or more line corridors stay above the first station row and never cross station boxes", () => {
const ir = createTubeMap({
title: "Five lines",
stations: ["s1", "s2", "s3", "s4", "s5", "s6"].map((id) => ({ id, label: id.toUpperCase() })),
lines: [
{ id: "l1", label: "L1", color: "#dd0000", stations: ["s1", "s2", "s3"] },
{ id: "l2", label: "L2", color: "#0000dd", stations: ["s2", "s4"] },
{ id: "l3", label: "L3", color: "#00aa00", stations: ["s5", "s6"] },
{ id: "l4", label: "L4", color: "#ccaa00", stations: ["s6", "s3"] },
{ id: "l5", label: "L5", color: "#aa00aa", stations: ["s1", "s4"] },
],
});
const topStation = Math.min(...ir.pages[0].nodes.map((node) => node.geometry!.y));
for (const edge of ir.pages[0].edges) {
const corridor = edge.waypoints!.find((point, index, points) => index > 0 && point.y === points[index - 1].y)!;
assert.ok(corridor.y < topStation, `corridor ${corridor.y} of ${edge.id} runs through the first station row (top ${topStation})`);
}
const dir = mkdtempSync(join(tmpdir(), "tube-map-five-lines-"));
const file = join(dir, "five.drawio");
try {
writeFileSync(file, diagramIRToDrawio(ir), "utf8");
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
assert.equal(validation.summary.connectorShapeOverlaps, 0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+5 -2
View File
@@ -22,9 +22,12 @@ export function createTubeMap(input: TubeMapInput): DiagramIRV2 {
memberships.forEach((lines) => lines.sort());
const firstLine = new Map<string, number>();
lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); }));
// Every line owns a horizontal corridor at 40 + 20*i; the first station row sits below all of them
// so corridors never run through station boxes however many lines there are.
const corridorBand = 20 * lines.length;
const centers = new Map<string, DiagramPoint>();
const nodes = stations.map((station, index) => {
const center = { x: 100 + index * 160, y: 100 + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center);
const center = { x: 100 + index * 160, y: 100 + corridorBand + (firstLine.get(station.id) ?? lines.length) * 160 }; centers.set(station.id, center);
const stationLines = memberships.get(station.id)!;
return { id: station.id, label: station.label, kind: stationLines.length > 1 ? "tube-interchange" : "tube-station", geometry: { x: center.x - 20, y: center.y - 20, width: 40, height: 40 }, style: `ellipse;whiteSpace=wrap;html=1;strokeWidth=${stationLines.length > 1 ? 6 : 3}`, properties: { interchange: stationLines.length > 1, lines: stationLines }, provenance: station.provenance === undefined ? undefined : structuredClone(station.provenance) };
});
@@ -45,6 +48,6 @@ export function createTubeMap(input: TubeMapInput): DiagramIRV2 {
];
return { id: `${line.id}-segment-${index + 1}`, source, target, kind: "tube-line", style: `edgeStyle=none;rounded=0;strokeColor=${line.color};strokeWidth=8;endArrow=none`, waypoints, properties: { lineId: line.id, lineLabel: line.label, segment: index + 1 }, provenance: line.provenance === undefined ? undefined : structuredClone(line.provenance) };
}));
const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } };
const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "tube-map", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: Math.max(320, stations.length * 160 + 40), height: Math.max(320, (lines.length + 1) * 160 + corridorBand), properties: { profile: "tube-map", lines: lines.map((line) => ({ id: line.id, label: line.label, color: line.color })) } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "tube-map" } };
return validateDiagramIR(ir) as DiagramIRV2;
}
@@ -56,6 +56,48 @@ test("what-if outgoing reachability stops at failure-isolating edges", () => {
assert.throws(() => simulateFailure(IR, "missing"), /unknown node/i);
});
test("semantic operations merge an element projected onto several pages", () => {
const shared: DiagramIRV2 = {
version: 2,
pages: [
{ id: "context", title: "Context", nodes: [
{ id: "shop", label: "Shop", kind: "c4-system", properties: { c4Type: "system" }, provenance: { line: 2 } },
{ id: "buyer", label: "Buyer", kind: "actor" },
], edges: [{ id: "uses", source: "buyer", target: "shop" }] },
{ id: "containers", title: "Containers", nodes: [
{ id: "shop", label: "Shop", kind: "container", properties: { c4Type: "system" }, provenance: { line: 2 } },
{ id: "buyer", label: "Buyer", kind: "actor" },
{ id: "api", label: "API", kind: "service", parentId: "shop" },
], edges: [{ id: "uses", source: "buyer", target: "shop" }, { id: "calls", source: "buyer", target: "api" }] },
],
};
const system = projectLinkedViews(shared, ["system"])[0];
assert.deepEqual(system.nodes.map((node) => node.id), ["shop", "buyer", "api"]);
assert.equal(system.nodes.find((node) => node.id === "shop")?.kind, "c4-system", "specific kind wins over the presentational container promotion");
assert.equal(system.nodes.find((node) => node.id === "api")?.parentId, undefined, "parent that is not a container in the merged model is dropped");
assert.deepEqual(system.edges.map((edge) => edge.id), ["uses", "calls"]);
assert.deepEqual(queryDiagram(shared, { from: "buyer", to: "api" }).path, ["buyer", "api"]);
assert.equal(runPolicies(shared, ["no-orphans"]).findings.length, 0);
assert.deepEqual(simulateFailure(shared, "buyer").impacted, ["api", "shop"]);
assert.throws(() => queryDiagram({ ...shared, pages: [shared.pages[0], { ...shared.pages[1], edges: [{ id: "uses", source: "api", target: "shop" }] }] }, {}), /ambiguous.*edge.*uses/i);
});
test("views keep container parents that are part of the selection and detach the rest", () => {
const nested: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [
{ id: "outer", label: "Outer", kind: "container", geometry: { x: 100, y: 100, width: 400, height: 300 }, properties: { importance: 5 } },
{ id: "inner", label: "Inner", kind: "container", parentId: "outer", geometry: { x: 20, y: 40, width: 200, height: 150 } },
{ id: "leaf", label: "Leaf", kind: "service", parentId: "inner", geometry: { x: 10, y: 50, width: 120, height: 60 }, properties: { importance: 9 } },
], edges: [] }] };
const [executive] = projectLinkedViews({ ...nested, pages: [{ ...nested.pages[0], nodes: nested.pages[0].nodes.slice(0, 2).concat(Array.from({ length: 11 }, (_, index) => ({ id: `n${index}`, label: `N${index}`, properties: { importance: 8 } })), nested.pages[0].nodes.slice(2)) }] }, ["executive"]);
const leaf = executive.nodes.find((node) => node.id === "leaf")!;
assert.equal(executive.nodes.some((node) => node.id === "inner"), false);
assert.equal(leaf.parentId, undefined);
assert.deepEqual(leaf.geometry, { x: 130, y: 190, width: 120, height: 60 });
const [system] = projectLinkedViews(nested, ["system"]);
assert.equal(system.nodes.find((node) => node.id === "leaf")?.parentId, "inner");
assert.deepEqual(system.nodes.find((node) => node.id === "leaf")?.geometry, { x: 10, y: 50, width: 120, height: 60 });
});
test("semantic operations fail closed on ambiguous page-local IDs", () => {
const ambiguous: DiagramIRV2 = {
version: 2,
@@ -8,20 +8,72 @@ export interface QueryResult { nodes: DiagramNode[]; edges: DiagramEdge[]; path?
export interface PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string }
export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] }
function stableJson(value: unknown): string {
if (value === undefined) return "null";
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);
}
// A semantic element may legitimately be projected onto several pages (the C4 profile does this).
// Projections agree on identity (label, properties, provenance, extensions); only presentation may differ:
// a page may promote the element to kind "container" when its children are shown there, and parentId
// depends on whether the parent is projected onto that page. Anything else is a genuine conflict.
function mergeNode(existing: DiagramNode, incoming: DiagramNode, pageIds: string[]): DiagramNode {
const conflict = (): never => { throw new Error(`Ambiguous semantic node ID ${existing.id} appears on pages ${pageIds.join(", ")} with conflicting definitions`); };
if (existing.label !== incoming.label) conflict();
for (const key of ["properties", "provenance", "extensions"] as const) if (stableJson(existing[key]) !== stableJson(incoming[key])) conflict();
let kind = existing.kind;
if (existing.kind !== incoming.kind) {
if (existing.kind === "container") kind = incoming.kind;
else if (incoming.kind !== "container") conflict();
}
return { ...existing, ...(kind === undefined ? {} : { kind }), ...(existing.parentId === undefined && incoming.parentId !== undefined ? { parentId: incoming.parentId } : {}) };
}
function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir);
const pagesByNodeId = new Map<string, string[]>();
const nodes = new Map<string, DiagramNode>();
const edges = new Map<string, DiagramEdge>();
for (const page of ir.pages) {
for (const node of page.nodes) pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
for (const node of page.nodes) {
const pageIds = [...(pagesByNodeId.get(node.id) ?? []), page.id];
pagesByNodeId.set(node.id, pageIds);
const existing = nodes.get(node.id);
nodes.set(node.id, existing ? mergeNode(existing, node, pageIds) : node);
}
for (const edge of page.edges) {
const existing = edges.get(edge.id);
if (!existing) { edges.set(edge.id, edge); continue; }
if (existing.source !== edge.source || existing.target !== edge.target) throw new Error(`Ambiguous semantic edge ID ${edge.id} connects different endpoints on different pages`);
}
}
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) };
return { nodes: [...nodes.values()], edges: [...edges.values()] };
}
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)) };
const byId = new Map(nodes.map((node) => [node.id, node]));
const keepsParent = (node: DiagramNode): boolean => node.parentId !== undefined && selected.has(node.parentId) && byId.get(node.parentId)?.kind === "container";
// Geometry is relative to the parent; when the parent is projected away, re-express it in page coordinates.
const detach = (node: DiagramNode): DiagramNode => {
const { parentId, ...rest } = node;
if (!rest.geometry) return rest;
let offset = { x: 0, y: 0 };
const seen = new Set<string>();
for (let current = parentId ? byId.get(parentId) : undefined; current && !seen.has(current.id); current = current.parentId ? byId.get(current.parentId) : undefined) {
seen.add(current.id);
offset = { x: offset.x + (current.geometry?.x ?? 0), y: offset.y + (current.geometry?.y ?? 0) };
}
return { ...rest, geometry: { ...rest.geometry, x: rest.geometry.x + offset.x, y: rest.geometry.y + offset.y } };
};
return {
nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(keepsParent(node) ? node : detach(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[] {
@@ -44,3 +44,12 @@ test("story fails closed on ambiguous multi-page node IDs", () => {
};
assert.throws(() => createStoryHtml(ambiguous), /ambiguous.*shared.*one.*two/i);
});
test("story HTML escapes markup-significant characters inside the embedded JSON so labels cannot break out of <script>", () => {
const label = "<!--<script>&</script>
x";
const html = createStoryHtml({ version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "a", label }], edges: [], layout: { type: "manual" } }] });
const blob = /<script>const STEPS=(.*?);let i=-1;/s.exec(html)![1];
assert.doesNotMatch(blob, /[<>&\u2028\u2029]/);
assert.equal(html.indexOf("</script>"), html.lastIndexOf("</script>"));
assert.deepEqual((JSON.parse(blob) as Array<{ label: string }>).map((step) => step.label), [label]);
});
@@ -26,7 +26,8 @@ export function createStoryHtml(ir: DiagramIRV2, options: StoryOptions = {}): st
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("</", "<\\/");
// JSON inside <script> must not contain markup-significant characters (`<!--`, `<script`, `</script`) or JS line terminators.
const data = stableJson(steps).replace(/[<>&\u2028\u2029]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`);
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>`;
}
@@ -1,4 +1,7 @@
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
import { syncDiagramIR } from "./sync.js";
@@ -90,3 +93,52 @@ test("sync retains removed pages unless pruning is explicit", () => {
assert.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false);
assert.ok(pruned.removed.includes("legacy"));
});
const page = (id: string, nodes: DiagramIRV2["pages"][number]["nodes"]): DiagramIRV2["pages"][number] => ({ id, title: id, nodes, edges: [], layout: { type: "manual" } });
test("page-level add/add is reported as an $add conflict and manual content is kept", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "m1", label: "manual-only work" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }]), page("p2", [{ id: "g1", label: "generated" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.pageId, c.id, c.field]), [["p2", "p2", "$add"]]);
assert.deepEqual(result.ir.pages.find((p) => p.id === "p2")!.nodes.map((n) => n.id), ["m1"]);
assert.deepEqual(syncDiagramIR(b, manual, manual).conflicts, []);
});
test("manual delete versus incoming modify yields a $delete conflict instead of resurrecting the node", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A" }, { id: "k", label: "K" }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "k", label: "K" }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A-renamed" }, { id: "k", label: "K" }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts.map((c) => [c.id, c.field, c.manual, (c.incoming as { label: string }).label]), [["a", "$delete", undefined, "A-renamed"]]);
assert.deepEqual(result.ir.pages[0].nodes.map((n) => n.id), ["k"]);
assert.deepEqual(syncDiagramIR(b, manual, b).conflicts, []);
});
test("object key order is not a change: equality uses canonical serialization", () => {
const b: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 2 } }])] };
const manual: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { y: 2, x: 1 } }])] };
const incoming: DiagramIRV2 = { version: 2, pages: [page("p1", [{ id: "a", label: "A", properties: { x: 1, y: 3 } }])] };
const result = syncDiagramIR(b, manual, incoming);
assert.deepEqual(result.conflicts, []);
assert.deepEqual(result.ir.pages[0].nodes[0].properties, { x: 1, y: 3 });
});
test("sync action fails and withholds output on conflicts unless --force is given", async () => {
const { run } = await import("../../actions/sync/action.js");
const dir = mkdtempSync(join(tmpdir(), "sync-action-"));
try {
const write = (name: string, ir: DiagramIRV2) => { const p = join(dir, name); writeFileSync(p, JSON.stringify(ir), "utf8"); return p; };
const b = write("base.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A" }])] });
const manual = write("manual.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A manual" }])] });
const incoming = write("incoming.json", { version: 2, pages: [page("p1", [{ id: "a", label: "A incoming" }])] });
const out = join(dir, "out.json");
const blocked = run(manual, 0, out, { base: b, spec: incoming });
assert.equal(blocked.failed, true); assert.equal(blocked.output, undefined); assert.equal(existsSync(out), false); assert.match(String(blocked.summary), /conflict/);
const forced = run(manual, 0, out, { base: b, spec: incoming, force: true });
assert.equal(forced.failed, false); assert.equal(existsSync(out), true); assert.equal((forced.conflicts as unknown[]).length, 1);
const clean = run(manual, 0, out, { base: b, spec: manual });
assert.equal(clean.failed, false); assert.equal(clean.output, out);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
@@ -4,7 +4,13 @@ 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);
/** Canonical serialization with sorted object keys so key order alone never counts as a change. */
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") { const record = value as Record<string, unknown>; return `{${Object.keys(record).filter((key) => record[key] !== undefined).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; }
return String(JSON.stringify(value));
}
const equal = (a: unknown, b: unknown): boolean => canonical(a) === canonical(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];
@@ -32,7 +38,11 @@ function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage,
added.push(incomingNode.id);
continue;
}
if (!manualNode) { nodes.push(structuredClone(incomingNode)); continue; }
if (!manualNode) {
// Manual deleted a node that incoming changed since base: a delete/modify conflict, not a silent resurrection.
if (!equal(incomingNode, baseNode)) { conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$delete", base: structuredClone(baseNode), manual: undefined, incoming: structuredClone(incomingNode) }); continue; }
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);
@@ -66,7 +76,10 @@ function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage,
added.push(incomingEdge.id);
continue;
}
if (!manualEdge) { edges.push(structuredClone(incomingEdge)); continue; }
if (!manualEdge) {
if (!equal(incomingEdge, baseEdge)) { conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$delete", base: structuredClone(baseEdge), manual: undefined, incoming: structuredClone(incomingEdge) }); continue; }
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);
@@ -91,7 +104,14 @@ export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming:
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); }
if (!manualPage) { added.push(page.id); return structuredClone(page); }
if (!basePage) {
// Page add/add: both sides introduced the same page ID independently; keep manual and report it like node-level `$add`.
added.push(page.id);
if (equal(manualPage, page)) return structuredClone(page);
conflicts.push({ pageId: page.id, id: page.id, field: "$add", base: undefined, manual: structuredClone(manualPage), incoming: structuredClone(page) });
return structuredClone(manualPage);
}
return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed);
});
const incomingPageIds = new Set(incoming.pages.map((page) => page.id));
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -383,3 +384,22 @@ test("loads YAML data without permitting custom tags", async () => {
assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Service/default/api"]);
await assert.rejects(() => importSource({ sourceKind: "ci", path: "tagged.yaml", input: { type: "file", root } }), /tag|malformed YAML/i);
});
test("rejects a FIFO inside a source directory promptly instead of blocking on open()", async () => {
const root = await mkdtemp(join(tmpdir(), "source-fifo-"));
await writeFile(join(root, "a.py"), "import os\n");
const made = spawnSync("mkfifo", [join(root, "pipe")]);
if (made.status !== 0) return; // platform without mkfifo: nothing to regress
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("importSource hung on the FIFO")), 5_000).unref());
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: ".", input: { type: "file", root } }), timeout]), /unsupported entry type/);
await assert.rejects(() => Promise.race([importSource({ sourceKind: "python", path: "pipe", input: { type: "file", root } }), timeout]), /unsupported entry type/);
});
test("Rust macro detection is linear on long lines and still flags macro invocations", async () => {
const started = performance.now();
const long = await textInput("rust", "long.rs", `fn main() {}\n${"a".repeat(200_000)}\n`);
assert.ok(performance.now() - started < 200, `took ${(performance.now() - started).toFixed(0)}ms`);
assert.deepEqual(long.diagnostics.filter((d) => /macros/.test(d.message)), []);
const macros = await textInput("rust", "macros.rs", "println!(\"hi\");\nfoo! (1);\nlet x = a != b;\n! (not);\n");
assert.deepEqual(macros.diagnostics.filter((d) => /macros/.test(d.message)).map((d) => d.line), [1, 2]);
});
+19 -3
View File
@@ -1,5 +1,5 @@
import { constants } from "node:fs";
import { open, readdir, realpath, stat } from "node:fs/promises";
import { lstat, open, readdir, realpath, stat } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { isAbsolute, relative, resolve } from "node:path";
@@ -610,7 +610,7 @@ async function importInline(request: SourceImportRequest): Promise<SourceImportR
? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => {
const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1];
const use = line.match(/^\s*use\s+([^;]+);/)?.[1];
if (/\w+!\s*\(/.test(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 });
if (hasRustMacroCall(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 });
if (mod) return [{ name: mod, line: index + 1 }];
if (use) {
const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || use;
@@ -629,15 +629,31 @@ async function importInline(request: SourceImportRequest): Promise<SourceImportR
return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } };
}
/** Linear-time equivalent of `/\w+!\s*\(/` (the regex backtracks quadratically on long identifier runs). */
function hasRustMacroCall(line: string): boolean {
for (let bang = line.indexOf("!"); bang >= 0; bang = line.indexOf("!", bang + 1)) {
if (bang === 0 || !/\w/.test(line[bang - 1])) continue;
let next = bang + 1;
while (next < line.length && /\s/.test(line[next])) next++;
if (line[next] === "(") return true;
}
return false;
}
function isConfined(root: string, candidate: string): boolean {
const pathFromRoot = relative(root, candidate);
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
}
async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> {
// Inspect before open(): opening a FIFO (or a device) blocks indefinitely, so only regular files and
// directories may reach open(); O_NONBLOCK additionally keeps a FIFO swapped in after lstat from hanging.
const kind = await lstat(candidate);
if (kind.isSymbolicLink()) throw new Error("Source symlink escape risk cannot be opened safely");
if (!kind.isFile() && !kind.isDirectory()) throw new Error("Source directory contains an unsupported entry type");
let handle: FileHandle;
try {
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW);
handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely");
throw error;
@@ -132,3 +132,18 @@ test("theme validation rejects invalid colors and insufficient contrast", () =>
lowContrast.node.fillColor = "#777777";
assert.throws(() => validateTheme(lowContrast), /contrast/i);
});
test("themed edges carry the canvas colour as label background exactly once so labels do not render as boxes", () => {
const source = structuredClone(IR);
source.pages[0].edges[0].style = "edgeStyle=orthogonalEdgeStyle;labelBackgroundColor=#FFFFFF;labelBackgroundColor=#EEEEEE;";
for (const name of ["dark", "corporate"] as const) {
const themed = applyTheme(source, name);
const edgeStyle = themed.pages[0].edges[0].style!;
const tokens = edgeStyle.split(";").filter((token) => token.startsWith("labelBackgroundColor="));
assert.deepEqual(tokens, [`labelBackgroundColor=${BUILT_IN_THEMES[name].backgroundColor}`], edgeStyle);
for (const node of themed.pages[0].nodes) assert.doesNotMatch(node.style!, /labelBackgroundColor/);
}
const untouched = applyTheme(IR, "dark").pages[0].edges[0].style!;
assert.equal(untouched.split(";").filter((token) => token.startsWith("labelBackgroundColor=")).length, 1, untouched);
assert.match(untouched, /labelBackgroundColor=#111827;/);
});
+6 -4
View File
@@ -81,11 +81,13 @@ function parseStyle(style = ""): StyleEntry[] {
});
}
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string): string {
function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string, labelBackgroundColor?: string): string {
const entries = parseStyle(style);
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
const visual = new Map<string, string>();
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
// Edge labels are drawn on the canvas, so their backdrop must follow the canvas colour or they render as boxes.
if (labelBackgroundColor) visual.set("labelBackgroundColor", labelBackgroundColor);
if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor);
if (colors.fontColor) {
let fontColor = colors.fontColor;
@@ -95,7 +97,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.set("fontColor", fontColor);
}
visual.set("sketch", sketch ? "1" : "0");
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]);
const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]);
const handled = new Set<string>();
const output = entries.flatMap((entry) => {
if (!visualKeys.has(entry.key)) return [entry];
@@ -106,7 +108,7 @@ function themedStyle(style: string | undefined, colors: Partial<ThemeRoleStyle>,
visual.delete(entry.key);
return [{ key: entry.key, value: replacement }];
});
for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) {
for (const key of ["fillColor", "strokeColor", "fontColor", "labelBackgroundColor", "sketch"]) {
const value = visual.get(key);
if (value !== undefined) output.push({ key, value });
}
@@ -122,7 +124,7 @@ export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDe
...node,
style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor),
});
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) });
const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true, undefined, selectedTheme.backgroundColor) });
return {
...isolated,
theme: selectedTheme.name,