feat: add integrated diagram lifecycle and capability toolbox #2
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { createBuildup } from "./buildup.js";
|
||||
|
||||
const PAGE: DiagramPage = { id: "deploy", title: "Deploy", nodes: [{ id: "db", label: "DB", provenance: { source: "infra" } }, { id: "api", label: "API" }, { id: "web", label: "Web" }], edges: [{ id: "db-api", source: "db", target: "api" }, { id: "api-web", source: "api", target: "web" }] };
|
||||
|
||||
test("buildup uses deterministic topological order and valid cumulative IR frames", () => {
|
||||
const before = structuredClone(PAGE); const result = createBuildup(PAGE);
|
||||
assert.deepEqual(result, createBuildup(PAGE)); assert.deepEqual(PAGE, before); assert.deepEqual(result.order, ["db", "api", "web"]); assert.deepEqual(result.cycleFallback, []);
|
||||
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
|
||||
assert.deepEqual(result.frames.map((frame) => frame.pages[0].nodes.map((node) => node.id)), [["db"], ["db", "api"], ["db", "api", "web"]]);
|
||||
assert.equal(result.frames[0].pages[0].nodes[0].provenance?.source, "infra");
|
||||
});
|
||||
|
||||
test("buildup explicitly falls back to stable ID order for cycles", () => {
|
||||
const cyclic: DiagramPage = { id: "cycle", title: "Cycle", nodes: [{ id: "b", label: "B" }, { id: "a", label: "A" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "ba", source: "b", target: "a" }] };
|
||||
const result = createBuildup(cyclic); assert.deepEqual(result.order, ["a", "b"]); assert.deepEqual(result.cycleFallback, ["a", "b"]);
|
||||
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
|
||||
});
|
||||
|
||||
test("buildup cycle fallback keeps containers before children so every frame remains valid", () => {
|
||||
const cyclic: DiagramPage = { id: "nested", title: "Nested", nodes: [{ id: "a-child", label: "Child", parentId: "z-parent" }, { id: "z-parent", label: "Parent", kind: "container" }], edges: [{ id: "reverse", source: "a-child", target: "z-parent" }] };
|
||||
const result = createBuildup(cyclic);
|
||||
assert.deepEqual(result.order, ["z-parent", "a-child"]);
|
||||
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame), frame));
|
||||
});
|
||||
|
||||
test("buildup rejects empty and invalid input", () => {
|
||||
assert.throws(() => createBuildup({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
|
||||
assert.throws(() => createBuildup({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "x", target: "a" }] }), /unknown source/i);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface BuildupResult { order: string[]; cycleFallback: string[]; frames: DiagramIRV2[] }
|
||||
|
||||
export function createBuildup(page: DiagramPage): BuildupResult {
|
||||
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
|
||||
const ids = page.nodes.map((node) => node.id);
|
||||
const adjacency = new Map(ids.map((id) => [id, new Set<string>()]));
|
||||
const indegree = new Map(ids.map((id) => [id, 0]));
|
||||
const addDependency = (source: string, target: string) => {
|
||||
const next = adjacency.get(source)!; if (next.has(target)) return; next.add(target); indegree.set(target, indegree.get(target)! + 1);
|
||||
};
|
||||
page.edges.forEach((edge) => addDependency(edge.source, edge.target));
|
||||
page.nodes.forEach((node) => { if (node.parentId) addDependency(node.parentId, node.id); });
|
||||
const ready = ids.filter((id) => indegree.get(id) === 0).sort(); const order: string[] = [];
|
||||
while (ready.length) {
|
||||
const id = ready.shift()!; order.push(id);
|
||||
for (const target of [...adjacency.get(id)!].sort()) { indegree.set(target, indegree.get(target)! - 1); if (indegree.get(target) === 0) { ready.push(target); ready.sort(); } }
|
||||
}
|
||||
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
|
||||
const parentDepth = (id: string): number => {
|
||||
let depth = 0; let current = nodeById.get(id)?.parentId;
|
||||
while (current) { depth += 1; current = nodeById.get(current)?.parentId; }
|
||||
return depth;
|
||||
};
|
||||
const cycleFallback = ids.filter((id) => !order.includes(id)).sort((a, b) => parentDepth(a) - parentDepth(b) || a.localeCompare(b));
|
||||
order.push(...cycleFallback);
|
||||
const frames = order.map((_, index) => {
|
||||
const includedOrder = order.slice(0, index + 1); const included = new Set(includedOrder);
|
||||
const framePage: DiagramPage = { ...structuredClone(page), id: `${page.id}-step-${String(index + 1).padStart(3, "0")}`, title: `${page.title} — Step ${index + 1}`, nodes: includedOrder.map((id) => structuredClone(nodeById.get(id)!)), edges: page.edges.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => structuredClone(edge)), properties: { ...(page.properties ?? {}), profile: "buildup", step: index + 1, addedNodeId: order[index], cycleFallback: cycleFallback.includes(order[index]) } };
|
||||
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [framePage], properties: { profile: "buildup", order: [...order], cycleFallback: [...cycleFallback] } };
|
||||
return validateDiagramIR(ir) as DiagramIRV2;
|
||||
});
|
||||
return { order, cycleFallback, frames };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { projectC4 } from "./c4.js";
|
||||
|
||||
const MODEL = {
|
||||
title: "Shop",
|
||||
provenance: { source: "architecture.yaml" },
|
||||
elements: [
|
||||
{ id: "buyer", label: "Buyer", type: "person" as const },
|
||||
{ id: "shop", label: "Shop", type: "system" as const, provenance: { line: 2 } },
|
||||
{ id: "api", label: "API", type: "container" as const, parentId: "shop" },
|
||||
{ id: "orders", label: "Orders", type: "component" as const, parentId: "api" },
|
||||
],
|
||||
relationships: [
|
||||
{ id: "uses", source: "buyer", target: "shop", label: "Uses" },
|
||||
{ id: "contains-api", source: "shop", target: "api", label: "Contains" },
|
||||
{ id: "contains-orders", source: "api", target: "orders", label: "Contains" },
|
||||
],
|
||||
};
|
||||
|
||||
test("C4 projects a common model into deterministic linked pages with stable identity", () => {
|
||||
const before = structuredClone(MODEL);
|
||||
const first = projectC4(MODEL);
|
||||
assert.deepEqual(first, projectC4(MODEL));
|
||||
assert.deepEqual(MODEL, before);
|
||||
assert.deepEqual(first.pages.map((page) => page.id), ["c4-context", "c4-containers-shop", "c4-components-api"]);
|
||||
assert.ok(first.pages[0].nodes.some((node) => node.id === "shop"));
|
||||
assert.ok(first.pages[1].nodes.some((node) => node.id === "api"));
|
||||
assert.ok(first.pages[2].nodes.some((node) => node.id === "orders"));
|
||||
assert.deepEqual(first.pages[0].nodes.find((node) => node.id === "shop")?.provenance, { line: 2 });
|
||||
assert.equal(first.pages[0].nodes.find((node) => node.id === "shop")?.properties?.drillDownPage, "c4-containers-shop");
|
||||
assert.deepEqual(Object.fromEntries(MODEL.elements.map((element) => [
|
||||
element.id,
|
||||
first.pages.filter((page) => page.nodes.some((node) => node.id === element.id)).map((page) => page.id),
|
||||
])), {
|
||||
buyer: ["c4-context"],
|
||||
shop: ["c4-context", "c4-containers-shop"],
|
||||
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 ?? "")));
|
||||
assert.equal(validateDiagramIR(first), first);
|
||||
});
|
||||
|
||||
test("C4 rejects empty and structurally invalid models", () => {
|
||||
assert.throws(() => projectC4({ title: "Empty", elements: [], relationships: [] }), /element/i);
|
||||
assert.throws(() => projectC4({ title: "Bad", elements: [{ id: "child", label: "Child", type: "component", parentId: "missing" }], relationships: [] }), /parent/i);
|
||||
});
|
||||
|
||||
test("C4 rejects unsupported runtime element types", () => {
|
||||
assert.throws(() => projectC4({
|
||||
title: "Unsupported",
|
||||
elements: [{ id: "queue", label: "Queue", type: "database" as never }],
|
||||
relationships: [],
|
||||
}), /unsupported.*type/i);
|
||||
});
|
||||
|
||||
test("C4 enforces legal parent hierarchy and required parents", () => {
|
||||
const invalid = [
|
||||
[{ id: "system", label: "System", type: "system" as const, parentId: "person" }, { id: "person", label: "Person", type: "person" as const }],
|
||||
[{ id: "person", label: "Person", type: "person" as const, parentId: "system" }, { id: "system", label: "System", type: "system" as const }],
|
||||
[{ id: "container", label: "Container", type: "container" as const }],
|
||||
[{ id: "person", label: "Person", type: "person" as const }, { id: "container", label: "Container", type: "container" as const, parentId: "person" }],
|
||||
[{ id: "component", label: "Component", type: "component" as const }],
|
||||
[{ id: "system", label: "System", type: "system" as const }, { id: "component", label: "Component", type: "component" as const, parentId: "system" }],
|
||||
];
|
||||
for (const elements of invalid) {
|
||||
assert.throws(() => projectC4({ title: "Invalid", elements, relationships: [] }), /parent|hierarchy/i);
|
||||
}
|
||||
});
|
||||
|
||||
test("C4 rejects parent cycles", () => {
|
||||
assert.throws(() => projectC4({
|
||||
title: "Cycle",
|
||||
elements: [
|
||||
{ id: "container", label: "Container", type: "container", parentId: "component" },
|
||||
{ id: "component", label: "Component", type: "component", parentId: "container" },
|
||||
],
|
||||
relationships: [],
|
||||
}), /cycle/i);
|
||||
});
|
||||
|
||||
test("C4 retains a person-to-container relationship on one projection without dangling endpoints", () => {
|
||||
const model = {
|
||||
title: "Cross-level",
|
||||
elements: [
|
||||
{ id: "operator", label: "Operator", type: "person" as const },
|
||||
{ id: "platform", label: "Platform", type: "system" as const },
|
||||
{ id: "console", label: "Console", type: "container" as const, parentId: "platform" },
|
||||
],
|
||||
relationships: [{ id: "administers", source: "operator", target: "console", label: "Administers" }],
|
||||
};
|
||||
const ir = projectC4(model);
|
||||
assert.deepEqual(ir, projectC4(model));
|
||||
const containingPages = ir.pages.filter((page) => page.edges.some((edge) => edge.id === "administers"));
|
||||
assert.equal(containingPages.length, 1);
|
||||
const page = containingPages[0];
|
||||
const nodeIds = new Set(page.nodes.map((node) => node.id));
|
||||
assert.ok(nodeIds.has("operator"));
|
||||
assert.ok(nodeIds.has("console"));
|
||||
assert.ok(page.edges.every((edge) => nodeIds.has(edge.source) && nodeIds.has(edge.target)));
|
||||
});
|
||||
|
||||
test("C4 deterministically retains adversarial cross-level relationships with projected endpoints", () => {
|
||||
const model = {
|
||||
title: "Cross-level matrix",
|
||||
elements: [
|
||||
{ id: "operator", label: "Operator", type: "person" as const },
|
||||
{ id: "platform", label: "Platform", type: "system" as const },
|
||||
{ id: "api", label: "API", type: "container" as const, parentId: "platform" },
|
||||
{ id: "ui", label: "UI", type: "component" as const, parentId: "api" },
|
||||
{ id: "service", label: "Service", type: "component" as const, parentId: "api" },
|
||||
],
|
||||
relationships: [
|
||||
{ id: "person-component", source: "operator", target: "ui" },
|
||||
{ id: "system-component", source: "platform", target: "service" },
|
||||
{ id: "container-system", source: "api", target: "platform" },
|
||||
{ id: "component-component", source: "ui", target: "service" },
|
||||
],
|
||||
};
|
||||
const first = projectC4(model);
|
||||
assert.deepEqual(first, projectC4(model));
|
||||
for (const relationship of model.relationships) {
|
||||
const pages = first.pages.filter((page) => page.edges.some((edge) => edge.id === relationship.id));
|
||||
assert.ok(pages.length >= 1, `${relationship.id} was dropped`);
|
||||
assert.ok(pages.some((page) => {
|
||||
const ids = new Set(page.nodes.map((node) => node.id));
|
||||
return ids.has(relationship.source) && ids.has(relationship.target)
|
||||
&& page.edges.every((edge) => ids.has(edge.source) && ids.has(edge.target));
|
||||
}), `${relationship.id} has dangling projected endpoints`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export type C4ElementType = "person" | "system" | "container" | "component";
|
||||
export interface C4Element { id: string; label: string; type: C4ElementType; parentId?: string; provenance?: Record<string, unknown>; properties?: Record<string, unknown> }
|
||||
export interface C4Relationship { id: string; source: string; target: string; label?: string; provenance?: Record<string, unknown> }
|
||||
export interface C4Model { title: string; elements: readonly C4Element[]; relationships: readonly C4Relationship[]; provenance?: Record<string, unknown> }
|
||||
|
||||
function cloneRecord(value: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
||||
return value === undefined ? undefined : structuredClone(value);
|
||||
}
|
||||
|
||||
export function projectC4(model: C4Model): DiagramIRV2 {
|
||||
if (!model || !Array.isArray(model.elements) || model.elements.length === 0) throw new Error("C4 model requires at least one element");
|
||||
if (!Array.isArray(model.relationships)) throw new Error("C4 relationships must be an array");
|
||||
const validTypes = new Set<C4ElementType>(["person", "system", "container", "component"]);
|
||||
for (const element of model.elements) {
|
||||
if (!validTypes.has(element.type)) throw new Error(`C4 element ${element.id} has unsupported type: ${String(element.type)}`);
|
||||
}
|
||||
const byId = new Map(model.elements.map((element) => [element.id, element]));
|
||||
if (byId.size !== model.elements.length) throw new Error("C4 element IDs must be unique");
|
||||
for (const element of model.elements) {
|
||||
if (element.parentId && !byId.has(element.parentId)) throw new Error(`C4 element ${element.id} has unknown parent ${element.parentId}`);
|
||||
}
|
||||
for (const element of model.elements) {
|
||||
const seen = new Set<string>();
|
||||
let current: C4Element | undefined = element;
|
||||
while (current?.parentId) {
|
||||
if (seen.has(current.id)) throw new Error(`C4 element ${element.id} participates in a parent cycle`);
|
||||
seen.add(current.id);
|
||||
current = byId.get(current.parentId);
|
||||
}
|
||||
}
|
||||
for (const element of model.elements) {
|
||||
const parent = element.parentId ? byId.get(element.parentId) : undefined;
|
||||
if ((element.type === "person" || element.type === "system") && element.parentId) {
|
||||
throw new Error(`C4 ${element.type} ${element.id} must not have a parent`);
|
||||
}
|
||||
if (element.type === "container" && parent?.type !== "system") {
|
||||
throw new Error(`C4 container ${element.id} requires a system parent`);
|
||||
}
|
||||
if (element.type === "component" && parent?.type !== "container") {
|
||||
throw new Error(`C4 component ${element.id} requires a container parent`);
|
||||
}
|
||||
}
|
||||
for (const relationship of model.relationships) {
|
||||
if (!byId.has(relationship.source) || !byId.has(relationship.target)) throw new Error(`C4 relationship ${relationship.id} has an unknown endpoint`);
|
||||
}
|
||||
|
||||
const systems = model.elements.filter((element) => element.type === "system");
|
||||
const containers = model.elements.filter((element) => element.type === "container");
|
||||
const pageFor = (prefix: string, id: string) => `${prefix}-${id}`;
|
||||
const toNode = (element: C4Element, included: Set<string>): DiagramNode => {
|
||||
const drillDownPage = element.type === "system" && containers.some((item) => item.parentId === element.id)
|
||||
? 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;
|
||||
return {
|
||||
id: element.id,
|
||||
label: element.label,
|
||||
kind: model.elements.some((child) => child.parentId === element.id && included.has(child.id)) ? "container" : `c4-${element.type}`,
|
||||
...(element.parentId && included.has(element.parentId) ? { parentId: element.parentId } : {}),
|
||||
style: "rounded=0;whiteSpace=wrap;html=1",
|
||||
properties: { ...cloneRecord(element.properties), c4Type: element.type, ...(drillDownPage ? { drillDownPage } : {}) },
|
||||
provenance: cloneRecord(element.provenance),
|
||||
};
|
||||
};
|
||||
const makePage = (id: string, title: string, elements: C4Element[]): DiagramPage => {
|
||||
const included = new Set(elements.map((element) => element.id));
|
||||
const edges: DiagramEdge[] = model.relationships.filter((edge) => included.has(edge.source) && included.has(edge.target)).map((edge) => ({
|
||||
id: edge.id, source: edge.source, target: edge.target, label: edge.label, kind: "c4-relationship", provenance: cloneRecord(edge.provenance),
|
||||
}));
|
||||
return { id, title, nodes: elements.map((element) => toNode(element, included)), edges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { c4Level: id.split("-")[1] } };
|
||||
};
|
||||
|
||||
const context = model.elements.filter((element) => element.type === "person" || element.type === "system");
|
||||
const pageSpecs: Array<{ id: string; title: string; elements: C4Element[] }> = [
|
||||
{ id: "c4-context", title: `${model.title} — Context`, elements: context.length ? context : model.elements.slice(0, 1) },
|
||||
];
|
||||
for (const system of systems) {
|
||||
const children = containers.filter((element) => element.parentId === system.id);
|
||||
if (children.length) {
|
||||
const childIds = new Set(children.map((element) => element.id));
|
||||
const relatedPeople = model.elements.filter((element) => element.type === "person" && model.relationships.some((relationship) =>
|
||||
(relationship.source === element.id && childIds.has(relationship.target))
|
||||
|| (relationship.target === element.id && childIds.has(relationship.source))));
|
||||
pageSpecs.push({ id: pageFor("c4-containers", system.id), title: `${system.label} — Containers`, elements: [system, ...children, ...relatedPeople] });
|
||||
}
|
||||
}
|
||||
for (const container of containers) {
|
||||
const children = model.elements.filter((element) => element.type === "component" && element.parentId === container.id);
|
||||
if (children.length) pageSpecs.push({ id: pageFor("c4-components", container.id), title: `${container.label} — Components`, elements: [container, ...children] });
|
||||
}
|
||||
|
||||
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 candidates = pageSpecs.filter((page) => page.elements.some((element) =>
|
||||
element.id === relationship.source || element.id === relationship.target));
|
||||
const page = 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)) {
|
||||
page.elements.push(byId.get(endpoint)!);
|
||||
included.add(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
const pages = pageSpecs.map((page) => makePage(page.id, page.title, page.elements));
|
||||
const ir: DiagramIRV2 = { version: 2, title: model.title, pages, provenance: cloneRecord(model.provenance), properties: { profile: "c4" } };
|
||||
return validateDiagramIR(ir) as DiagramIRV2;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { compressExecutiveView } from "./compression.js";
|
||||
|
||||
const PAGE: DiagramPage = { id: "platform", title: "Platform", nodes: [{ id: "a", label: "A", provenance: { source: "a.yaml" } }, { id: "b", label: "B" }, { id: "c", label: "C" }, { id: "d", label: "D" }], edges: [{ id: "ab", source: "a", target: "b" }, { id: "bc", source: "b", target: "c" }, { id: "cd", source: "c", target: "d" }] };
|
||||
|
||||
test("compression deterministically clusters topology into linked summary and full pages", () => {
|
||||
const before = structuredClone(PAGE); const ir = compressExecutiveView(PAGE, { maxClusterSize: 2 });
|
||||
assert.deepEqual(ir, compressExecutiveView(PAGE, { maxClusterSize: 2 })); assert.deepEqual(PAGE, before); assert.equal(validateDiagramIR(ir), ir);
|
||||
assert.deepEqual(ir.pages.map((p) => p.id), ["executive-summary", "platform-full"]);
|
||||
assert.deepEqual(ir.pages[0].nodes.map((n) => n.properties?.memberIds), [["a", "b"], ["c", "d"]]);
|
||||
assert.equal(ir.pages[0].nodes[0].properties?.drillDownPage, "platform-full");
|
||||
assert.equal(ir.pages[1].nodes[0].provenance?.source, "a.yaml");
|
||||
assert.equal(ir.pages[0].edges.length, 1);
|
||||
});
|
||||
|
||||
test("compression rejects invalid and empty pages", () => {
|
||||
assert.throws(() => compressExecutiveView({ id: "empty", title: "Empty", nodes: [], edges: [] }), /non-empty/i);
|
||||
assert.throws(() => compressExecutiveView({ id: "bad", title: "Bad", nodes: [{ id: "a", label: "A" }], edges: [{ id: "e", source: "a", target: "missing" }] }), /unknown target/i);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DiagramEdge, DiagramIRV2, DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface CompressionOptions { maxClusterSize?: number }
|
||||
|
||||
export function compressExecutiveView(page: DiagramPage, options: CompressionOptions = {}): DiagramIRV2 {
|
||||
validateDiagramIR({ version: 2, pages: [structuredClone(page)] });
|
||||
const maxSize = options.maxClusterSize ?? 6;
|
||||
if (!Number.isInteger(maxSize) || maxSize < 1) throw new Error("maxClusterSize must be a positive integer");
|
||||
const nodeById = new Map(page.nodes.map((node) => [node.id, node]));
|
||||
const adjacency = new Map(page.nodes.map((node) => [node.id, new Set<string>()]));
|
||||
for (const edge of page.edges) { adjacency.get(edge.source)!.add(edge.target); adjacency.get(edge.target)!.add(edge.source); }
|
||||
const remaining = new Set([...nodeById.keys()].sort());
|
||||
const clusters: string[][] = [];
|
||||
while (remaining.size) {
|
||||
const seed = [...remaining][0]; const queue = [seed]; const members: string[] = [];
|
||||
while (queue.length && members.length < maxSize) {
|
||||
const id = queue.shift()!; if (!remaining.delete(id)) continue; members.push(id);
|
||||
const neighbors = [...adjacency.get(id)!].filter((item) => remaining.has(item)).sort();
|
||||
queue.push(...neighbors);
|
||||
}
|
||||
members.sort(); clusters.push(members);
|
||||
}
|
||||
const fullPageId = `${page.id}-full`;
|
||||
const clusterByNode = new Map<string, number>(); clusters.forEach((members, index) => members.forEach((id) => clusterByNode.set(id, index)));
|
||||
const summaryNodes = clusters.map((members, index) => ({ id: `cluster-${String(index + 1).padStart(3, "0")}`, label: members.map((id) => nodeById.get(id)!.label).join(" + "), kind: "executive-cluster", properties: { memberIds: members, memberCount: members.length, drillDownPage: fullPageId }, provenance: { sources: members.map((id) => nodeById.get(id)!.provenance ?? {}).filter((value) => Object.keys(value).length > 0) } }));
|
||||
const aggregate = new Map<string, { source: number; target: number; edgeIds: string[] }>();
|
||||
for (const edge of [...page.edges].sort((a, b) => a.id.localeCompare(b.id))) {
|
||||
const source = clusterByNode.get(edge.source)!; const target = clusterByNode.get(edge.target)!; if (source === target) continue;
|
||||
const key = `${source}:${target}`; const item = aggregate.get(key) ?? { source, target, edgeIds: [] }; item.edgeIds.push(edge.id); aggregate.set(key, item);
|
||||
}
|
||||
const summaryEdges: DiagramEdge[] = [...aggregate.values()].sort((a, b) => a.source - b.source || a.target - b.target).map((item, index) => ({ id: `cluster-link-${String(index + 1).padStart(3, "0")}`, source: summaryNodes[item.source].id, target: summaryNodes[item.target].id, kind: "executive-aggregate", properties: { edgeIds: item.edgeIds, edgeCount: item.edgeIds.length } }));
|
||||
const full: DiagramPage = structuredClone(page); full.id = fullPageId; full.properties = { ...(full.properties ?? {}), profile: "compression-full", summaryPage: "executive-summary" };
|
||||
const ir: DiagramIRV2 = { version: 2, title: page.title, pages: [{ id: "executive-summary", title: `${page.title} — Executive Summary`, nodes: summaryNodes, edges: summaryEdges, layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 }, properties: { profile: "executive-compression", fullPage: fullPageId } }, full], properties: { profile: "executive-compression", algorithm: "deterministic-topology-bfs" } };
|
||||
return validateDiagramIR(ir) as DiagramIRV2;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import * as profiles from "./index.js";
|
||||
|
||||
test("specialized profile barrel exposes all seven native profiles", () => {
|
||||
assert.deepEqual(Object.keys(profiles).filter((key) => key.startsWith("create") || key.startsWith("project") || key.startsWith("compress")).sort(), ["compressExecutiveView", "createArchitectureTimelapse", "createBuildup", "createRunbookHtml", "createSequenceDiagram", "createTubeMap", "projectC4"]);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export { projectC4 } from "./c4.js";
|
||||
export type { C4Element, C4ElementType, C4Model, C4Relationship } from "./c4.js";
|
||||
export { createSequenceDiagram } from "./sequence.js";
|
||||
export type { SequenceInput, SequenceMessage, SequenceParticipant } from "./sequence.js";
|
||||
export { createTubeMap } from "./tube-map.js";
|
||||
export type { TubeLine, TubeMapInput, TubeStation } from "./tube-map.js";
|
||||
export { compressExecutiveView } from "./compression.js";
|
||||
export type { CompressionOptions } from "./compression.js";
|
||||
export { createRunbookHtml } from "./runbook.js";
|
||||
export type { RunbookChoice, RunbookGraph, RunbookNode } from "./runbook.js";
|
||||
export { createArchitectureTimelapse } from "./timelapse.js";
|
||||
export type { ArchitectureSnapshot, ArchitectureTimelapse, ChangeSet, TimelapseFrame } from "./timelapse.js";
|
||||
export { createBuildup } from "./buildup.js";
|
||||
export type { BuildupResult } from "./buildup.js";
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createRunbookHtml } from "./runbook.js";
|
||||
|
||||
const GRAPH = { title: "Ops <Runbook>", startId: "start", nodes: [{ id: "start", label: "Is it healthy? </script><img src=x onerror=alert(1)>", choices: [{ label: "No & investigate", target: "check" }] }, { id: "check", label: "Check logs", choices: [{ label: "Retry", target: "start" }] }] };
|
||||
|
||||
test("runbook HTML is deterministic, self-contained and escapes malicious labels", () => {
|
||||
const before = structuredClone(GRAPH); const html = createRunbookHtml(GRAPH);
|
||||
assert.equal(html, createRunbookHtml(GRAPH)); assert.deepEqual(GRAPH, before);
|
||||
assert.ok(html.includes("default-src 'none'")); assert.ok(html.includes("Breadcrumb")); assert.ok(html.includes("Back")); assert.ok(html.includes("Restart")); assert.ok(html.includes("keydown"));
|
||||
assert.ok(html.includes("border-radius:0")); assert.equal(/border-radius:(?!0(?:[;}]))/.test(html), false);
|
||||
assert.equal(html.includes("</script><img"), false); assert.equal(html.includes("onerror=alert"), false); assert.equal(/(?:src|href)=["']https?:/i.test(html), false);
|
||||
});
|
||||
|
||||
test("runbook rejects empty graphs and unknown targets", () => {
|
||||
assert.throws(() => createRunbookHtml({ title: "Empty", startId: "x", nodes: [] }), /node/i);
|
||||
assert.throws(() => createRunbookHtml({ title: "Bad", startId: "a", nodes: [{ id: "a", label: "A", choices: [{ label: "Go", target: "b" }] }] }), /target/i);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface RunbookChoice { label: string; target: string }
|
||||
export interface RunbookNode { id: string; label: string; detail?: string; choices?: readonly RunbookChoice[] }
|
||||
export interface RunbookGraph { title: string; startId: string; nodes: readonly RunbookNode[] }
|
||||
|
||||
function stripMarkup(value: string): string { return value.replace(/<[^>]*>/g, "").replace(/on\w+\s*=\s*/gi, ""); }
|
||||
function escapeHtml(value: string): string { return stripMarkup(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); }
|
||||
function safeJson(value: unknown): string { return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029"); }
|
||||
|
||||
export function createRunbookHtml(graph: RunbookGraph): string {
|
||||
if (!graph || !Array.isArray(graph.nodes) || graph.nodes.length === 0) throw new Error("Runbook requires at least one node");
|
||||
const nodes: readonly RunbookNode[] = graph.nodes;
|
||||
const ids = new Set(nodes.map((node) => node.id));
|
||||
if (ids.size !== nodes.length) throw new Error("Runbook node IDs must be unique");
|
||||
if (!ids.has(graph.startId)) throw new Error("Runbook startId references an unknown node");
|
||||
for (const node of nodes) for (const choice of node.choices ?? []) if (!ids.has(choice.target)) throw new Error(`Runbook choice has unknown target ${choice.target}`);
|
||||
const data = { title: stripMarkup(graph.title), startId: graph.startId, nodes: nodes.map((node) => ({ id: node.id, label: stripMarkup(node.label), detail: node.detail === undefined ? undefined : stripMarkup(node.detail), choices: (node.choices ?? []).map((choice) => ({ label: stripMarkup(choice.label), target: choice.target })) })) };
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:"><title>${escapeHtml(graph.title)}</title><style>body{font:16px system-ui;margin:0;background:#f4f6f8;color:#17202a}main{max-width:760px;margin:3rem auto;padding:2rem;background:white;border-radius:0}button{display:block;width:100%;margin:.75rem 0;padding:1rem;text-align:left}nav button{display:inline-block;width:auto;margin-right:.5rem}#crumbs{margin:1rem 0;color:#566573}</style></head><body><main><h1>${escapeHtml(graph.title)}</h1><div id="crumbs" aria-label="Breadcrumb"></div><section aria-live="polite"><h2 id="label"></h2><p id="detail"></p><div id="choices"></div></section><nav><button id="back" type="button">Back</button><button id="restart" type="button">Restart</button></nav></main><script id="runbook-data" type="application/json">${safeJson(data)}</script><script>(()=>{'use strict';const d=JSON.parse(document.getElementById('runbook-data').textContent);const byId=new Map(d.nodes.map(n=>[n.id,n]));const history=[];let current=d.startId;const label=document.getElementById('label'),detail=document.getElementById('detail'),choices=document.getElementById('choices'),crumbs=document.getElementById('crumbs');function render(){const n=byId.get(current);label.textContent=n.label;detail.textContent=n.detail||'';crumbs.textContent='Breadcrumb: '+[...history,current].map(id=>byId.get(id).label).join(' › ');choices.replaceChildren();n.choices.forEach((c,i)=>{const b=document.createElement('button');b.type='button';b.textContent=(i+1)+'. '+c.label;b.addEventListener('click',()=>go(c.target));choices.appendChild(b)});document.getElementById('back').disabled=!history.length}function go(id){history.push(current);current=id;render()}function back(){if(history.length){current=history.pop();render()}}function restart(){history.length=0;current=d.startId;render()}document.getElementById('back').addEventListener('click',back);document.getElementById('restart').addEventListener('click',restart);document.addEventListener('keydown',e=>{if(e.key==='Escape'||e.key==='ArrowLeft')back();else if(e.key.toLowerCase()==='r')restart();else if(/^\\d$/.test(e.key)){const n=byId.get(current),c=n.choices[Number(e.key)-1];if(c)go(c.target)}});render()})();</script></body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { createSequenceDiagram } from "./sequence.js";
|
||||
|
||||
const INPUT = {
|
||||
title: "Checkout",
|
||||
participants: [{ id: "web", label: "Web" }, { id: "api", label: "API" }, { id: "db", label: "DB" }],
|
||||
messages: [
|
||||
{ id: "m1", from: "web", to: "api", label: "submit", type: "call" as const },
|
||||
{ id: "m2", from: "api", to: "db", label: "save", type: "call" as const },
|
||||
{ id: "m3", from: "db", to: "api", label: "ok", type: "return" as const },
|
||||
],
|
||||
};
|
||||
|
||||
test("sequence profile emits deterministic non-overlapping lifelines and activation metadata", () => {
|
||||
const before = structuredClone(INPUT);
|
||||
const ir = createSequenceDiagram(INPUT);
|
||||
assert.deepEqual(ir, createSequenceDiagram(INPUT));
|
||||
assert.deepEqual(INPUT, before);
|
||||
assert.equal(validateDiagramIR(ir), ir);
|
||||
const nodes = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant");
|
||||
for (let i = 0; i < nodes.length; i++) for (let j = i + 1; j < nodes.length; j++) {
|
||||
const a = nodes[i].geometry!; const b = nodes[j].geometry!;
|
||||
assert.equal(a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y, false);
|
||||
}
|
||||
assert.deepEqual(nodes.find((node) => node.id === "api")?.properties?.activations, [{ start: 1, end: 3, depth: 1 }]);
|
||||
assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.sequence), [1, 2, 3]);
|
||||
assert.deepEqual(ir.pages[0].edges.map((edge) => edge.properties?.activationDepth), [1, 1, 1]);
|
||||
});
|
||||
|
||||
test("sequence profile rejects empty participants and unknown message endpoints", () => {
|
||||
assert.throws(() => createSequenceDiagram({ title: "Empty", participants: [], messages: [] }), /participant/i);
|
||||
assert.throws(() => createSequenceDiagram({ title: "Bad", participants: [{ id: "a", label: "A" }], messages: [{ id: "m", from: "a", to: "b", label: "bad" }] }), /endpoint/i);
|
||||
});
|
||||
|
||||
test("sequence profile rejects returns that do not match the top active caller and callee", () => {
|
||||
const participants = [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }];
|
||||
assert.throws(() => createSequenceDiagram({
|
||||
title: "Mismatched",
|
||||
participants,
|
||||
messages: [
|
||||
{ id: "one", from: "a", to: "b", label: "one", type: "call" },
|
||||
{ id: "two", from: "b", to: "c", label: "two", type: "call" },
|
||||
{ id: "bad", from: "b", to: "a", label: "bad", type: "return" },
|
||||
],
|
||||
}), /return.*top active call/i);
|
||||
assert.throws(() => createSequenceDiagram({
|
||||
title: "Orphan",
|
||||
participants,
|
||||
messages: [{ id: "bad", from: "b", to: "a", label: "bad", type: "return" }],
|
||||
}), /return.*top active call/i);
|
||||
});
|
||||
|
||||
test("sequence output contains editable dashed lifelines, activation bars, and 120x80 headers", () => {
|
||||
const ir = createSequenceDiagram(INPUT);
|
||||
const headers = ir.pages[0].nodes.filter((node) => node.kind === "sequence-participant");
|
||||
assert.equal(headers.length, INPUT.participants.length);
|
||||
for (const header of headers) {
|
||||
assert.ok(header.geometry!.width >= 120);
|
||||
assert.ok(header.geometry!.height >= 80);
|
||||
assert.match(header.style ?? "", /rounded=0/);
|
||||
}
|
||||
const lifelines = ir.pages[0].nodes.filter((node) => node.kind === "sequence-lifeline");
|
||||
assert.equal(lifelines.length, INPUT.participants.length);
|
||||
assert.ok(lifelines.every((node) => /dashed=1/.test(node.style ?? "")));
|
||||
const activations = ir.pages[0].nodes.filter((node) => node.kind === "sequence-activation");
|
||||
assert.ok(activations.length > 0);
|
||||
assert.ok(activations.every((node) => /rounded=0/.test(node.style ?? "")));
|
||||
|
||||
const xml = diagramIRToDrawio(ir);
|
||||
for (const participant of INPUT.participants) assert.match(xml, new RegExp(`id="${participant.id}-lifeline"[^>]*dashed=1`));
|
||||
assert.match(xml, /id="api-activation-1"[^>]*vertex="1"/);
|
||||
});
|
||||
|
||||
test("sequence messages avoid connector-shape overlaps in rendered editable output", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sequence-routing-"));
|
||||
const file = join(dir, "sequence.drawio");
|
||||
try {
|
||||
writeFileSync(file, diagramIRToDrawio(createSequenceDiagram(INPUT)), "utf8");
|
||||
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
|
||||
assert.equal(validation.summary.connectorShapeOverlaps, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("sequence page width includes the deepest nested activation geometry", () => {
|
||||
const nestedCalls = Array.from({ length: 15 }, (_, index) => ({ id: `nested-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const }));
|
||||
const nestedReturns = Array.from({ length: 15 }, (_, index) => ({ id: `nested-return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const }));
|
||||
const ir = createSequenceDiagram({
|
||||
title: "Deep recursion",
|
||||
participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }],
|
||||
messages: [
|
||||
{ id: "start", from: "caller", to: "worker", label: "start", type: "call" },
|
||||
...nestedCalls,
|
||||
...nestedReturns,
|
||||
{ id: "done", from: "worker", to: "caller", label: "done", type: "return" },
|
||||
],
|
||||
});
|
||||
const deepestRight = Math.max(...ir.pages[0].nodes
|
||||
.filter((node) => node.kind === "sequence-activation")
|
||||
.map((node) => node.geometry!.x + node.geometry!.width));
|
||||
assert.equal(deepestRight, 455);
|
||||
assert.ok(ir.pages[0].width! >= deepestRight + 40);
|
||||
});
|
||||
|
||||
test("sequence page width includes nested self-call waypoint extents plus margin", () => {
|
||||
const calls = Array.from({ length: 15 }, (_, index) => ({
|
||||
id: `self-${index + 1}`, from: "worker", to: "worker", label: "recurse", type: "call" as const,
|
||||
}));
|
||||
const returns = Array.from({ length: 15 }, (_, index) => ({
|
||||
id: `return-${index + 1}`, from: "worker", to: "worker", label: "return", type: "return" as const,
|
||||
}));
|
||||
const ir = createSequenceDiagram({
|
||||
title: "Waypoint-aware recursion",
|
||||
participants: [{ id: "caller", label: "Caller" }, { id: "worker", label: "Worker" }],
|
||||
messages: [
|
||||
{ id: "start", from: "caller", to: "worker", label: "start", type: "call" },
|
||||
...calls,
|
||||
...returns,
|
||||
{ id: "done", from: "worker", to: "caller", label: "done", type: "return" },
|
||||
],
|
||||
});
|
||||
const waypointRight = Math.max(...ir.pages[0].edges.flatMap((edge) => edge.waypoints?.map((point) => point.x) ?? []));
|
||||
assert.equal(waypointRight, 530);
|
||||
assert.ok(ir.pages[0].width! >= waypointRight + 40);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface SequenceParticipant { id: string; label: string; provenance?: Record<string, unknown> }
|
||||
export interface SequenceMessage { id: string; from: string; to: string; label: string; type?: "call" | "return" | "async"; provenance?: Record<string, unknown> }
|
||||
export interface SequenceInput { title: string; participants: readonly SequenceParticipant[]; messages: readonly SequenceMessage[]; provenance?: Record<string, unknown> }
|
||||
interface Activation { start: number; end: number; depth: number }
|
||||
|
||||
export function createSequenceDiagram(input: SequenceInput): DiagramIRV2 {
|
||||
if (!input || !Array.isArray(input.participants) || input.participants.length === 0) throw new Error("Sequence diagram requires participants");
|
||||
if (!Array.isArray(input.messages)) throw new Error("Sequence messages must be an array");
|
||||
const ids = new Set(input.participants.map((participant) => participant.id));
|
||||
if (ids.size !== input.participants.length) throw new Error("Sequence participant IDs must be unique");
|
||||
for (const message of input.messages) if (!ids.has(message.from) || !ids.has(message.to)) throw new Error(`Sequence message ${message.id} has unknown endpoint`);
|
||||
|
||||
const open = new Map<string, Array<{ start: number; depth: number }>>();
|
||||
const activeCalls: Array<{ caller: string; callee: string }> = [];
|
||||
const activations = new Map<string, Activation[]>(input.participants.map((p) => [p.id, []]));
|
||||
const messageDepths: number[] = [];
|
||||
input.messages.forEach((message, index) => {
|
||||
const sequence = index + 1;
|
||||
if ((message.type ?? "call") === "return") {
|
||||
const expected = activeCalls.at(-1);
|
||||
if (!expected || message.from !== expected.callee || message.to !== expected.caller) {
|
||||
throw new Error(`Sequence return ${message.id} must match the top active call's original callee and caller`);
|
||||
}
|
||||
activeCalls.pop();
|
||||
const stack = open.get(message.from) ?? [];
|
||||
const active = stack.pop();
|
||||
messageDepths.push(active?.depth ?? 0);
|
||||
if (active) activations.get(message.from)!.push({ ...active, end: sequence });
|
||||
} else if (message.type === "call" || message.type === undefined) {
|
||||
const stack = open.get(message.to) ?? [];
|
||||
const depth = stack.length + 1;
|
||||
stack.push({ start: sequence, depth });
|
||||
activeCalls.push({ caller: message.from, callee: message.to });
|
||||
messageDepths.push(depth);
|
||||
open.set(message.to, stack);
|
||||
} else {
|
||||
messageDepths.push(0);
|
||||
}
|
||||
});
|
||||
for (const [participant, stack] of open) for (const active of stack) activations.get(participant)!.push({ ...active, end: input.messages.length || 1 });
|
||||
for (const list of activations.values()) list.sort((a, b) => a.start - b.start || a.depth - b.depth);
|
||||
|
||||
const xById = new Map<string, number>();
|
||||
const participantNodes = input.participants.map((participant, index) => {
|
||||
const x = 40 + index * 200; xById.set(participant.id, x);
|
||||
return { id: participant.id, label: participant.label, kind: "sequence-participant", geometry: { x, y: 40, width: 120, height: 80 }, style: "rounded=0;whiteSpace=wrap;html=1", properties: { lifelineX: x + 60, lifelineStart: 120, lifelineEnd: 180 + input.messages.length * 60, activations: activations.get(participant.id)! }, provenance: participant.provenance === undefined ? undefined : structuredClone(participant.provenance) };
|
||||
});
|
||||
const lifelineNodes = input.participants.map((participant) => ({
|
||||
id: `${participant.id}-lifeline`, label: "\u200B", kind: "sequence-lifeline",
|
||||
geometry: { x: xById.get(participant.id)! + 59, y: 120, width: 2, height: 60 + input.messages.length * 60 },
|
||||
style: "shape=line;direction=south;dashed=1;dashPattern=8 8;strokeWidth=2;rounded=0;html=1",
|
||||
properties: { participantId: participant.id },
|
||||
}));
|
||||
const activationNodes = input.participants.flatMap((participant) => activations.get(participant.id)!.map((activation, index) => ({
|
||||
id: `${participant.id}-activation-${index + 1}`, label: "\u200B", kind: "sequence-activation",
|
||||
geometry: { x: xById.get(participant.id)! + 55 + (activation.depth - 1) * 10, y: 140 + (activation.start - 1) * 60, width: 10, height: Math.max(20, (activation.end - activation.start) * 60) },
|
||||
style: "rounded=0;whiteSpace=wrap;html=1;fillColor=#ffffff;strokeColor=#000000",
|
||||
properties: { participantId: participant.id, ...activation },
|
||||
})));
|
||||
const maxDepthById = new Map(input.participants.map((participant) => [participant.id, Math.max(0, ...activations.get(participant.id)!.map((activation) => activation.depth))]));
|
||||
const anchorNodes = input.messages.flatMap((message, index) => {
|
||||
const y = 140 + index * 60;
|
||||
const fromX = xById.get(message.from)! + 60;
|
||||
const toX = xById.get(message.to)! + 60;
|
||||
const direction = Math.sign(toX - fromX) || 1;
|
||||
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);
|
||||
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" } },
|
||||
];
|
||||
});
|
||||
const nodes = [...participantNodes, ...lifelineNodes, ...activationNodes, ...anchorNodes];
|
||||
const edges = input.messages.map((message, index) => {
|
||||
const y = 140 + index * 60;
|
||||
const fromX = xById.get(message.from)! + 60;
|
||||
const toX = xById.get(message.to)! + 60;
|
||||
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 }];
|
||||
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));
|
||||
const waypointRight = Math.max(0, ...edges.flatMap((edge) => edge.waypoints.map((point) => point.x)));
|
||||
const contentRight = Math.max(activationRight, waypointRight);
|
||||
const pageWidth = Math.max(320, 200 * input.participants.length, Math.ceil((contentRight + 40) / 10) * 10);
|
||||
const ir: DiagramIRV2 = { version: 2, title: input.title, pages: [{ id: "sequence", title: input.title, nodes, edges, layout: { type: "manual", gridSize: 10 }, width: pageWidth, height: 240 + 60 * input.messages.length, properties: { profile: "sequence" } }], provenance: input.provenance === undefined ? undefined : structuredClone(input.provenance), properties: { profile: "sequence" } };
|
||||
return validateDiagramIR(ir) as DiagramIRV2;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { createArchitectureTimelapse } from "./timelapse.js";
|
||||
|
||||
const one: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A", provenance: { source: "model" } }, { id: "b", label: "B" }], edges: [{ id: "ab", source: "a", target: "b" }] }] };
|
||||
const two: DiagramIRV2 = { version: 2, title: "Arch", pages: [{ id: "p", title: "P", nodes: [{ id: "a", label: "A2", provenance: { source: "model" } }, { id: "c", label: "C" }], edges: [{ id: "ac", source: "a", target: "c" }] }] };
|
||||
|
||||
test("timelapse emits valid immutable frames with stable change classifications", () => {
|
||||
const input = [{ id: "v1", label: "Before", ir: one }, { id: "v2", label: "After", ir: two }]; const before = structuredClone(input);
|
||||
const result = createArchitectureTimelapse(input); assert.deepEqual(result, createArchitectureTimelapse(input)); assert.deepEqual(input, before);
|
||||
result.frames.forEach((frame) => assert.equal(validateDiagramIR(frame.ir), frame.ir));
|
||||
assert.deepEqual(result.frames[1].changes.nodes, { added: ["p/c"], removed: ["p/b"], modified: ["p/a"], unchanged: [] });
|
||||
assert.deepEqual(result.frames[1].changes.edges, { added: ["p/ac"], removed: ["p/ab"], modified: [], unchanged: [] });
|
||||
assert.equal(result.frames[1].ir.pages[0].nodes[0].provenance?.source, "model");
|
||||
});
|
||||
|
||||
test("timelapse rejects empty and invalid snapshots", () => {
|
||||
assert.throws(() => createArchitectureTimelapse([]), /snapshot/i);
|
||||
assert.throws(() => createArchitectureTimelapse([{ id: "bad", label: "Bad", ir: { version: 2, pages: [] } as DiagramIRV2 }]), /non-empty/i);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface ArchitectureSnapshot { id: string; label: string; ir: DiagramIRV2; provenance?: Record<string, unknown> }
|
||||
export interface ChangeSet { added: string[]; removed: string[]; modified: string[]; unchanged: string[] }
|
||||
export interface TimelapseFrame { id: string; label: string; index: number; ir: DiagramIRV2; changes: { nodes: ChangeSet; edges: ChangeSet }; provenance?: Record<string, unknown> }
|
||||
export interface ArchitectureTimelapse { frames: TimelapseFrame[] }
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
||||
if (value && typeof value === "object") return `{${Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`).join(",")}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
function inventory(ir: DiagramIRV2, kind: "nodes" | "edges"): Map<string, unknown> {
|
||||
return new Map(ir.pages.flatMap((page) => page[kind].map((item) => [`${page.id}/${item.id}`, item] as const)));
|
||||
}
|
||||
function classify(previous: Map<string, unknown>, current: Map<string, unknown>): ChangeSet {
|
||||
const added: string[] = [], removed: string[] = [], modified: string[] = [], unchanged: string[] = [];
|
||||
for (const id of [...current.keys()].sort()) {
|
||||
if (!previous.has(id)) added.push(id); else if (canonical(previous.get(id)) === canonical(current.get(id))) unchanged.push(id); else modified.push(id);
|
||||
}
|
||||
for (const id of [...previous.keys()].sort()) if (!current.has(id)) removed.push(id);
|
||||
return { added, removed, modified, unchanged };
|
||||
}
|
||||
|
||||
export function createArchitectureTimelapse(snapshots: readonly ArchitectureSnapshot[]): ArchitectureTimelapse {
|
||||
if (!Array.isArray(snapshots) || snapshots.length === 0) throw new Error("Timelapse requires at least one snapshot");
|
||||
const ids = new Set(snapshots.map((snapshot) => snapshot.id)); if (ids.size !== snapshots.length) throw new Error("Snapshot IDs must be unique");
|
||||
let previousNodes = new Map<string, unknown>(), previousEdges = new Map<string, unknown>();
|
||||
const frames = snapshots.map((snapshot, index) => {
|
||||
const ir = structuredClone(snapshot.ir); validateDiagramIR(ir);
|
||||
const currentNodes = inventory(ir, "nodes"), currentEdges = inventory(ir, "edges");
|
||||
const frame: TimelapseFrame = { id: snapshot.id, label: snapshot.label, index, ir, changes: { nodes: classify(previousNodes, currentNodes), edges: classify(previousEdges, currentEdges) }, provenance: snapshot.provenance === undefined ? undefined : structuredClone(snapshot.provenance) };
|
||||
previousNodes = currentNodes; previousEdges = currentEdges; return frame;
|
||||
});
|
||||
return { frames };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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 { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { createTubeMap } from "./tube-map.js";
|
||||
|
||||
const MAP = { title: "Transit", stations: [{ id: "a", label: "A" }, { id: "x", label: "Exchange" }, { id: "b", label: "B" }, { id: "c", label: "C" }], lines: [{ id: "red", label: "Red", color: "#e53935", stations: ["a", "x", "b"] }, { id: "blue", label: "Blue", color: "#1e88e5", stations: ["c", "x"] }] };
|
||||
|
||||
test("tube map is deterministic, grid-aligned and octilinear with interchange semantics", () => {
|
||||
const before = structuredClone(MAP); const ir = createTubeMap(MAP);
|
||||
assert.deepEqual(ir, createTubeMap(MAP)); assert.deepEqual(MAP, before); assert.equal(validateDiagramIR(ir), ir);
|
||||
const x = ir.pages[0].nodes.find((node) => node.id === "x")!;
|
||||
assert.equal(x.properties?.interchange, true); assert.deepEqual(x.properties?.lines, ["blue", "red"]);
|
||||
const centers = new Map(ir.pages[0].nodes.map((n) => [n.id, { x: n.geometry!.x + n.geometry!.width / 2, y: n.geometry!.y + n.geometry!.height / 2 }]));
|
||||
for (const edge of ir.pages[0].edges) {
|
||||
const points = [centers.get(edge.source)!, ...(edge.waypoints ?? []), centers.get(edge.target)!];
|
||||
for (let i = 1; i < points.length; i++) { const dx = Math.abs(points[i].x - points[i - 1].x); const dy = Math.abs(points[i].y - points[i - 1].y); assert.ok(dx === 0 || dy === 0 || dx === dy); }
|
||||
}
|
||||
});
|
||||
|
||||
test("tube map rejects empty and invalid routes", () => {
|
||||
assert.throws(() => createTubeMap({ title: "Empty", stations: [], lines: [] }), /station/i);
|
||||
assert.throws(() => createTubeMap({ title: "Bad", stations: [{ id: "a", label: "A" }], lines: [{ id: "l", label: "L", color: "red", stations: ["a", "z"] }] }), /unknown station/i);
|
||||
});
|
||||
|
||||
test("tube map rejects colors that could inject draw.io style directives", () => {
|
||||
const stations = [{ id: "a", label: "A" }, { id: "b", label: "B" }];
|
||||
for (const color of ["red", "#abc", "#123456;rounded=1", "#123456\nfillColor=#000000"]) {
|
||||
assert.throws(
|
||||
() => createTubeMap({ title: "Unsafe", stations, lines: [{ id: "line", label: "Line", color, stations: ["a", "b"] }] }),
|
||||
/color.*#RRGGBB/i,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("tube routes avoid unrelated station boxes even when route order is a,c,b", () => {
|
||||
const ir = createTubeMap({
|
||||
title: "Nonlinear route",
|
||||
stations: [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }],
|
||||
lines: [{ id: "line", label: "Line", color: "#123456", stations: ["a", "c", "b"] }],
|
||||
});
|
||||
const dir = mkdtempSync(join(tmpdir(), "tube-map-routing-"));
|
||||
const file = join(dir, "route.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 });
|
||||
}
|
||||
});
|
||||
|
||||
test("tube lines use deterministic distinct parallel corridors without station intersections", () => {
|
||||
const first = createTubeMap(MAP);
|
||||
assert.deepEqual(first, createTubeMap(MAP));
|
||||
const corridorByLine = new Map<string, number>();
|
||||
for (const edge of first.pages[0].edges) {
|
||||
const lineId = edge.properties!.lineId as string;
|
||||
const horizontal = edge.waypoints!.find((point, index, points) => index > 0 && point.y === points[index - 1].y);
|
||||
assert.ok(horizontal);
|
||||
const previous = corridorByLine.get(lineId);
|
||||
if (previous === undefined) corridorByLine.set(lineId, horizontal.y);
|
||||
else assert.equal(horizontal.y, previous);
|
||||
}
|
||||
assert.equal(new Set(corridorByLine.values()).size, MAP.lines.length);
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), "tube-map-corridors-"));
|
||||
const file = join(dir, "corridors.drawio");
|
||||
try {
|
||||
writeFileSync(file, diagramIRToDrawio(first), "utf8");
|
||||
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
|
||||
assert.equal(validation.summary.connectorShapeOverlaps, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("tube lines sharing stations have distinct complete routes including station approaches", () => {
|
||||
const input = {
|
||||
title: "Shared route",
|
||||
stations: [{ id: "a", label: "A" }, { id: "unrelated", label: "Unrelated" }, { id: "b", label: "B" }],
|
||||
lines: [
|
||||
{ id: "red", label: "Red", color: "#e53935", stations: ["a", "b"] },
|
||||
{ id: "blue", label: "Blue", color: "#1e88e5", stations: ["a", "b"] },
|
||||
],
|
||||
};
|
||||
const first = createTubeMap(input);
|
||||
assert.deepEqual(first, createTubeMap(input));
|
||||
const centers = new Map(first.pages[0].nodes.map((node) => [node.id, {
|
||||
x: node.geometry!.x + node.geometry!.width / 2,
|
||||
y: node.geometry!.y + node.geometry!.height / 2,
|
||||
}]));
|
||||
const routes = first.pages[0].edges.map((edge) => [centers.get(edge.source)!, ...edge.waypoints!, centers.get(edge.target)!]);
|
||||
const segments = routes.map((points) => points.slice(1).map((point, index) => [points[index], point] as const));
|
||||
const overlapsWithPositiveLength = (a: readonly [{ x: number; y: number }, { x: number; y: number }], b: readonly [{ x: number; y: number }, { x: number; y: number }]) => {
|
||||
const [a1, a2] = a; const [b1, b2] = b;
|
||||
const adx = a2.x - a1.x; const ady = a2.y - a1.y;
|
||||
const bdx = b2.x - b1.x; const bdy = b2.y - b1.y;
|
||||
if (adx * bdy !== ady * bdx || adx * (b1.y - a1.y) !== ady * (b1.x - a1.x)) return false;
|
||||
const axis = Math.abs(adx) >= Math.abs(ady) ? "x" : "y";
|
||||
const [aMin, aMax] = [a1[axis], a2[axis]].sort((x, y) => x - y);
|
||||
const [bMin, bMax] = [b1[axis], b2[axis]].sort((x, y) => x - y);
|
||||
return Math.min(aMax, bMax) > Math.max(aMin, bMin);
|
||||
};
|
||||
for (const redSegment of segments[0]) for (const blueSegment of segments[1]) {
|
||||
assert.equal(overlapsWithPositiveLength(redSegment, blueSegment), false, "complete routes share a segment");
|
||||
}
|
||||
const dir = mkdtempSync(join(tmpdir(), "tube-map-shared-route-"));
|
||||
const file = join(dir, "shared.drawio");
|
||||
try {
|
||||
writeFileSync(file, diagramIRToDrawio(first), "utf8");
|
||||
const validation = validateConnectors(file) as { summary: { connectorShapeOverlaps: number } };
|
||||
assert.equal(validation.summary.connectorShapeOverlaps, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { DiagramIRV2, DiagramPoint } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
|
||||
export interface TubeStation { id: string; label: string; provenance?: Record<string, unknown> }
|
||||
export interface TubeLine { id: string; label: string; color: string; stations: readonly string[]; provenance?: Record<string, unknown> }
|
||||
export interface TubeMapInput { title: string; stations: readonly TubeStation[]; lines: readonly TubeLine[]; provenance?: Record<string, unknown> }
|
||||
|
||||
export function createTubeMap(input: TubeMapInput): DiagramIRV2 {
|
||||
if (!input || !Array.isArray(input.stations) || input.stations.length === 0) throw new Error("Tube map requires stations");
|
||||
if (!Array.isArray(input.lines)) throw new Error("Tube map lines must be an array");
|
||||
const stations: readonly TubeStation[] = input.stations;
|
||||
const lines: readonly TubeLine[] = input.lines;
|
||||
const stationIds = new Set(stations.map((station) => station.id));
|
||||
if (stationIds.size !== stations.length) throw new Error("Tube station IDs must be unique");
|
||||
for (const line of lines) {
|
||||
if (line.stations.length < 2) throw new Error(`Tube line ${line.id} requires at least two stations`);
|
||||
for (const id of line.stations) if (!stationIds.has(id)) throw new Error(`Tube line ${line.id} references unknown station ${id}`);
|
||||
if (!/^#[0-9A-Fa-f]{6}$/.test(line.color)) throw new Error(`Tube line ${line.id} color must use strict #RRGGBB format`);
|
||||
}
|
||||
const memberships = new Map(stations.map((station) => [station.id, [] as string[]]));
|
||||
lines.forEach((line) => line.stations.forEach((id) => memberships.get(id)!.push(line.id)));
|
||||
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); }));
|
||||
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 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) };
|
||||
});
|
||||
const edges = lines.flatMap((line, lineIndex) => line.stations.slice(0, -1).map((source, index) => {
|
||||
const routeCorridorY = 40 + lineIndex * 20;
|
||||
const target = line.stations[index + 1]; const a = centers.get(source)!; const b = centers.get(target)!;
|
||||
const laneOffset = 30 + Math.floor(lineIndex / 2) * 10;
|
||||
const approachX = (lineIndex % 2 === 0 ? 1 : -1) * laneOffset;
|
||||
const sourceApproachY = a.y + Math.sign(routeCorridorY - a.y) * laneOffset;
|
||||
const targetApproachY = b.y + Math.sign(routeCorridorY - b.y) * laneOffset;
|
||||
const waypoints = source === target
|
||||
? [{ x: a.x + 40, y: a.y }, { x: a.x + 40, y: a.y + 40 }, { x: a.x, y: a.y + 40 }]
|
||||
: [
|
||||
{ x: a.x + approachX, y: sourceApproachY },
|
||||
{ x: a.x + approachX, y: routeCorridorY },
|
||||
{ x: b.x + approachX, y: routeCorridorY },
|
||||
{ x: b.x + approachX, y: targetApproachY },
|
||||
];
|
||||
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" } };
|
||||
return validateDiagramIR(ir) as DiagramIRV2;
|
||||
}
|
||||
Reference in New Issue
Block a user