diff --git a/scripts/package.json b/scripts/package.json index 627aa00..ba3d991 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -14,7 +14,7 @@ }, "dependencies": { "@maxgraph/core": "^0.23.0", - "js-yaml": "^4.1.0", + "js-yaml": "^4.3.1", "jsdom": "^25.0.0", "pako": "^2.1.0" }, diff --git a/scripts/pnpm-lock.yaml b/scripts/pnpm-lock.yaml index 7c75cae..b494285 100644 --- a/scripts/pnpm-lock.yaml +++ b/scripts/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: ^0.23.0 version: 0.23.0 js-yaml: - specifier: ^4.1.0 - version: 4.3.0 + specifier: ^4.3.1 + version: 4.3.1 jsdom: specifier: ^25.0.0 version: 25.0.1 @@ -373,8 +373,8 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true jsdom@25.0.1: @@ -779,7 +779,7 @@ snapshots: is-potential-custom-element-name@1.0.1: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 diff --git a/scripts/src/services/profiles/buildup.test.ts b/scripts/src/services/profiles/buildup.test.ts new file mode 100644 index 0000000..e74deca --- /dev/null +++ b/scripts/src/services/profiles/buildup.test.ts @@ -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); +}); diff --git a/scripts/src/services/profiles/buildup.ts b/scripts/src/services/profiles/buildup.ts new file mode 100644 index 0000000..70d5cb8 --- /dev/null +++ b/scripts/src/services/profiles/buildup.ts @@ -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()])); + 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 }; +} diff --git a/scripts/src/services/profiles/c4.test.ts b/scripts/src/services/profiles/c4.test.ts new file mode 100644 index 0000000..8312b3b --- /dev/null +++ b/scripts/src/services/profiles/c4.test.ts @@ -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`); + } +}); diff --git a/scripts/src/services/profiles/c4.ts b/scripts/src/services/profiles/c4.ts new file mode 100644 index 0000000..52d419e --- /dev/null +++ b/scripts/src/services/profiles/c4.ts @@ -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; properties?: Record } +export interface C4Relationship { id: string; source: string; target: string; label?: string; provenance?: Record } +export interface C4Model { title: string; elements: readonly C4Element[]; relationships: readonly C4Relationship[]; provenance?: Record } + +function cloneRecord(value: Record | undefined): Record | 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(["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(); + 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): 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; +} diff --git a/scripts/src/services/profiles/compression.test.ts b/scripts/src/services/profiles/compression.test.ts new file mode 100644 index 0000000..4cd2780 --- /dev/null +++ b/scripts/src/services/profiles/compression.test.ts @@ -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); +}); diff --git a/scripts/src/services/profiles/compression.ts b/scripts/src/services/profiles/compression.ts new file mode 100644 index 0000000..3b9e653 --- /dev/null +++ b/scripts/src/services/profiles/compression.ts @@ -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()])); + 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(); 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(); + 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; +} diff --git a/scripts/src/services/profiles/index.test.ts b/scripts/src/services/profiles/index.test.ts new file mode 100644 index 0000000..223eda4 --- /dev/null +++ b/scripts/src/services/profiles/index.test.ts @@ -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"]); +}); diff --git a/scripts/src/services/profiles/index.ts b/scripts/src/services/profiles/index.ts new file mode 100644 index 0000000..75eee71 --- /dev/null +++ b/scripts/src/services/profiles/index.ts @@ -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"; diff --git a/scripts/src/services/profiles/runbook.test.ts b/scripts/src/services/profiles/runbook.test.ts new file mode 100644 index 0000000..1ed5fbc --- /dev/null +++ b/scripts/src/services/profiles/runbook.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createRunbookHtml } from "./runbook.js"; + +const GRAPH = { title: "Ops ", startId: "start", nodes: [{ id: "start", label: "Is it healthy? ", 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(" { + 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); +}); diff --git a/scripts/src/services/profiles/runbook.ts b/scripts/src/services/profiles/runbook.ts new file mode 100644 index 0000000..4bb5bfb --- /dev/null +++ b/scripts/src/services/profiles/runbook.ts @@ -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, "'"); } +function safeJson(value: unknown): string { return JSON.stringify(value).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 ` +${escapeHtml(graph.title)}

${escapeHtml(graph.title)}

`; +} diff --git a/scripts/src/services/profiles/sequence.test.ts b/scripts/src/services/profiles/sequence.test.ts new file mode 100644 index 0000000..b6b4082 --- /dev/null +++ b/scripts/src/services/profiles/sequence.test.ts @@ -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); +}); diff --git a/scripts/src/services/profiles/sequence.ts b/scripts/src/services/profiles/sequence.ts new file mode 100644 index 0000000..b1d22aa --- /dev/null +++ b/scripts/src/services/profiles/sequence.ts @@ -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 } +export interface SequenceMessage { id: string; from: string; to: string; label: string; type?: "call" | "return" | "async"; provenance?: Record } +export interface SequenceInput { title: string; participants: readonly SequenceParticipant[]; messages: readonly SequenceMessage[]; provenance?: Record } +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>(); + const activeCalls: Array<{ caller: string; callee: string }> = []; + const activations = new Map(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(); + 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; +} diff --git a/scripts/src/services/profiles/timelapse.test.ts b/scripts/src/services/profiles/timelapse.test.ts new file mode 100644 index 0000000..ccedf88 --- /dev/null +++ b/scripts/src/services/profiles/timelapse.test.ts @@ -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); +}); diff --git a/scripts/src/services/profiles/timelapse.ts b/scripts/src/services/profiles/timelapse.ts new file mode 100644 index 0000000..129a7bb --- /dev/null +++ b/scripts/src/services/profiles/timelapse.ts @@ -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 } +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 } +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).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 { + return new Map(ir.pages.flatMap((page) => page[kind].map((item) => [`${page.id}/${item.id}`, item] as const))); +} +function classify(previous: Map, current: Map): 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(), previousEdges = new Map(); + 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 }; +} diff --git a/scripts/src/services/profiles/tube-map.test.ts b/scripts/src/services/profiles/tube-map.test.ts new file mode 100644 index 0000000..6384ca5 --- /dev/null +++ b/scripts/src/services/profiles/tube-map.test.ts @@ -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(); + 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 }); + } +}); diff --git a/scripts/src/services/profiles/tube-map.ts b/scripts/src/services/profiles/tube-map.ts new file mode 100644 index 0000000..a5aadf1 --- /dev/null +++ b/scripts/src/services/profiles/tube-map.ts @@ -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 } +export interface TubeLine { id: string; label: string; color: string; stations: readonly string[]; provenance?: Record } +export interface TubeMapInput { title: string; stations: readonly TubeStation[]; lines: readonly TubeLine[]; provenance?: Record } + +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(); + lines.forEach((line, lineIndex) => line.stations.forEach((id) => { if (!firstLine.has(id)) firstLine.set(id, lineIndex); })); + const centers = new Map(); + 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; +}