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:
@@ -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);
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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;/);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user