From fa62a5fc4cdf127b919c90ca7451d24f7e25817c Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Thu, 3 Sep 2026 19:11:50 +0000 Subject: [PATCH 1/5] [verified] feat: add diagram toolbox capabilities --- scripts/package.json | 2 +- scripts/pnpm-lock.yaml | 10 +- scripts/src/actions/build/action.test.ts | 43 +++++ scripts/src/authoring/ir-to-drawio.ts | 17 +- .../shape-catalog/shape-catalog.test.ts | 34 ++++ .../services/shape-catalog/shape-catalog.ts | 98 ++++++++++++ .../src/services/themes/theme-service.test.ts | 133 ++++++++++++++++ scripts/src/services/themes/theme-service.ts | 148 ++++++++++++++++++ .../src/services/transforms/heatmap.test.ts | 74 +++++++++ scripts/src/services/transforms/heatmap.ts | 85 ++++++++++ .../src/services/transforms/relabel.test.ts | 33 ++++ scripts/src/services/transforms/relabel.ts | 35 +++++ .../src/services/transforms/reverse.test.ts | 40 +++++ scripts/src/services/transforms/reverse.ts | 62 ++++++++ .../services/transforms/semantic-diff.test.ts | 64 ++++++++ .../src/services/transforms/semantic-diff.ts | 100 ++++++++++++ 16 files changed, 971 insertions(+), 7 deletions(-) create mode 100644 scripts/src/services/shape-catalog/shape-catalog.test.ts create mode 100644 scripts/src/services/shape-catalog/shape-catalog.ts create mode 100644 scripts/src/services/themes/theme-service.test.ts create mode 100644 scripts/src/services/themes/theme-service.ts create mode 100644 scripts/src/services/transforms/heatmap.test.ts create mode 100644 scripts/src/services/transforms/heatmap.ts create mode 100644 scripts/src/services/transforms/relabel.test.ts create mode 100644 scripts/src/services/transforms/relabel.ts create mode 100644 scripts/src/services/transforms/reverse.test.ts create mode 100644 scripts/src/services/transforms/reverse.ts create mode 100644 scripts/src/services/transforms/semantic-diff.test.ts create mode 100644 scripts/src/services/transforms/semantic-diff.ts 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/actions/build/action.test.ts b/scripts/src/actions/build/action.test.ts index 4263645..7cce73b 100644 --- a/scripts/src/actions/build/action.test.ts +++ b/scripts/src/actions/build/action.test.ts @@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { JSDOM } from "jsdom"; import { parseAllPages } from "../../services/drawio-parser/parser.js"; import { run } from "./action.js"; @@ -101,3 +102,45 @@ test("build serializes every v2 page and honors explicit geometry and waypoints" rmSync(dir, { recursive: true, force: true }); } }); + +test("build emits parseable XML when generic model attributes collide with fixed attributes", () => { + const dir = mkdtempSync(join(tmpdir(), "drawio-build-model-attributes-test-")); + const input = join(dir, "model-attributes.yaml"); + const output = join(dir, "model-attributes.drawio"); + try { + writeFileSync(input, `version: 2 +pages: + - id: page + title: Page + layout: { type: manual, gridSize: 20 } + extensions: + $drawio: + attributes: + model:grid: "0" + model:gridSize: "99" + model:page: "0" + model:pageWidth: "1" + model:pageHeight: "2" + model:background: "#ffffff" + nodes: + - { id: node, label: Node, geometry: { x: 0, y: 0, width: 100, height: 60 } } + edges: [] +`, "utf8"); + run(input, 0, output); + + const xml = readFileSync(output, "utf8"); + const dom = new JSDOM(""); + const document = new dom.window.DOMParser().parseFromString(xml, "text/xml"); + assert.equal(document.querySelector("parsererror"), null); + const model = document.querySelector("mxGraphModel"); + assert.ok(model); + assert.equal(model.getAttribute("grid"), "1"); + assert.equal(model.getAttribute("gridSize"), "20"); + assert.equal(model.getAttribute("page"), "1"); + assert.equal(model.getAttribute("pageWidth"), "1169"); + assert.equal(model.getAttribute("pageHeight"), "827"); + assert.equal(model.getAttribute("background"), "#ffffff"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/scripts/src/authoring/ir-to-drawio.ts b/scripts/src/authoring/ir-to-drawio.ts index 81378fd..9abcb5e 100644 --- a/scripts/src/authoring/ir-to-drawio.ts +++ b/scripts/src/authoring/ir-to-drawio.ts @@ -6,6 +6,7 @@ import { layoutPage } from "../services/layout/layout-engine.js"; const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;"; const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;"; +const FIXED_MODEL_ATTRIBUTES = new Set(["grid", "gridSize", "page", "pageWidth", "pageHeight"]); function escapeXml(value: string): string { return value @@ -33,6 +34,19 @@ function dataAttributes(values: Record): string { .join(""); } +function rawAttributes(extensions: Record | undefined, prefix: string, reserved = new Set()): string { + const drawio = extensions?.$drawio; + const attributes = drawio && typeof drawio === "object" && !Array.isArray(drawio) + ? (drawio as Record).attributes + : undefined; + if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) return ""; + return Object.entries(attributes as Record) + .filter(([key, value]) => key.startsWith(prefix) && !reserved.has(key.slice(prefix.length)) && /^[A-Za-z_][\w:.-]*$/.test(key.slice(prefix.length)) + && (typeof value === "string" || typeof value === "number" || typeof value === "boolean")) + .map(([key, value]) => ` ${key.slice(prefix.length)}="${escapeXml(String(value))}"`) + .join(""); +} + function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } { if (node.geometry) return node.geometry; const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal"; @@ -91,7 +105,8 @@ function serializePage(input: DiagramPage): string { const pageWidth = Math.max(page.width ?? 1169, fit(extent.width)); const pageHeight = Math.max(page.height ?? 827, fit(extent.height)); const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions }); - return ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; + const modelAttributes = rawAttributes(page.extensions, "model:", FIXED_MODEL_ATTRIBUTES); + return ` \n \n \n \n \n${[...nodeCells, ...edgeCells].join("\n")}\n \n \n `; } export function diagramIRToDrawio(ir: DiagramIR): string { diff --git a/scripts/src/services/shape-catalog/shape-catalog.test.ts b/scripts/src/services/shape-catalog/shape-catalog.test.ts new file mode 100644 index 0000000..e68790c --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { OFFLINE_SHAPE_CATALOG, searchShapes } from "./shape-catalog.js"; + +test("offline catalog entries carry explicit public provenance and licensing", () => { + assert.ok(OFFLINE_SHAPE_CATALOG.length >= 6); + for (const entry of OFFLINE_SHAPE_CATALOG) { + assert.equal(entry.provenance.license, "Apache-2.0"); + assert.match(entry.provenance.sourceUrl, /^https:\/\//); + assert.match(entry.style, /shape=/); + } +}); + +test("shape search ranks exact, alias, and fuzzy matches deterministically", () => { + assert.deepEqual(searchShapes("database"), searchShapes("database")); + assert.equal(searchShapes("database").matches[0].id, "cylinder"); + assert.equal(searchShapes("database").matches[0].matchType, "alias"); + assert.equal(searchShapes("rhombus").matches[0].matchType, "exact"); + const fuzzy = searchShapes("proces box").matches[0]; + assert.equal(fuzzy.id, "rectangle"); + assert.equal(fuzzy.matchType, "fuzzy"); + assert.ok(fuzzy.confidence > 0 && fuzzy.confidence < 0.9); +}); + +test("shape search returns an explicit generic fallback for unknown or blank queries", () => { + for (const query of ["quantum-flibbertigibbet", " "]) { + const result = searchShapes(query); + assert.equal(result.matches.length, 0); + assert.equal(result.fallback.id, "rectangle"); + assert.equal(result.fallback.matchType, "fallback"); + assert.equal(result.fallback.confidence, 0); + } +}); diff --git a/scripts/src/services/shape-catalog/shape-catalog.ts b/scripts/src/services/shape-catalog/shape-catalog.ts new file mode 100644 index 0000000..e9c0610 --- /dev/null +++ b/scripts/src/services/shape-catalog/shape-catalog.ts @@ -0,0 +1,98 @@ +export interface ShapeProvenance { + sourceName: string; + sourceUrl: string; + license: "Apache-2.0"; + note: string; +} + +export interface ShapeCatalogEntry { + id: string; + name: string; + aliases: readonly string[]; + style: string; + provenance: ShapeProvenance; +} + +export type ShapeMatchType = "exact" | "alias" | "fuzzy" | "fallback"; +export interface ShapeSearchMatch extends ShapeCatalogEntry { + confidence: number; + matchType: ShapeMatchType; +} +export interface ShapeSearchResult { + query: string; + matches: ShapeSearchMatch[]; + fallback: ShapeSearchMatch; +} + +const PROVENANCE: ShapeProvenance = { + sourceName: "diagrams.net generic built-in shape vocabulary", + sourceUrl: "https://github.com/jgraph/drawio", + license: "Apache-2.0", + note: "Hand-curated metadata for generic native Draw.io shapes; no third-party assets are bundled.", +}; + +export const OFFLINE_SHAPE_CATALOG: readonly ShapeCatalogEntry[] = Object.freeze([ + entry("actor", "Actor", ["person", "user", "human"], "shape=umlActor;verticalLabelPosition=bottom;verticalAlign=top;"), + entry("cloud", "Cloud", ["internet", "external cloud"], "shape=cloud;"), + entry("cylinder", "Cylinder", ["database", "data store", "storage"], "shape=cylinder;"), + entry("document", "Document", ["file", "report", "paper"], "shape=document;"), + entry("ellipse", "Ellipse", ["oval", "start", "end"], "shape=ellipse;"), + entry("hexagon", "Hexagon", ["preparation", "compute"], "shape=hexagon;"), + entry("rectangle", "Rectangle", ["process", "box", "generic"], "shape=rectangle;"), + entry("rhombus", "Rhombus", ["decision", "diamond", "choice"], "shape=rhombus;"), +]); + +function entry(id: string, name: string, aliases: readonly string[], style: string): ShapeCatalogEntry { + return Object.freeze({ id, name, aliases: Object.freeze([...aliases]), style, provenance: Object.freeze({ ...PROVENANCE }) }); +} + +function normalize(value: string): string { + return value.trim().toLocaleLowerCase("en-US").replace(/[_-]+/g, " ").replace(/\s+/g, " "); +} + +function similarity(left: string, right: string): number { + if (left === right) return 1; + const a = [...left]; + const b = [...right]; + if (a.length === 0 || b.length === 0) return 0; + const previous = Array.from({ length: b.length + 1 }, (_, index) => index); + for (let row = 1; row <= a.length; row += 1) { + const current = [row]; + for (let column = 1; column <= b.length; column += 1) { + current[column] = Math.min( + current[column - 1] + 1, + previous[column] + 1, + previous[column - 1] + (a[row - 1] === b[column - 1] ? 0 : 1), + ); + } + previous.splice(0, previous.length, ...current); + } + return 1 - previous[b.length] / Math.max(a.length, b.length); +} + +function match(entryValue: ShapeCatalogEntry, query: string): ShapeSearchMatch | undefined { + const id = normalize(entryValue.id); + const name = normalize(entryValue.name); + const aliases = entryValue.aliases.map(normalize); + if (query === id || query === name) return { ...entryValue, confidence: 1, matchType: "exact" }; + if (aliases.includes(query)) return { ...entryValue, confidence: 0.95, matchType: "alias" }; + const terms = [id, name, ...aliases]; + const score = Math.max(...terms.map((term) => similarity(query, term))); + if (score < 0.45) return undefined; + return { ...entryValue, confidence: Number((score * 0.85).toFixed(6)), matchType: "fuzzy" }; +} + +export function searchShapes(rawQuery: string, limit = 5): ShapeSearchResult { + if (!Number.isInteger(limit) || limit < 1) throw new Error("Shape search limit must be a positive integer"); + const query = normalize(rawQuery); + const generic = OFFLINE_SHAPE_CATALOG.find(({ id }) => id === "rectangle")!; + const fallback: ShapeSearchMatch = { ...generic, confidence: 0, matchType: "fallback" }; + const matches = query === "" ? [] : OFFLINE_SHAPE_CATALOG + .map((candidate) => match(candidate, query)) + .filter((candidate): candidate is ShapeSearchMatch => candidate !== undefined) + .sort((left, right) => right.confidence - left.confidence || left.id.localeCompare(right.id, "en-US")) + .slice(0, limit); + return { query: rawQuery, matches, fallback }; +} + +export const searchShapeCatalog = searchShapes; diff --git a/scripts/src/services/themes/theme-service.test.ts b/scripts/src/services/themes/theme-service.test.ts new file mode 100644 index 0000000..6aa24b6 --- /dev/null +++ b/scripts/src/services/themes/theme-service.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { diagramIRToDrawio } from "../../authoring/ir-to-drawio.js"; +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { BUILT_IN_THEMES, applyTheme, contrastRatio, validateTheme } from "./theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ + id: "p", title: "Page", + nodes: [ + { id: "service", label: "Service", kind: "service", style: "shape=hexagon;fillColor=#123456;rounded=1;" }, + { id: "transparent", label: "Transparent", kind: "container", style: "swimlane;fillColor=none;horizontal=0;" }, + ], + edges: [{ id: "edge", source: "service", target: "transparent", style: "edgeStyle=orthogonalEdgeStyle;dashed=1;" }], + }], +}; + +test("built-in themes validate and meet role contrast requirements", () => { + assert.deepEqual(Object.keys(BUILT_IN_THEMES), ["default", "corporate", "dark", "colorblind-safe", "handdrawn"]); + for (const theme of Object.values(BUILT_IN_THEMES)) { + assert.doesNotThrow(() => validateTheme(theme)); + assert.ok(contrastRatio(theme.node.fontColor, theme.node.fillColor) >= 4.5, theme.name); + } +}); + +test("built-in theme registry and nested role styles reject mutation", () => { + const dark = BUILT_IN_THEMES.dark; + assert.ok(Object.isFrozen(dark)); + assert.ok(Object.isFrozen(dark.node)); + assert.ok(Object.isFrozen(dark.container)); + assert.ok(Object.isFrozen(dark.edge)); + assert.throws(() => { (dark.node as { fillColor: string }).fillColor = "#FFFFFF"; }, TypeError); + assert.equal(BUILT_IN_THEMES.dark.node.fillColor, "#243447"); +}); + +test("theme application is deterministic, immutable, role based, and preserves structural style", () => { + const snapshot = structuredClone(IR); + const first = applyTheme(IR, "corporate"); + assert.deepEqual(first, applyTheme(IR, "corporate")); + assert.deepEqual(IR, snapshot); + assert.match(first.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.pages[0].nodes[0].style!, /rounded=1/); + assert.match(first.pages[0].nodes[1].style!, /fillColor=none/); + assert.match(first.pages[0].nodes[1].style!, /swimlane/); + assert.match(first.pages[0].edges[0].style!, /edgeStyle=orthogonalEdgeStyle/); + assert.doesNotMatch(first.pages[0].nodes[0].style!, /rounded=0/); +}); + +test("theme result is deeply isolated from source objects", () => { + const source = structuredClone(IR); + source.assets = { icons: [{ name: "api" }] }; + source.pages[0].nodes[0].properties = { config: { retries: 3 } }; + source.pages[0].edges[0].extensions = { route: { owner: "network" } }; + const snapshot = structuredClone(source); + + const themed = applyTheme(source, "corporate"); + ((themed.assets!.icons as Array<{ name: string }>)[0]).name = "changed"; + ((themed.pages[0].nodes[0].properties!.config as { retries: number })).retries = 9; + ((themed.pages[0].edges[0].extensions!.route as { owner: string })).owner = "changed"; + + assert.deepEqual(source, snapshot); +}); + +test("theme application emits each visual style key exactly once", () => { + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=#000000;fillColor=#FFFFFF;strokeColor=#000000;strokeColor=#FFFFFF;fontColor=#000000;fontColor=#FFFFFF;sketch=0;sketch=0;"; + + const style = applyTheme(source, "handdrawn").pages[0].nodes[0].style!; + + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + assert.equal(style.split(";").filter((token) => token.startsWith(`${key}=`)).length, 1, style); + } + assert.match(style, /fillColor=#FFFDF5;/); + assert.match(style, /strokeColor=#444444;/); + assert.match(style, /fontColor=#222222;/); + assert.match(style, /sketch=1;/); +}); + +test("theme application preserves page extensions and serializes its canvas background", () => { + const source = structuredClone(IR); + source.pages[0].extensions = { owner: "platform", $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25" } } }; + + const themed = applyTheme(source, "dark"); + const xml = diagramIRToDrawio(themed); + + assert.deepEqual(themed.pages[0].extensions, { + owner: "platform", + $drawio: { attributes: { custom: "kept", "model:pageScale": "1.25", "model:background": "#111827" } }, + }); + assert.match(xml, /]* background="#111827"/); + assert.match(xml, /]* pageScale="1.25"/); + assert.match(xml, /data-extensions="\{"\$drawio":\{"attributes":\{"custom":"kept","model:background":"#111827","model:pageScale":"1.25"\}\},"owner":"platform"\}"/); +}); + +test("transparent nodes use a readable dark font on a light canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.dark); + custom.name = "custom-light-canvas"; + custom.backgroundColor = "#FFFFFF"; + custom.edge.fontColor = "#000000"; + + const style = applyTheme(IR, custom).pages[0].nodes[1].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#000000;/); + assert.ok(contrastRatio("#000000", custom.backgroundColor) >= 4.5); +}); + +test("transparent nodes use a readable light font on a dark canvas", () => { + const custom = structuredClone(BUILT_IN_THEMES.default); + custom.name = "custom-dark-canvas"; + custom.backgroundColor = "#000000"; + custom.edge.fontColor = "#FFFFFF"; + + const source = structuredClone(IR); + source.pages[0].nodes[0].style = "shape=hexagon;fillColor=none;rounded=1;"; + const style = applyTheme(source, custom).pages[0].nodes[0].style!; + + assert.match(style, /fillColor=none;/); + assert.match(style, /fontColor=#FFFFFF;/); + assert.ok(contrastRatio("#FFFFFF", custom.backgroundColor) >= 4.5); +}); + +test("theme validation rejects invalid colors and insufficient contrast", () => { + const invalidColor = structuredClone(BUILT_IN_THEMES.default); + invalidColor.node.fillColor = "red"; + assert.throws(() => validateTheme(invalidColor), /color/i); + const lowContrast = structuredClone(BUILT_IN_THEMES.default); + lowContrast.node.fontColor = "#777777"; + lowContrast.node.fillColor = "#777777"; + assert.throws(() => validateTheme(lowContrast), /contrast/i); +}); diff --git a/scripts/src/services/themes/theme-service.ts b/scripts/src/services/themes/theme-service.ts new file mode 100644 index 0000000..901a8f8 --- /dev/null +++ b/scripts/src/services/themes/theme-service.ts @@ -0,0 +1,148 @@ +import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; + +export type BuiltInThemeName = "default" | "corporate" | "dark" | "colorblind-safe" | "handdrawn"; + +export interface ThemeRoleStyle { + fillColor: string; + strokeColor: string; + fontColor: string; +} + +export interface ThemeDefinition { + name: BuiltInThemeName | (string & {}); + backgroundColor: string; + node: ThemeRoleStyle; + container: ThemeRoleStyle; + edge: Pick; + sketch?: boolean; +} + +function deepFreeze(value: T): T { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const nested of Object.values(value as Record)) deepFreeze(nested); + Object.freeze(value); + } + return value; +} + +export const BUILT_IN_THEMES: Readonly> = deepFreeze({ + default: theme("default", "#FFFFFF", ["#FFFFFF", "#4A5568", "#1A202C"], ["#F7FAFC", "#718096", "#1A202C"], ["#4A5568", "#1A202C"]), + corporate: theme("corporate", "#FFFFFF", ["#E8F0FE", "#1F5AA6", "#102A43"], ["#F5F8FC", "#526D82", "#102A43"], ["#1F5AA6", "#102A43"]), + dark: theme("dark", "#111827", ["#243447", "#94A3B8", "#FFFFFF"], ["#1F2937", "#64748B", "#FFFFFF"], ["#CBD5E1", "#FFFFFF"]), + "colorblind-safe": theme("colorblind-safe", "#FFFFFF", ["#FFF4CC", "#0072B2", "#1A1A1A"], ["#E8F3F8", "#009E73", "#1A1A1A"], ["#0072B2", "#1A1A1A"]), + handdrawn: { ...theme("handdrawn", "#FFFDF5", ["#FFFDF5", "#444444", "#222222"], ["#F6F1E5", "#555555", "#222222"], ["#444444", "#222222"]), sketch: true }, +}); + +function theme(name: BuiltInThemeName, backgroundColor: string, node: [string, string, string], container: [string, string, string], edge: [string, string]): ThemeDefinition { + return { + name, + backgroundColor, + node: { fillColor: node[0], strokeColor: node[1], fontColor: node[2] }, + container: { fillColor: container[0], strokeColor: container[1], fontColor: container[2] }, + edge: { strokeColor: edge[0], fontColor: edge[1] }, + }; +} + +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +export function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => { + if (!HEX_COLOR.test(color)) throw new Error(`Invalid color: ${color}`); + const values = [1, 3, 5].map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4); + return values[0] * 0.2126 + values[1] * 0.7152 + values[2] * 0.0722; + }; + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +export function validateTheme(themeDefinition: ThemeDefinition): ThemeDefinition { + if (!themeDefinition || typeof themeDefinition.name !== "string" || themeDefinition.name.trim() === "") throw new Error("Theme requires a name"); + const colors: Array<[string, string]> = [ + ["backgroundColor", themeDefinition.backgroundColor], + ["node.fillColor", themeDefinition.node?.fillColor], ["node.strokeColor", themeDefinition.node?.strokeColor], ["node.fontColor", themeDefinition.node?.fontColor], + ["container.fillColor", themeDefinition.container?.fillColor], ["container.strokeColor", themeDefinition.container?.strokeColor], ["container.fontColor", themeDefinition.container?.fontColor], + ["edge.strokeColor", themeDefinition.edge?.strokeColor], ["edge.fontColor", themeDefinition.edge?.fontColor], + ]; + for (const [path, color] of colors) if (typeof color !== "string" || !HEX_COLOR.test(color)) throw new Error(`Theme ${path} must be a #RRGGBB color`); + for (const role of ["node", "container"] as const) { + if (contrastRatio(themeDefinition[role].fontColor, themeDefinition[role].fillColor) < 4.5) throw new Error(`Theme ${role} text has insufficient contrast`); + } + if (contrastRatio(themeDefinition.edge.fontColor, themeDefinition.backgroundColor) < 4.5) throw new Error("Theme edge text has insufficient contrast"); + return themeDefinition; +} + +interface StyleEntry { key: string; value?: string } + +function parseStyle(style = ""): StyleEntry[] { + return style.split(";").filter(Boolean).map((token) => { + const separator = token.indexOf("="); + return separator < 0 ? { key: token } : { key: token.slice(0, separator), value: token.slice(separator + 1) }; + }); +} + +function themedStyle(style: string | undefined, colors: Partial, sketch: boolean, backgroundColor?: string): string { + const entries = parseStyle(style); + const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none"); + const visual = new Map(); + if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor); + if (colors.strokeColor) visual.set("strokeColor", colors.strokeColor); + if (colors.fontColor) { + let fontColor = colors.fontColor; + if (originalFillNone && backgroundColor && contrastRatio(fontColor, backgroundColor) < 4.5) { + fontColor = contrastRatio("#000000", backgroundColor) >= 4.5 ? "#000000" : "#FFFFFF"; + } + visual.set("fontColor", fontColor); + } + visual.set("sketch", sketch ? "1" : "0"); + const visualKeys = new Set(["fillColor", "strokeColor", "fontColor", "sketch"]); + const handled = new Set(); + const output = entries.flatMap((entry) => { + if (!visualKeys.has(entry.key)) return [entry]; + if (handled.has(entry.key)) return []; + handled.add(entry.key); + const replacement = visual.get(entry.key); + if (replacement === undefined) return [entry]; + visual.delete(entry.key); + return [{ key: entry.key, value: replacement }]; + }); + for (const key of ["fillColor", "strokeColor", "fontColor", "sketch"]) { + const value = visual.get(key); + if (value !== undefined) output.push({ key, value }); + } + return output.map(({ key, value }) => value === undefined ? key : `${key}=${value}`).join(";") + ";"; +} + +export function applyTheme(ir: DiagramIRV2, selected: BuiltInThemeName | ThemeDefinition): DiagramIRV2 { + const selectedTheme = typeof selected === "string" ? BUILT_IN_THEMES[selected] : selected; + if (!selectedTheme) throw new Error(`Unknown built-in theme: ${selected}`); + validateTheme(selectedTheme); + const isolated = structuredClone(ir); + const applyNode = (node: DiagramNode): DiagramNode => ({ + ...node, + style: themedStyle(node.style, node.kind === "container" ? selectedTheme.container : selectedTheme.node, selectedTheme.sketch === true, selectedTheme.backgroundColor), + }); + const applyEdge = (edge: DiagramEdge): DiagramEdge => ({ ...edge, style: themedStyle(edge.style, selectedTheme.edge, selectedTheme.sketch === true) }); + return { + ...isolated, + theme: selectedTheme.name, + pages: isolated.pages.map((page) => { + const extensions = page.extensions ?? {}; + const drawio = extensions.$drawio && typeof extensions.$drawio === "object" && !Array.isArray(extensions.$drawio) + ? extensions.$drawio as Record + : {}; + const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes) + ? drawio.attributes as Record + : {}; + return { + ...page, + extensions: { + ...extensions, + $drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } }, + }, + nodes: page.nodes.map(applyNode), + edges: page.edges.map(applyEdge), + }; + }), + }; +} diff --git a/scripts/src/services/transforms/heatmap.test.ts b/scripts/src/services/transforms/heatmap.test.ts new file mode 100644 index 0000000..eddfe79 --- /dev/null +++ b/scripts/src/services/transforms/heatmap.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { applyMetricsHeatmap } from "./heatmap.js"; +import { contrastRatio } from "../themes/theme-service.js"; + +const IR: DiagramIRV2 = { + version: 2, + pages: [{ id: "p", title: "Page", nodes: [ + { id: "low", label: "Low", style: "shape=hexagon;strokeColor=#000000;", geometry: { x: 0, y: 0, width: 100, height: 60 } }, + { id: "high", label: "High", style: "shape=ellipse;", geometry: { x: 200, y: 0, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "low", target: "high", waypoints: [{ x: 150, y: 30 }] }] }], +}; + +test("metrics heatmap is deterministic with legend metadata and preserves topology", () => { + const request = { metric: "latency_ms", min: 0, max: 100, values: { "p/low": 0, "p/high": 100 } }; + const snapshot = structuredClone(IR); + const first = applyMetricsHeatmap(IR, request); + assert.deepEqual(first, applyMetricsHeatmap(IR, request)); + assert.deepEqual(IR, snapshot); + assert.deepEqual(first.legend, { + metric: "latency_ms", min: 0, max: 100, + stops: [ + { value: 0, color: "#440154", fontColor: "#FFFFFF" }, + { value: 25, color: "#3B528B", fontColor: "#FFFFFF" }, + { value: 50, color: "#21918C", fontColor: "#1A1A1A" }, + { value: 75, color: "#5EC962", fontColor: "#1A1A1A" }, + { value: 100, color: "#FDE725", fontColor: "#1A1A1A" }, + ], + }); + assert.match(first.diagram.pages[0].nodes[0].style!, /shape=hexagon/); + assert.match(first.diagram.pages[0].nodes[0].style!, /fillColor=#440154/); + assert.match(first.diagram.pages[0].nodes[1].style!, /fillColor=#FDE725/); + assert.deepEqual(first.diagram.pages[0].edges, IR.pages[0].edges); + assert.deepEqual(first.diagram.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry })), IR.pages[0].nodes.map(({ id, geometry }) => ({ id, geometry }))); +}); + +test("metrics heatmap sets readable text for every palette stop", () => { + const result = applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, + values: { "p/low": 0, "p/high": 100 }, + }); + + for (const stop of result.legend.stops) { + assert.ok(contrastRatio(stop.fontColor, stop.color) >= 4.5, `${stop.fontColor} on ${stop.color}`); + } + assert.match(result.diagram.pages[0].nodes[0].style!, /fontColor=#FFFFFF/); + assert.match(result.diagram.pages[0].nodes[1].style!, /fontColor=#1A1A1A/); +}); + +test("metrics heatmap switches colors at the legend stop boundaries", () => { + const colorsAt = (low: number, high: number): string[] => applyMetricsHeatmap(IR, { + metric: "latency_ms", min: 0, max: 100, values: { "p/low": low, "p/high": high }, + }).diagram.pages[0].nodes.map((node) => /fillColor=(#[0-9A-F]{6})/i.exec(node.style!)?.[1] ?? ""); + + assert.deepEqual(colorsAt(24.999, 25), ["#440154", "#3B528B"]); + assert.deepEqual(colorsAt(49.999, 50), ["#3B528B", "#21918C"]); + assert.deepEqual(colorsAt(74.999, 75), ["#21918C", "#5EC962"]); + assert.deepEqual(colorsAt(99.999, 100), ["#5EC962", "#FDE725"]); +}); + +test("metrics heatmap rejects palettes without a readable text color", () => { + assert.throws(() => applyMetricsHeatmap(IR, { + metric: "x", min: 0, max: 1, values: {}, colors: ["#777777", "#777777"], + }), /no readable font color/i); +}); + +test("metrics heatmap rejects invalid bounds, colors, keys, and out-of-range values", () => { + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 1, max: 1, values: {} }), /min.*max/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { "p/low": 2 } }), /bounded/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: { missing: 0 } }), /unknown/i); + assert.throws(() => applyMetricsHeatmap(IR, { metric: "x", min: 0, max: 1, values: {}, colors: ["red"] }), /color/i); +}); diff --git a/scripts/src/services/transforms/heatmap.ts b/scripts/src/services/transforms/heatmap.ts new file mode 100644 index 0000000..173090c --- /dev/null +++ b/scripts/src/services/transforms/heatmap.ts @@ -0,0 +1,85 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface HeatmapRequest { + metric: string; + min: number; + max: number; + values: Readonly>; + colors?: readonly string[]; +} +export interface HeatmapLegendStop { value: number; color: string; fontColor: string } +export interface HeatmapLegend { metric: string; min: number; max: number; stops: HeatmapLegendStop[] } +export interface HeatmapResult { diagram: DiagramIRV2; legend: HeatmapLegend } + +export const DEFAULT_HEATMAP_COLORS = Object.freeze(["#440154", "#3B528B", "#21918C", "#5EC962", "#FDE725"]); +const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/; + +function setHeatmapColors(style: string | undefined, color: string, fontColor: string): string { + const entries = (style ?? "").split(";").filter(Boolean); + const replacements = new Map([["fillColor", color], ["fontColor", fontColor]]); + const targetKeys = new Set(replacements.keys()); + const output = entries.map((entry) => { + const key = entry.split("=", 1)[0]; + if (!targetKeys.has(key)) return entry; + const replacement = replacements.get(key); + if (replacement === undefined) return undefined; + replacements.delete(key); + return `${key}=${replacement}`; + }).filter((entry): entry is string => entry !== undefined); + for (const [key, value] of replacements) output.push(`${key}=${value}`); + return `${output.join(";")};`; +} + +function contrastRatio(left: string, right: string): number { + const luminance = (color: string): number => [1, 3, 5] + .map((offset) => Number.parseInt(color.slice(offset, offset + 2), 16) / 255) + .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4) + .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0); + const [high, low] = [luminance(left), luminance(right)].sort((a, b) => b - a); + return (high + 0.05) / (low + 0.05); +} + +const READABLE_FONT_COLORS = ["#FFFFFF", "#1A1A1A"] as const; + +function readableFontColor(background: string): string { + const color = READABLE_FONT_COLORS.find((candidate) => contrastRatio(candidate, background) >= 4.5); + if (!color) throw new Error(`Heatmap palette color ${background} has no readable font color`); + return color; +} + +function exactNumber(value: number): number { + return Number(value.toFixed(12)); +} + +export function applyMetricsHeatmap(ir: DiagramIRV2, request: HeatmapRequest): HeatmapResult { + if (typeof request.metric !== "string" || request.metric.trim() === "") throw new Error("Heatmap metric must be a non-empty string"); + if (!Number.isFinite(request.min) || !Number.isFinite(request.max) || request.min >= request.max) throw new Error("Heatmap min must be less than max"); + if (!request.values || typeof request.values !== "object" || Array.isArray(request.values)) throw new Error("Heatmap values must be an object"); + const colors = [...(request.colors ?? DEFAULT_HEATMAP_COLORS)]; + if (colors.length < 2 || colors.some((color) => typeof color !== "string" || !HEX_COLOR.test(color))) throw new Error("Heatmap colors must contain at least two #RRGGBB colors"); + const nodeKeys = new Set(ir.pages.flatMap((page) => page.nodes.map((node) => `${page.id}/${node.id}`))); + for (const [key, value] of Object.entries(request.values)) { + if (!nodeKeys.has(key)) throw new Error(`Heatmap value has unknown node: ${key}`); + if (typeof value !== "number" || !Number.isFinite(value) || value < request.min || value > request.max) { + throw new Error(`Heatmap value for ${key} must be numeric and bounded by min and max`); + } + } + const stops = colors.map((color, index) => ({ + value: exactNumber(request.min + (request.max - request.min) * index / (colors.length - 1)), + color, + fontColor: readableFontColor(color), + })); + const legend: HeatmapLegend = { metric: request.metric, min: request.min, max: request.max, stops }; + const diagram = structuredClone(ir); + for (const page of diagram.pages) for (const node of page.nodes) { + const value = request.values[`${page.id}/${node.id}`]; + if (value === undefined) continue; + const nextStopIndex = stops.findIndex((stop) => value < stop.value); + const colorIndex = nextStopIndex === -1 ? stops.length - 1 : Math.max(0, nextStopIndex - 1); + node.style = setHeatmapColors(node.style, colors[colorIndex], stops[colorIndex].fontColor); + } + diagram.extensions = { ...(diagram.extensions ?? {}), heatmap: legend }; + return { diagram, legend }; +} + +export const transformMetricsHeatmap = applyMetricsHeatmap; diff --git a/scripts/src/services/transforms/relabel.test.ts b/scripts/src/services/transforms/relabel.test.ts new file mode 100644 index 0000000..9f9fd68 --- /dev/null +++ b/scripts/src/services/transforms/relabel.test.ts @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { relabelDiagram } from "./relabel.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Original", + pages: [{ id: "p", title: "Page", layout: { type: "manual" }, nodes: [ + { id: "a", label: "A", style: "shape=hexagon;", geometry: { x: 10, y: 20, width: 100, height: 60 } }, + { id: "b", label: "B", style: "shape=ellipse;", geometry: { x: 200, y: 20, width: 100, height: 60 } }, + ], edges: [{ id: "flow", source: "a", target: "b", label: "old", style: "dashed=1;", waypoints: [{ x: 150, y: 50 }] }] }], +}; + +test("complete relabel changes only labels while preserving IDs, topology, layout, and style", () => { + const snapshot = structuredClone(IR); + const result = relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new" } }); + assert.deepEqual(result.pages[0].nodes.map(({ id, label }) => ({ id, label })), [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta" }]); + assert.equal(result.pages[0].edges[0].label, "new"); + const restored = structuredClone(result); + restored.pages[0].nodes[0].label = "A"; + restored.pages[0].nodes[1].label = "B"; + restored.pages[0].edges[0].label = "old"; + assert.deepEqual(restored, IR); + assert.deepEqual(IR, snapshot); +}); + +test("relabel requires a complete exact explicit map", () => { + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha" } }), /missing.*p\/b.*p\/flow/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "Alpha", "p/b": "Beta", "p/flow": "new", extra: "no" } }), /unknown.*extra/i); + assert.throws(() => relabelDiagram(IR, { mode: "complete", labels: { "p/a": "", "p/b": "Beta", "p/flow": "new" } }), /non-empty/i); +}); diff --git a/scripts/src/services/transforms/relabel.ts b/scripts/src/services/transforms/relabel.ts new file mode 100644 index 0000000..8de4f09 --- /dev/null +++ b/scripts/src/services/transforms/relabel.ts @@ -0,0 +1,35 @@ +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; + +export interface CompleteRelabelRequest { + mode: "complete"; + labels: Readonly>; +} + +export function relabelDiagram(ir: DiagramIRV2, request: CompleteRelabelRequest): DiagramIRV2 { + if (!request || request.mode !== "complete" || !request.labels || typeof request.labels !== "object" || Array.isArray(request.labels)) { + throw new Error("Relabel requires an explicit complete map mode"); + } + const required: string[] = []; + for (const page of ir.pages) { + for (const node of page.nodes) required.push(`${page.id}/${node.id}`); + for (const edge of page.edges) if (edge.label !== undefined) required.push(`${page.id}/${edge.id}`); + } + required.sort((left, right) => left.localeCompare(right, "en-US")); + const provided = Object.keys(request.labels).sort((left, right) => left.localeCompare(right, "en-US")); + const missing = required.filter((key) => !Object.hasOwn(request.labels, key)); + if (missing.length > 0) throw new Error(`Relabel map is missing: ${missing.join(", ")}`); + const unknown = provided.filter((key) => !required.includes(key)); + if (unknown.length > 0) throw new Error(`Relabel map has unknown keys: ${unknown.join(", ")}`); + for (const key of required) { + const label = request.labels[key]; + if (typeof label !== "string" || label.trim() === "") throw new Error(`Relabel value for ${key} must be a non-empty string`); + } + const output = structuredClone(ir); + for (const page of output.pages) { + for (const node of page.nodes) node.label = request.labels[`${page.id}/${node.id}`]; + for (const edge of page.edges) if (edge.label !== undefined) edge.label = request.labels[`${page.id}/${edge.id}`]; + } + return output; +} + +export const relabelDiagramIR = relabelDiagram; diff --git a/scripts/src/services/transforms/reverse.test.ts b/scripts/src/services/transforms/reverse.test.ts new file mode 100644 index 0000000..5b41925 --- /dev/null +++ b/scripts/src/services/transforms/reverse.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { diagramIRToMermaid, diagramIRToStructuredMarkdown } from "./reverse.js"; + +const IR: DiagramIRV2 = { + version: 2, + title: "Platform", + pages: [{ + id: "system", title: "System", + nodes: [ + { id: "a", label: "Client" }, + { id: "b", label: "API \"danger\"]\n%%{init: {}}%% [click](javascript:alert(1))" }, + ], + edges: [{ id: "call", source: "a", target: "b", label: "calls | inject" }], + }], +}; + +test("reverse Mermaid flowchart is deterministic and escapes untrusted labels", () => { + const output = diagramIRToMermaid(IR); + assert.equal(output, diagramIRToMermaid(IR)); + assert.match(output, /^flowchart LR/m); + assert.match(output, /n0\["Client"\]/); + assert.match(output, /n0 -->\|"calls | inject"\| n1/); + assert.doesNotMatch(output, /`; +} + +export interface DoctorBackend { status: "available" | "missing"; path?: string; mandatory: false } +export interface DoctorReport { networkRequired: false; backends: Record; capabilities: { semanticLifecycle: true; nativeDrawio: true; storyHtml: true } } + +function executable(name: string, pathValue: string): string | undefined { + for (const directory of pathValue.split(delimiter).filter(Boolean)) { + const candidate = join(directory, name); + if (!existsSync(candidate)) continue; + try { accessSync(candidate, constants.X_OK); return candidate; } catch { /* not executable */ } + } + return undefined; +} + +export function doctorReport(options: { path?: string; onLaunch?: () => void } = {}): DoctorReport { + const pathValue = options.path ?? process.env.PATH ?? ""; + const backend = (name: string): DoctorBackend => { const path = executable(name, pathValue); return path ? { status: "available", path, mandatory: false } : { status: "missing", mandatory: false }; }; + return { networkRequired: false, backends: { drawio: backend("drawio"), graphviz: backend("dot") }, capabilities: { semanticLifecycle: true, nativeDrawio: true, storyHtml: true } }; +} diff --git a/scripts/src/services/semantic-lifecycle/sync.test.ts b/scripts/src/services/semantic-lifecycle/sync.test.ts new file mode 100644 index 0000000..a2c0817 --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/sync.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { DiagramIRV2 } from "../../model/diagram-ir.js"; +import { syncDiagramIR } from "./sync.js"; + +const base: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [ + { id: "api", label: "API", kind: "service", properties: { owner: "a" }, geometry: { x: 10, y: 10, width: 100, height: 50 }, style: "fillColor=#fff;" }, + { id: "old", label: "Old", kind: "service", geometry: { x: 200, y: 10, width: 100, height: 50 } }, +], edges: [{ id: "uses", source: "api", target: "old", label: "uses" }] }] }; + +test("three-way sync preserves manual geometry/style, adds incoming IDs, and reports semantic conflicts", () => { + const manual = structuredClone(base); + manual.pages[0].nodes[0].geometry = { x: 90, y: 80, width: 140, height: 70 }; + manual.pages[0].nodes[0].style = "fillColor=#00ff00;"; + manual.pages[0].nodes[0].label = "API manual"; + const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", layout: { type: "manual" }, nodes: [ + { id: "api", label: "API incoming", kind: "service", properties: { owner: "b" } }, + { id: "new", label: "New", kind: "service", geometry: { x: 300, y: 10, width: 100, height: 50 } }, + ], edges: [{ id: "new-edge", source: "api", target: "new" }] }] }; + const result = syncDiagramIR(base, manual, incoming); + const api = result.ir.pages[0].nodes.find((node) => node.id === "api")!; + assert.deepEqual(api.geometry, { x: 90, y: 80, width: 140, height: 70 }); + assert.equal(api.style, "fillColor=#00ff00;"); + assert.equal(api.label, "API manual"); + assert.ok(result.added.includes("new")); + assert.ok(result.conflicts.some((conflict) => conflict.id === "api" && conflict.field === "label")); + const removed = result.ir.pages[0].nodes.find((node) => node.id === "old")!; + assert.equal(removed.properties?.lifecycleStatus, "removed"); + assert.match(removed.style!, /opacity=45/); +}); + +test("sync reports divergent add/add conflicts instead of silently choosing incoming", () => { + const manual = structuredClone(base); + manual.pages[0].nodes.push({ id: "worker", label: "Manual worker", kind: "service" }); + const incoming = structuredClone(base); + incoming.pages[0].nodes.push({ id: "worker", label: "Incoming worker", kind: "database" }); + + const result = syncDiagramIR(base, manual, incoming); + const conflict = result.conflicts.find((item) => item.id === "worker" && item.field === "$add"); + assert.ok(conflict); + assert.equal(conflict.base, undefined); + assert.equal((conflict.manual as { label: string }).label, "Manual worker"); + assert.equal((conflict.incoming as { label: string }).label, "Incoming worker"); + assert.equal(result.ir.pages[0].nodes.find((node) => node.id === "worker")?.label, "Manual worker"); +}); + +test("sync reports divergent edge add/add conflicts", () => { + const manual = structuredClone(base); + manual.pages[0].edges.push({ id: "new-link", source: "api", target: "old", label: "Manual" }); + const incoming = structuredClone(base); + incoming.pages[0].edges.push({ id: "new-link", source: "old", target: "api", label: "Incoming" }); + + const result = syncDiagramIR(base, manual, incoming); + assert.ok(result.conflicts.some((item) => item.id === "new-link" && item.field === "$add")); + assert.equal(result.ir.pages[0].edges.find((edge) => edge.id === "new-link")?.source, "api"); +}); + +test("sync only prunes removals when explicitly requested and is deterministic", () => { + const incoming: DiagramIRV2 = { version: 2, pages: [{ id: "p", title: "P", nodes: [{ id: "api", label: "API", kind: "service" }], edges: [] }] }; + const kept = syncDiagramIR(base, base, incoming); + assert.ok(kept.ir.pages[0].nodes.some((node) => node.id === "old")); + const pruned = syncDiagramIR(base, base, incoming, { prune: true }); + assert.equal(pruned.ir.pages[0].nodes.some((node) => node.id === "old"), false); + assert.equal(pruned.ir.pages[0].edges.length, 0); + assert.deepEqual(syncDiagramIR(base, base, incoming, { prune: true }), pruned); +}); + +test("sync preserves pages manually added after the base when incoming omits them", () => { + const manualPage = { id: "notes", title: "Notes", nodes: [{ id: "note", label: "Note" }], edges: [] }; + const manual: DiagramIRV2 = { ...structuredClone(base), pages: [...structuredClone(base.pages), manualPage] }; + const incoming = structuredClone(base); + + const result = syncDiagramIR(base, manual, incoming); + assert.deepEqual(result.ir.pages.find((page) => page.id === "notes"), manualPage); + assert.equal(result.removed.includes("notes"), false); +}); + +test("sync retains removed pages unless pruning is explicit", () => { + const oldPage = { id: "legacy", title: "Legacy", nodes: [{ id: "legacy-node", label: "Legacy" }], edges: [] }; + const withPage: DiagramIRV2 = { ...base, pages: [...base.pages, oldPage] }; + const incoming: DiagramIRV2 = { version: 2, pages: [structuredClone(base.pages[0])] }; + + const kept = syncDiagramIR(withPage, withPage, incoming); + const retainedPage = kept.ir.pages.find((page) => page.id === "legacy"); + assert.ok(retainedPage); + assert.equal(retainedPage.properties?.lifecycleStatus, "removed"); + assert.ok(kept.removed.includes("legacy")); + + const pruned = syncDiagramIR(withPage, withPage, incoming, { prune: true }); + assert.equal(pruned.ir.pages.some((page) => page.id === "legacy"), false); + assert.ok(pruned.removed.includes("legacy")); +}); diff --git a/scripts/src/services/semantic-lifecycle/sync.ts b/scripts/src/services/semantic-lifecycle/sync.ts new file mode 100644 index 0000000..484cc1f --- /dev/null +++ b/scripts/src/services/semantic-lifecycle/sync.ts @@ -0,0 +1,113 @@ +import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export interface SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown } +export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] } + +const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +function conflictValue(pageId: string, id: string, field: keyof T, base: T, manual: T, incoming: T, conflicts: SyncConflict[]): unknown { + const b = base[field]; const m = manual[field]; const n = incoming[field]; + if (!equal(m, b) && !equal(n, b) && !equal(m, n)) { conflicts.push({ pageId, id, field: String(field), base: b, manual: m, incoming: n }); return structuredClone(m); } + return structuredClone(!equal(m, b) ? m : n); +} + +function fadedStyle(style: string | undefined, edge = false): string { + const base = style ?? (edge ? "edgeStyle=orthogonalEdgeStyle;" : "whiteSpace=wrap;html=1;"); + return `${base}${base.endsWith(";") ? "" : ";"}dashed=1;opacity=${edge ? 35 : 45};strokeColor=#b85450;`; +} + +function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage, prune: boolean, conflicts: SyncConflict[], added: string[], removed: string[]): DiagramPage { + const baseNodes = new Map(base.nodes.map((item) => [item.id, item])); + const manualNodes = new Map(manual.nodes.map((item) => [item.id, item])); + const incomingNodes = new Map(incoming.nodes.map((item) => [item.id, item])); + const nodes: DiagramNode[] = []; + for (const incomingNode of incoming.nodes) { + const baseNode = baseNodes.get(incomingNode.id); const manualNode = manualNodes.get(incomingNode.id); + if (!baseNode) { + if (manualNode && !equal(manualNode, incomingNode)) { + conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$add", base: undefined, manual: structuredClone(manualNode), incoming: structuredClone(incomingNode) }); + nodes.push(structuredClone(manualNode)); + } else nodes.push(structuredClone(incomingNode)); + added.push(incomingNode.id); + continue; + } + if (!manualNode) { nodes.push(structuredClone(incomingNode)); continue; } + const node = structuredClone(incomingNode); + for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) { + const value = conflictValue(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts); + if (value === undefined) delete node[field]; else (node as unknown as Record)[field] = value; + } + if (manualNode.geometry) node.geometry = structuredClone(manualNode.geometry); else delete node.geometry; + if (manualNode.style !== undefined) node.style = manualNode.style; else delete node.style; + if (manualNode.width !== undefined) node.width = manualNode.width; + if (manualNode.height !== undefined) node.height = manualNode.height; + nodes.push(node); + } + for (const manualNode of manual.nodes) { + if (incomingNodes.has(manualNode.id)) continue; + if (!baseNodes.has(manualNode.id)) { nodes.push(structuredClone(manualNode)); continue; } + removed.push(manualNode.id); + if (!prune) nodes.push({ ...structuredClone(manualNode), style: fadedStyle(manualNode.style), properties: { ...(manualNode.properties ?? {}), lifecycleStatus: "removed" } }); + } + const keptNodeIds = new Set(nodes.map((node) => node.id)); + const baseEdges = new Map(base.edges.map((item) => [item.id, item])); + const manualEdges = new Map(manual.edges.map((item) => [item.id, item])); + const incomingEdges = new Map(incoming.edges.map((item) => [item.id, item])); + const edges: DiagramEdge[] = []; + for (const incomingEdge of incoming.edges) { + if (!keptNodeIds.has(incomingEdge.source) || !keptNodeIds.has(incomingEdge.target)) continue; + const baseEdge = baseEdges.get(incomingEdge.id); const manualEdge = manualEdges.get(incomingEdge.id); + if (!baseEdge) { + if (manualEdge && !equal(manualEdge, incomingEdge)) { + conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$add", base: undefined, manual: structuredClone(manualEdge), incoming: structuredClone(incomingEdge) }); + edges.push(structuredClone(manualEdge)); + } else edges.push(structuredClone(incomingEdge)); + added.push(incomingEdge.id); + continue; + } + if (!manualEdge) { edges.push(structuredClone(incomingEdge)); continue; } + const edge = structuredClone(incomingEdge); + for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) { + const value = conflictValue(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts); + if (value === undefined) delete edge[field]; else (edge as unknown as Record)[field] = value; + } + if (manualEdge.style !== undefined) edge.style = manualEdge.style; else delete edge.style; + if (manualEdge.waypoints !== undefined) edge.waypoints = structuredClone(manualEdge.waypoints); else delete edge.waypoints; + edges.push(edge); + } + for (const manualEdge of manual.edges) { + if (incomingEdges.has(manualEdge.id)) continue; + if (!baseEdges.has(manualEdge.id)) { if (keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push(structuredClone(manualEdge)); continue; } + removed.push(manualEdge.id); + if (!prune && keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push({ ...structuredClone(manualEdge), style: fadedStyle(manualEdge.style, true), properties: { ...(manualEdge.properties ?? {}), lifecycleStatus: "removed" } }); + } + return { ...structuredClone(incoming), nodes, edges, ...(manual.width !== undefined ? { width: manual.width } : {}), ...(manual.height !== undefined ? { height: manual.height } : {}) }; +} + +export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming: DiagramIRV2, options: { prune?: boolean } = {}): SyncResult { + validateDiagramIR(base); validateDiagramIR(manual); validateDiagramIR(incoming); + const conflicts: SyncConflict[] = []; const added: string[] = []; const removed: string[] = []; + const basePages = new Map(base.pages.map((page) => [page.id, page])); const manualPages = new Map(manual.pages.map((page) => [page.id, page])); + const pages = incoming.pages.map((page) => { + const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id); + if (!basePage || !manualPage) { added.push(page.id); return structuredClone(page); } + return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed); + }); + const incomingPageIds = new Set(incoming.pages.map((page) => page.id)); + for (const manualPage of manual.pages) { + if (incomingPageIds.has(manualPage.id)) continue; + if (!basePages.has(manualPage.id)) { + if (options.prune !== true) pages.push(structuredClone(manualPage)); + continue; + } + removed.push(manualPage.id); + if (options.prune !== true) pages.push({ + ...structuredClone(manualPage), + properties: { ...(manualPage.properties ?? {}), lifecycleStatus: "removed" }, + }); + } + const ir: DiagramIRV2 = { ...structuredClone(incoming), pages }; + validateDiagramIR(ir); + return { ir, added: [...new Set(added)].sort(), removed: [...new Set(removed)].sort(), conflicts: conflicts.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.id.localeCompare(b.id) || a.field.localeCompare(b.field)) }; +} -- 2.54.0 From bb378ebd636f4fa3b0b4ce0311db0f0a5a5bf3ae Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Thu, 3 Sep 2026 19:11:51 +0000 Subject: [PATCH 3/5] [verified] feat: add safe source importers --- scripts/package.json | 2 +- scripts/pnpm-lock.yaml | 10 +- .../services/source-importers/index.test.ts | 385 +++++++++ .../src/services/source-importers/index.ts | 745 ++++++++++++++++++ 4 files changed, 1136 insertions(+), 6 deletions(-) create mode 100644 scripts/src/services/source-importers/index.test.ts create mode 100644 scripts/src/services/source-importers/index.ts 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/source-importers/index.test.ts b/scripts/src/services/source-importers/index.test.ts new file mode 100644 index 0000000..3100215 --- /dev/null +++ b/scripts/src/services/source-importers/index.test.ts @@ -0,0 +1,385 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { importSource } from "./index.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +const textInput = (sourceKind: Parameters[0]["sourceKind"], path: string, text: string) => + importSource({ sourceKind, path, input: { type: "text", text } }); + +test("imports Python modules with exact line provenance", async () => { + const result = await textInput("python", "src/app.py", "import os\nfrom pkg.db import Client\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [ + ["src/app.py", "module"], ["os", "library"], ["pkg.db", "library"], + ]); + assert.equal(result.diagram.pages[0].edges[1].provenance?.line, 2); + assert.equal(validateDiagramIR(result.diagram), result.diagram); +}); + +test("imports JavaScript and TypeScript static and require dependencies", async () => { + const js = await textInput("javascript", "src/app.js", "import express from 'express';\nconst x = require('./local.js');\n"); + const ts = await textInput("typescript", "src/app.ts", "export { x } from '@scope/pkg';\nimport type { T } from './types.js';\n"); + assert.deepEqual(js.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["express", "./local.js"]); + assert.deepEqual(ts.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["@scope/pkg", "./types.js"]); + assert.ok(js.diagram.pages[0].nodes.every((n) => ["module", "library"].includes(n.kind ?? ""))); +}); + +test("JavaScript scanner ignores comments and strings while preserving real static imports", async () => { + const source = [ + "// import commented from 'commented';", + "/* require('blocked');", + " export { x } from 'also-blocked'; */", + "const text = \"import fake from 'inside-string'\";", + "const template = `require('inside-template')`;", + "const pattern = /import regexFake from 'inside-regex'/;", + "loader.require('member-call');", + "const metadata = { import: 'property', from: 'property-from' };", + "import real from 'real';", + "export { thing } from './actual.js';", + "const required = require(\"required\");", + "import type {", + " Value", + "} from './types.js';", + "// const dynamic = import(name);", + ].join("\n"); + const result = await textInput("typescript", "src/app.ts", source); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js", "required", "./types.js"]); + assert.deepEqual(result.diagnostics, []); +}); + +test("JavaScript scanner treats arrow-expression regex literals as inert without hiding division or imports", async () => { + const source = [ + "const matcher = () => /require('phantom')/;", + "const ratio = numerator / denominator;", + "import real from 'real';", + "const actual = require('./actual.js');", + ].join("\n"); + const result = await textInput("typescript", "src/regex.ts", source); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((node) => node.label), ["real", "./actual.js"]); + assert.deepEqual(result.diagnostics, []); +}); + +test("JavaScript scanner diagnoses unsupported dynamic and unterminated constructs", async () => { + const result = await textInput("javascript", "src/app.js", "const dynamic = import(name);\nconst template = `${load(name)}`;\n/* unterminated"); + assert.ok(result.diagnostics.length >= 3); + assert.ok(result.diagnostics.every((diagnostic) => diagnostic.code === "unknown-construct")); +}); + +test("imports Go single and grouped imports", async () => { + const result = await textInput("go", "cmd/main.go", "package main\nimport \"fmt\"\nimport (\n alias \"example.com/acme/lib\"\n _ \"net/http/pprof\"\n)\n"); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["fmt", "example.com/acme/lib", "net/http/pprof"]); + assert.equal(result.diagram.pages[0].edges[2].provenance?.line, 5); +}); + +test("imports Rust mod declarations and use roots without guessing macros", async () => { + const result = await textInput("rust", "src/lib.rs", "mod api;\nuse crate::db::Pool;\nuse serde::{Serialize, Deserialize};\ncustom!(unknown);\n"); + assert.deepEqual(result.diagram.pages[0].nodes.slice(1).map((n) => n.label), ["api", "crate::db", "serde"]); + assert.equal(result.diagnostics[0].code, "unknown-construct"); +}); + +test("imports Terraform resources and explicit references", async () => { + const result = await textInput("terraform", "main.tf", "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n vpc_id = aws_vpc.main.id\n}\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["resource-6177735f7375626e65742e776562", "resource-6177735f7670632e6d61696e", "reference"]]); + assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 3); +}); + +test("non-JavaScript scanners ignore inert constructs and diagnose unterminated regions", async () => { + const [python, unterminatedPython, go, rust, sql, terraform] = await Promise.all([ + textInput("python", "app.py", "'''\nimport phantom\n'''\nimport real\n"), + textInput("python", "broken.py", "\"\"\"unterminated\nimport phantom\n"), + textInput("go", "main.go", "package main\n/*\nimport \"phantom\"\n*/\nimport \"fmt\"\n/* unterminated\n"), + textInput("rust", "lib.rs", "/*\nmod phantom;\nuse fake::Thing;\n*/\nmod api;\nuse serde::Serialize;\n/* unterminated\n"), + textInput("sql", "schema.sql", "/*\nCREATE TABLE phantom (id INT REFERENCES ghost(id));\n*/\nCREATE TABLE real (id INT);\n/* unterminated\n"), + textInput("terraform", "main.tf", [ + "/*", "resource \"aws_vpc\" \"phantom\" {}", "*/", + "resource \"aws_vpc\" \"main\" {}", + "resource \"aws_subnet\" \"web\" {", + " description = \"aws_vpc.phantom.id\"", + " # fake = aws_vpc.phantom.id", + " vpc_id = aws_vpc.main.id", + "}", "/* unterminated", + ].join("\n")), + ]); + assert.deepEqual(python.diagram.pages[0].nodes.map((node) => node.label), ["app.py", "real"]); + assert.deepEqual(unterminatedPython.diagram.pages[0].nodes.map((node) => node.label), ["broken.py"]); + assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]); + assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]); + assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.equal(terraform.diagram.pages[0].edges.length, 1); + for (const result of [unterminatedPython, go, rust, sql, terraform]) { + assert.ok(result.diagnostics.some((diagnostic) => diagnostic.code === "unknown-construct")); + } +}); + +test("multiline literals mask phantom topology and diagnose only unterminated regions", async () => { + const [go, rust, sql, terraform] = await Promise.all([ + textInput("go", "main.go", [ + "package main", "var first = `", "import \"phantom\"", "`", "import \"fmt\"", + ].join("\n")), + textInput("rust", "lib.rs", [ + "const FIRST: &str = r\"", "mod phantom_one;", "\";", + "const SECOND: &str = r##\"", "use phantom_two::Thing;", "\"##;", + "mod api;", "use serde::Serialize;", + ].join("\n")), + textInput("sql", "schema.sql", [ + "CREATE TABLE real (id INT);", "INSERT INTO real(note) VALUES ('", + "CREATE TABLE phantom (id INT REFERENCES ghost(id));", "escaped '' quote", "');", + ].join("\n")), + textInput("terraform", "main.tf", [ + "resource \"aws_vpc\" \"main\" {}", "resource \"aws_subnet\" \"web\" {", + " user_data = <<-EOT", " resource \"aws_instance\" \"phantom\" {}", + " aws_vpc.phantom.id", " EOT", " vpc_id = aws_vpc.main.id", "}", + ].join("\n")), + ]); + assert.deepEqual(go.diagram.pages[0].nodes.map((node) => node.label), ["main.go", "fmt"]); + assert.deepEqual(rust.diagram.pages[0].nodes.map((node) => node.label), ["lib.rs", "api", "serde"]); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["real"]); + assert.deepEqual(terraform.diagram.pages[0].nodes.map((node) => node.label), ["aws_subnet.web", "aws_vpc.main"]); + assert.equal(terraform.diagram.pages[0].edges.length, 1); + for (const result of [go, rust, sql, terraform]) assert.deepEqual(result.diagnostics, []); + + const unterminated = await Promise.all([ + textInput("go", "broken.go", "package main\nvar value = `unterminated\nimport \"phantom\""), + textInput("rust", "broken.rs", "const VALUE: &str = r###\"unterminated\nmod phantom;"), + textInput("sql", "broken.sql", "CREATE TABLE real (id INT);\nINSERT INTO real(note) VALUES ('unterminated\nCREATE TABLE phantom (id INT);"), + textInput("terraform", "broken.tf", "resource \"aws_vpc\" \"main\" {}\nvalue = < node.label.includes("phantom")), false); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-construct"]); + } +}); + +test("imports Kubernetes relationships and redacts Secret payloads", async () => { + const objects = [ + { apiVersion: "v1", kind: "Secret", metadata: { name: "db-secret" }, data: { password: "c2VjcmV0" }, stringData: { token: "plain" } }, + { apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "db-secret" } }] }] } } } }, + { apiVersion: "v1", kind: "Service", metadata: { name: "api-svc" }, spec: { selector: { app: "api" } } }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Deployment/default/api", "Secret/default/db-secret", "Service/default/api-svc"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["uses-secret", "selects"]); + assert.equal(JSON.stringify(result).includes("c2VjcmV0"), false); + assert.equal(JSON.stringify(result).includes("plain"), false); + assert.equal(result.diagnostics[0].code, "secret-redacted"); +}); + +test("Kubernetes ignores secretRef-shaped data outside supported workload fields", async () => { + const objects = [ + { kind: "Secret", metadata: { name: "real" } }, + { kind: "Secret", metadata: { name: "phantom" } }, + { + kind: "Deployment", metadata: { name: "api" }, status: { secretRef: { name: "phantom" } }, + spec: { template: { spec: { containers: [{ envFrom: [{ secretRef: { name: "real" } }] }] } } }, + }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + const labelById = new Map(result.diagram.pages[0].nodes.map((node) => [node.id, node.label])); + assert.deepEqual(result.diagram.pages[0].edges.map((edge) => [edge.kind, labelById.get(edge.target)]), [ + ["uses-secret", "Secret/default/real"], + ]); +}); + +test("scopes Kubernetes identity, secret references, and non-empty Service selectors by namespace", async () => { + const objects = [ + { kind: "Secret", metadata: { name: "shared" } }, + { kind: "Secret", metadata: { namespace: "team-b", name: "shared" } }, + { kind: "Deployment", metadata: { name: "api" }, spec: { selector: { matchLabels: { selectorOnly: "wrong" } }, template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } }, + { kind: "Deployment", metadata: { namespace: "team-b", name: "api" }, spec: { template: { metadata: { labels: { app: "api" } }, spec: { containers: [{ envFrom: [{ secretRef: { name: "shared" } }] }] } } } }, + { kind: "Deployment", metadata: { name: "selector-only" }, spec: { selector: { matchLabels: { app: "api" } }, template: { metadata: { labels: { app: "other" } } } } }, + { kind: "Service", metadata: { name: "api" }, spec: { selector: { app: "api" } } }, + { kind: "Service", metadata: { name: "empty" }, spec: { selector: {} } }, + ]; + const result = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: objects } }); + const nodes = result.diagram.pages[0].nodes; + assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length); + assert.ok(nodes.some((node) => node.label === "Secret/default/shared")); + assert.ok(nodes.some((node) => node.label === "Secret/team-b/shared")); + const labelById = new Map(nodes.map((node) => [node.id, node.label])); + const relationships = result.diagram.pages[0].edges.map((edge) => [labelById.get(edge.source), edge.kind, labelById.get(edge.target)]); + assert.deepEqual(relationships, [ + ["Deployment/default/api", "uses-secret", "Secret/default/shared"], + ["Deployment/team-b/api", "uses-secret", "Secret/team-b/shared"], + ["Service/default/api", "selects", "Deployment/default/api"], + ]); +}); + +test("imports Docker Compose service dependencies and named volumes", async () => { + const result = await importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: { + services: { api: { depends_on: ["db"], volumes: ["data:/var/lib/app"] }, db: { image: "postgres" } }, volumes: { data: {} }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["api", "service"], ["db", "service"], ["data", "volume"]]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => e.kind), ["depends-on", "mounts"]); +}); + +test("imports SQL tables and foreign keys", async () => { + const result = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n user_id INT,\n FOREIGN KEY (user_id) REFERENCES users(id)\n);\n"); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["orders", "users"]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target, e.kind]), [["table-6f7264657273", "table-7573657273", "foreign-key"]]); + assert.equal(result.diagram.pages[0].edges[0].provenance?.line, 4); +}); + +test("orders importer output by Unicode code point without locale-sensitive comparison", async () => { + const sql = await textInput("sql", "schema.sql", "CREATE TABLE a (id INT);\nCREATE TABLE Z (id INT);\n"); + assert.deepEqual(sql.diagram.pages[0].nodes.map((node) => node.label), ["Z", "a"]); + const kubernetes = await importSource({ sourceKind: "kubernetes", path: "k8s.json", input: { type: "object", value: [ + { kind: "Service", metadata: { name: "a" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "Z" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "😀" }, spec: { selector: {} } }, + { kind: "Service", metadata: { name: "\uE000" }, spec: { selector: {} } }, + ] } }); + assert.deepEqual(kubernetes.diagram.pages[0].nodes.map((node) => node.label), ["Service/default/Z", "Service/default/a", "Service/default/\uE000", "Service/default/😀"]); +}); + +test("imports OpenAPI operations and schema references", async () => { + const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: { + openapi: "3.1.0", paths: { "/pets": { get: { operationId: "listPets", responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Pet" } } } } } } } }, + components: { schemas: { Pet: { type: "object" } } }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["GET /pets", "command"], ["Pet", "library"]]); + assert.equal(result.diagram.pages[0].edges[0].kind, "schema-reference"); +}); + +test("OpenAPI inherited schema names remain unknown references", async () => { + const result = await importSource({ sourceKind: "openapi", path: "openapi.json", input: { type: "object", value: { + paths: { "/unsafe": { get: { responses: { "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/toString" } } } } } } } }, + components: { schemas: {} }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((node) => node.label), ["GET /unsafe"]); + assert.deepEqual(result.diagram.pages[0].edges, []); + assert.deepEqual(result.diagnostics.map((diagnostic) => diagnostic.code), ["unknown-reference"]); +}); + +test("imports CI job DAG needs", async () => { + const result = await importSource({ sourceKind: "ci", path: ".github/workflows/ci.json", input: { type: "object", value: { + jobs: { deploy: { needs: ["build", "test"] }, test: { needs: "build" }, build: { "runs-on": "ubuntu-latest" } }, + } } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => [n.label, n.kind]), [["build", "command"], ["deploy", "command"], ["test", "command"]]); + assert.deepEqual(result.diagram.pages[0].edges.map((e) => [e.source, e.target]), [["command-6275696c64", "command-6465706c6f79"], ["command-74657374", "command-6465706c6f79"], ["command-6275696c64", "command-74657374"]]); +}); + +test("keeps generated IDs unique for normalized names, ambiguous composites, and repeated relationships", async () => { + const result = await importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: { + jobs: { + A: {}, + "A": {}, + a: {}, + "a-needs-b": {}, + "b-needs-c": { needs: ["a", "a"] }, + c: { needs: "a-needs-b" }, + }, + } } }); + const nodes = result.diagram.pages[0].nodes; + const edges = result.diagram.pages[0].edges; + assert.equal(new Set(nodes.map((node) => node.id)).size, nodes.length); + assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length); + assert.equal(edges.length, 3); + assert.doesNotThrow(() => validateDiagramIR(result.diagram)); +}); + +test("keeps sanitized IDs unique and reports duplicate logical definitions", async () => { + const openapi = await importSource({ sourceKind: "openapi", path: "api.json", input: { type: "object", value: { paths: { "/x": { get: {} } }, components: { schemas: { "A/B": {}, "A-B": {} } } } } }); + assert.equal(new Set(openapi.diagram.pages[0].nodes.map((n) => n.id)).size, openapi.diagram.pages[0].nodes.length); + const sql = await textInput("sql", "schema.sql", "CREATE TABLE users (id INT);\nCREATE TABLE users (id INT);\n"); + assert.equal(sql.diagnostics[0].code, "duplicate-id"); + assert.doesNotThrow(() => validateDiagramIR(openapi.diagram)); +}); + +test("disambiguates every repeated relationship occurrence deterministically", async () => { + const sqlText = "CREATE TABLE users (id INT PRIMARY KEY);\nCREATE TABLE orders (\n a INT REFERENCES users(id),\n b INT REFERENCES users(id)\n);\n"; + const terraformText = "resource \"aws_vpc\" \"main\" {}\nresource \"aws_subnet\" \"web\" {\n first = aws_vpc.main.id\n second = aws_vpc.main.id\n}\n"; + const composeValue = { services: { api: { depends_on: ["db", "db"], volumes: ["data:/one", "data:/two"] }, db: {} }, volumes: { data: {} } }; + const results = await Promise.all([ + textInput("sql", "schema.sql", sqlText), + textInput("terraform", "main.tf", terraformText), + importSource({ sourceKind: "docker-compose", path: "compose.json", input: { type: "object", value: composeValue } }), + ]); + const expectedEdgeCounts = [2, 2, 4]; + results.forEach((result, index) => { + const edges = result.diagram.pages[0].edges; + assert.equal(edges.length, expectedEdgeCounts[index]); + assert.equal(new Set(edges.map((edge) => edge.id)).size, edges.length); + assert.doesNotThrow(() => validateDiagramIR(result.diagram)); + }); + const again = await textInput("terraform", "main.tf", terraformText); + assert.deepEqual(again.diagram.pages[0].edges.map((edge) => edge.id), results[1].diagram.pages[0].edges.map((edge) => edge.id)); +}); + +test("rejects oversized and malformed inputs without executing object values", async () => { + await assert.rejects(() => importSource({ sourceKind: "python", path: "x.py", maxBytes: 4, input: { type: "text", text: "import os" } }), /exceeds.*4/i); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: { jobs: null } } }), /malformed CI/i); + let accessed = false; + const tagged = Object.create({ yamlTag: "!exec" }) as Record; + Object.defineProperty(tagged, "jobs", { enumerable: true, get() { accessed = true; return {}; } }); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: tagged } }), /plain JSON/i); + assert.equal(accessed, false); +}); + +test("rejects object input beyond the conservative nesting bound without stack overflow", async () => { + const root: Record = {}; + let cursor = root; + for (let depth = 0; depth < 10_000; depth += 1) { + const child: Record = {}; + cursor.child = child; + cursor = child; + } + await assert.rejects( + () => importSource({ sourceKind: "ci", path: "ci.json", input: { type: "object", value: root } }), + /nesting.*128/i, + ); +}); + +test("rejects file traversal and symlink escapes from the declared root", async () => { + const base = await mkdtemp(join(tmpdir(), "source-import-")); + const root = join(base, "root"); await mkdir(root); + await writeFile(join(base, "outside.py"), "import os\n"); + await symlink(join(base, "outside.py"), join(root, "escape.py")); + await assert.rejects(() => importSource({ sourceKind: "python", path: "../outside.py", input: { type: "file", root } }), /outside.*root|escape/i); + await assert.rejects(() => importSource({ sourceKind: "python", path: "escape.py", input: { type: "file", root } }), /outside.*root|symlink.*escape/i); +}); + +test("refuses even root-confined symlinks so substitution cannot change an opened source", async () => { + const root = await mkdtemp(join(tmpdir(), "source-no-follow-")); + await writeFile(join(root, "real.py"), "import safe\n"); + await symlink("real.py", join(root, "substituted.py")); + await assert.rejects( + () => importSource({ sourceKind: "python", path: "substituted.py", input: { type: "file", root } }), + /symlink|symbolic|safe source/i, + ); +}); + +test("ingests directories in deterministic path order with confined provenance", async () => { + const root = await mkdtemp(join(tmpdir(), "source-directory-")); + await mkdir(join(root, "src")); + await writeFile(join(root, "src", "b.py"), "import zlib\n"); + await writeFile(join(root, "src", "a.py"), "from pkg import x\n"); + const request = { sourceKind: "python" as const, path: "src", input: { type: "file" as const, root } }; + const first = await importSource(request); const second = await importSource(request); + assert.equal(JSON.stringify(first), JSON.stringify(second)); + assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "module").map((n) => n.label), ["src/a.py", "src/b.py"]); + assert.deepEqual(first.diagram.pages[0].nodes.filter((n) => n.kind === "library").map((n) => n.label), ["pkg", "zlib"]); + assert.equal(first.diagram.pages[0].edges[0].provenance?.path, "src/a.py"); + assert.doesNotThrow(() => validateDiagramIR(first.diagram)); +}); + +test("reports dynamic code imports as unknown constructs instead of guessing", async () => { + const python = await textInput("python", "dynamic.py", "module = __import__(name)\n"); + const javascript = await textInput("javascript", "dynamic.js", "const module = await import(name);\n"); + assert.equal(python.diagnostics[0].code, "unknown-construct"); + assert.equal(javascript.diagnostics[0].code, "unknown-construct"); + assert.deepEqual(python.diagram.pages[0].nodes.map((n) => n.label), ["dynamic.py"]); +}); + +test("loads YAML data without permitting custom tags", async () => { + const root = await mkdtemp(join(tmpdir(), "source-yaml-")); + await writeFile(join(root, "objects.yaml"), "apiVersion: v1\nkind: Service\nmetadata:\n name: api\nspec:\n selector:\n app: api\n"); + await writeFile(join(root, "tagged.yaml"), "jobs: !exec dangerous\n"); + const result = await importSource({ sourceKind: "kubernetes", path: "objects.yaml", input: { type: "file", root } }); + assert.deepEqual(result.diagram.pages[0].nodes.map((n) => n.label), ["Service/default/api"]); + await assert.rejects(() => importSource({ sourceKind: "ci", path: "tagged.yaml", input: { type: "file", root } }), /tag|malformed YAML/i); +}); diff --git a/scripts/src/services/source-importers/index.ts b/scripts/src/services/source-importers/index.ts new file mode 100644 index 0000000..5232af9 --- /dev/null +++ b/scripts/src/services/source-importers/index.ts @@ -0,0 +1,745 @@ +import { constants } from "node:fs"; +import { open, readdir, realpath } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { isAbsolute, relative, resolve } from "node:path"; + +import { JSON_SCHEMA, loadAll } from "js-yaml"; + +import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js"; +import { validateDiagramIR } from "../../model/diagram-ir.js"; + +export type SourceKind = "python" | "javascript" | "typescript" | "go" | "rust" | "terraform" | "kubernetes" | "docker-compose" | "sql" | "openapi" | "ci"; +export type SourceInput = { type: "text"; text: string } | { type: "object"; value: unknown } | { type: "file"; root: string }; +export interface SourceImportRequest { sourceKind: SourceKind; path: string; input: SourceInput; maxBytes?: number } +export interface ImportDiagnostic { code: string; severity: "warning" | "error"; message: string; path: string; line?: number } +export interface SourceImportResult { diagram: DiagramIRV2; diagnostics: ImportDiagnostic[]; provenance: { sourceKind: SourceKind; path: string } } + +function id(prefix: string, ...values: string[]): string { + const encoded = values.map((value) => Buffer.from(value, "utf8").toString("hex") || "0"); + return `${prefix}-${encoded.join(".")}`; +} + +function codePointCompare(left: string, right: string): number { + const leftPoints = [...left]; + const rightPoints = [...right]; + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index += 1) { + const difference = leftPoints[index].codePointAt(0)! - rightPoints[index].codePointAt(0)!; + if (difference !== 0) return difference; + } + return leftPoints.length - rightPoints.length; +} + +function diagramFor(request: SourceImportRequest, nodes: DiagramNode[], edges: DiagramEdge[]): DiagramIRV2 { + return validateDiagramIR({ version: 2, title: request.path, pages: [{ id: "source-import", title: request.path, nodes, edges, layout: { type: "layered", direction: "horizontal" } }], provenance: { sourceKind: request.sourceKind, path: request.path } }) as DiagramIRV2; +} + +function codeImports(kind: "python" | "go", text: string): Array<{ name: string; line: number }> { + const imports: Array<{ name: string; line: number }> = []; + let goBlock = false; + text.split(/\r?\n/).forEach((line, index) => { + let name: string | undefined; + if (kind === "python") { + const match = line.match(/^\s*(?:import\s+([A-Za-z_][\w.]*)|from\s+([A-Za-z_][\w.]*)\s+import\s+)/); + name = match?.[1] ?? match?.[2]; + } else if (kind === "go") { + if (/^\s*import\s*\(\s*$/.test(line)) { goBlock = true; return; } + if (goBlock && /^\s*\)/.test(line)) { goBlock = false; return; } + const match = goBlock ? line.match(/^\s*(?:[._A-Za-z]\w*\s+)?"([^"]+)"/) : line.match(/^\s*import\s+(?:[._A-Za-z]\w*\s+)?"([^"]+)"/); + name = match?.[1]; + } + if (name) imports.push({ name, line: index + 1 }); + }); + return imports; +} + +interface MaskedSource { text: string; unknownLines: number[] } + +function maskRange(characters: string[], start: number, end: number): void { + for (let index = start; index < end; index += 1) if (characters[index] !== "\n" && characters[index] !== "\r") characters[index] = " "; +} + +function maskPythonTripleStrings(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + const quote = text.startsWith("'''", offset) ? "'''" : text.startsWith('"""', offset) ? '"""' : undefined; + if (!quote) { offset += 1; continue; } + const start = offset; const startLine = line; + offset += 3; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text.startsWith(quote, offset)) { offset += 3; closed = true; break; } + offset += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +function maskComments(text: string, lineMarkers: string[], nestedBlock = false, maskedQuotes = "", rustRawStrings = false): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + const marker = lineMarkers.find((candidate) => text.startsWith(candidate, offset)); + if (marker) { + const start = offset; + while (offset < text.length && text[offset] !== "\n") offset += 1; + maskRange(characters, start, offset); + continue; + } + if (text.startsWith("/*", offset)) { + const start = offset; const startLine = line; + offset += 2; + let depth = 1; + while (offset < text.length && depth > 0) { + if (text[offset] === "\n") line += 1; + if (nestedBlock && text.startsWith("/*", offset)) { depth += 1; offset += 2; continue; } + if (text.startsWith("*/", offset)) { depth -= 1; offset += 2; continue; } + offset += 1; + } + maskRange(characters, start, offset); + if (depth > 0) unknownLines.push(startLine); + continue; + } + if (rustRawStrings && text[offset] === "r") { + let delimiterEnd = offset + 1; + while (text[delimiterEnd] === "#") delimiterEnd += 1; + if (text[delimiterEnd] === '"') { + const start = offset; const startLine = line; + const terminator = `"${"#".repeat(delimiterEnd - offset - 1)}`; + offset = delimiterEnd + 1; + const rawEnd = text.indexOf(terminator, offset); + const closed = rawEnd >= 0; + const next = closed ? rawEnd + terminator.length : text.length; + line += (text.slice(offset, next).match(/\n/g) ?? []).length; + offset = next; + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + continue; + } + } + if (text[offset] === "'" || text[offset] === '"' || text[offset] === "`") { + const quote = text[offset]; const start = offset; const startLine = line; + offset += 1; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && quote !== "`" && offset + 1 < text.length) { offset += 2; continue; } + if (quote === "'" && text[offset] === "'" && text[offset + 1] === "'") { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === quote) { offset += 1; closed = true; break; } + offset += 1; + } + if (maskedQuotes.includes(quote)) maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + continue; + } + offset += 1; + } + return { text: characters.join(""), unknownLines }; +} + +function maskTerraformHeredocs(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + const heredocOpening = /<<(-?)([A-Za-z_]\w*)[ \t]*(?:\r?\n)/y; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + if (text[offset] === "#" || text.startsWith("//", offset)) { + while (offset < text.length && text[offset] !== "\n") offset += 1; + continue; + } + if (text.startsWith("/*", offset)) { + const end = text.indexOf("*/", offset + 2); + const next = end < 0 ? text.length : end + 2; + line += (text.slice(offset, next).match(/\n/g) ?? []).length; + offset = next; + continue; + } + if (text[offset] === '"') { + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === '"') { offset += 1; break; } + offset += 1; + } + continue; + } + heredocOpening.lastIndex = offset; + const opening = heredocOpening.exec(text); + if (!opening) { offset += 1; continue; } + const start = offset; const startLine = line; + const indented = opening[1] === "-"; + const delimiter = opening[2]; + offset += opening[0].length; + line += 1; + let closed = false; + while (offset < text.length) { + const newline = text.indexOf("\n", offset); + const end = newline < 0 ? text.length : newline; + const candidate = text.slice(offset, end).replace(/\r$/, ""); + const expected = indented ? candidate.trim() : candidate.trimEnd(); + if (expected === delimiter && (indented || candidate === expected)) { + offset = end; + closed = true; + break; + } + offset = newline < 0 ? text.length : newline + 1; + if (newline >= 0) line += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +function maskTerraformStrings(text: string): MaskedSource { + const characters = text.split(""); + const unknownLines: number[] = []; + let offset = 0; let line = 1; + while (offset < text.length) { + if (text[offset] === "\n") { line += 1; offset += 1; continue; } + if (text[offset] !== '"') { offset += 1; continue; } + const start = offset; const startLine = line; + offset += 1; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") line += 1; + if (text[offset] === '"') { offset += 1; closed = true; break; } + offset += 1; + } + maskRange(characters, start, offset); + if (!closed) unknownLines.push(startLine); + } + return { text: characters.join(""), unknownLines }; +} + +interface JavaScriptToken { kind: "identifier" | "string" | "punctuation"; value: string; line: number } + +function scanJavaScriptImports(text: string): { imports: Array<{ name: string; line: number }>; unknownLines: number[] } { + const tokens: JavaScriptToken[] = []; + const unknownLines = new Set(); + let offset = 0; + let line = 1; + while (offset < text.length) { + const character = text[offset]; + if (character === "\n") { line += 1; offset += 1; continue; } + if (/\s/.test(character)) { offset += 1; continue; } + if (character === "/" && text[offset + 1] === "/") { + offset += 2; + while (offset < text.length && text[offset] !== "\n") offset += 1; + continue; + } + if (character === "/" && text[offset + 1] === "*") { + const startLine = line; + offset += 2; + let closed = false; + while (offset < text.length) { + if (text[offset] === "\n") line += 1; + if (text[offset] === "*" && text[offset + 1] === "/") { offset += 2; closed = true; break; } + offset += 1; + } + if (!closed) unknownLines.add(startLine); + continue; + } + if (character === "/") { + const previous = tokens[tokens.length - 1]; + const followsArrow = previous?.value === ">" && tokens[tokens.length - 2]?.value === "="; + const regexPrefix = !previous || followsArrow || (previous.kind === "punctuation" && "=([{,:;!&|?".includes(previous.value)) || (previous.kind === "identifier" && new Set(["return", "case", "throw", "typeof", "instanceof", "in", "of", "yield", "await"]).has(previous.value)); + if (regexPrefix) { + const startLine = line; + let inCharacterClass = false; + let closed = false; + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "\n") break; + if (text[offset] === "[") inCharacterClass = true; + else if (text[offset] === "]") inCharacterClass = false; + else if (text[offset] === "/" && !inCharacterClass) { offset += 1; closed = true; break; } + offset += 1; + } + while (closed && offset < text.length && /[A-Za-z]/.test(text[offset])) offset += 1; + if (!closed) unknownLines.add(startLine); + continue; + } + } + if (character === "'" || character === '"') { + const quote = character; + const startLine = line; + let value = ""; + offset += 1; + let closed = false; + while (offset < text.length) { + const child = text[offset]; + if (child === "\\" && offset + 1 < text.length) { value += child + text[offset + 1]; offset += 2; continue; } + if (child === quote) { offset += 1; closed = true; break; } + if (child === "\n") line += 1; + value += child; + offset += 1; + } + if (closed) tokens.push({ kind: "string", value, line: startLine }); + else unknownLines.add(startLine); + continue; + } + if (character === "`") { + const startLine = line; + let closed = false; + let hasExpression = false; + offset += 1; + while (offset < text.length) { + if (text[offset] === "\\" && offset + 1 < text.length) { offset += 2; continue; } + if (text[offset] === "$" && text[offset + 1] === "{") hasExpression = true; + if (text[offset] === "\n") line += 1; + if (text[offset] === "`") { offset += 1; closed = true; break; } + offset += 1; + } + if (hasExpression || !closed) unknownLines.add(startLine); + continue; + } + if (/[A-Za-z_$]/.test(character)) { + const start = offset; + offset += 1; + while (offset < text.length && /[A-Za-z0-9_$]/.test(text[offset])) offset += 1; + tokens.push({ kind: "identifier", value: text.slice(start, offset), line }); + continue; + } + tokens.push({ kind: "punctuation", value: character, line }); + offset += 1; + } + + const imports: Array<{ name: string; line: number }> = []; + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.kind !== "identifier") continue; + if (token.value === "require" && tokens[index - 1]?.value !== "." && tokens[index + 1]?.value === "(") { + const argument = tokens[index + 2]; + if (argument?.kind === "string" && tokens[index + 3]?.value === ")") imports.push({ name: argument.value, line: token.line }); + else unknownLines.add(token.line); + continue; + } + if (token.value !== "import" && token.value !== "export") continue; + if (tokens[index - 1]?.value === ".") continue; + const next = tokens[index + 1]; + if (token.value === "import" && next?.value === "(") { unknownLines.add(token.line); continue; } + const previous = tokens[index - 1]; + const statementPosition = !previous || previous.value === ";" || previous.value === "}" || previous.line < token.line; + if (!statementPosition) continue; + if (token.value === "import" && next?.kind === "string") { imports.push({ name: next.value, line: token.line }); continue; } + let found = false; + for (let cursor = index + 1; cursor < tokens.length && cursor <= index + 256; cursor += 1) { + if (tokens[cursor].value === ";") break; + if (tokens[cursor].value === "from" && tokens[cursor + 1]?.kind === "string") { + imports.push({ name: tokens[cursor + 1].value, line: token.line }); + found = true; + break; + } + } + if (!found && token.value === "import") unknownLines.add(token.line); + } + return { imports, unknownLines: [...unknownLines].sort((a, b) => a - b) }; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : undefined; +} + +function objectName(value: unknown): string | undefined { + const name = record(record(value)?.metadata)?.name; + return typeof name === "string" && name.length > 0 ? name : undefined; +} + +function collectWorkloadSecretRefs(podSpecValue: unknown, refs: Set): void { + const podSpec = record(podSpecValue); + if (!podSpec) return; + const addName = (value: unknown): void => { + const name = record(value)?.name; + if (typeof name === "string" && name.length > 0) refs.add(name); + }; + for (const pullSecret of Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []) addName(pullSecret); + for (const containerKey of ["initContainers", "containers", "ephemeralContainers"]) { + for (const containerValue of Array.isArray(podSpec[containerKey]) ? podSpec[containerKey] as unknown[] : []) { + const container = record(containerValue); + if (!container) continue; + for (const source of Array.isArray(container.envFrom) ? container.envFrom : []) addName(record(source)?.secretRef); + for (const environment of Array.isArray(container.env) ? container.env : []) addName(record(record(environment)?.valueFrom)?.secretKeyRef); + } + } + for (const volumeValue of Array.isArray(podSpec.volumes) ? podSpec.volumes : []) { + const volume = record(volumeValue); + const secretName = record(volume?.secret)?.secretName; + if (typeof secretName === "string" && secretName.length > 0) refs.add(secretName); + const sources = record(volume?.projected)?.sources; + for (const source of Array.isArray(sources) ? sources : []) addName(record(source)?.secret); + } +} + +function collectSchemaRefs(value: unknown, refs: Set, seen = new WeakSet()): void { + if (value === null || typeof value !== "object" || seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { value.forEach((item) => collectSchemaRefs(item, refs, seen)); return; } + for (const [key, child] of Object.entries(value as Record)) { + if (key === "$ref" && typeof child === "string" && child.startsWith("#/components/schemas/")) refs.add(child.slice("#/components/schemas/".length)); + else collectSchemaRefs(child, refs, seen); + } +} + +function validateBoundedInput(request: SourceImportRequest): void { + const limit = request.maxBytes ?? 1_048_576; + if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 8_388_608) throw new Error("maxBytes must be an integer between 1 and 8388608"); + if (!request.path || request.path.includes("\0")) throw new Error("Source path must be a non-empty safe string"); + if (request.input.type === "file") return; + if (request.input.type === "text") { + const size = new TextEncoder().encode(request.input.text).byteLength; + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + return; + } + let size = 0; + const seen = new WeakSet(); + const pending: Array<{ value: unknown; depth: number }> = [{ value: request.input.value, depth: 0 }]; + while (pending.length > 0) { + const { value, depth } = pending.pop()!; + if (depth > 128) throw new Error("Object input nesting exceeds maximum depth 128"); + if (value === null || typeof value === "boolean") size += 4; + else if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("Object input must contain finite JSON values"); + size += 8; + } else if (typeof value === "string") size += new TextEncoder().encode(value).byteLength; + else { + if (typeof value !== "object") throw new Error("Object input must contain plain JSON values"); + if (seen.has(value)) throw new Error("Object input must contain acyclic plain JSON values"); + seen.add(value); + if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Object input must contain plain JSON objects"); + const descriptors = Object.getOwnPropertyDescriptors(value); + for (const [key, descriptor] of Object.entries(descriptors)) { + if (key === "length") continue; + if (!("value" in descriptor)) throw new Error("Object input must contain plain JSON data properties"); + size += new TextEncoder().encode(key).byteLength; + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + pending.push({ value: descriptor.value, depth: depth + 1 }); + } + } + if (size > limit) throw new Error(`Source input exceeds maxBytes ${limit}`); + } +} + +async function importInline(request: SourceImportRequest): Promise { + validateBoundedInput(request); + if (request.sourceKind === "ci" && request.input.type === "object") { + const jobs = record(record(request.input.value)?.jobs); + if (!jobs || Object.keys(jobs).length === 0) throw new Error("Malformed CI input: jobs must be a non-empty object"); + const names = Object.keys(jobs).sort(codePointCompare); + const nodes: DiagramNode[] = names.map((name) => ({ id: id("command", name), label: name, kind: "command", provenance: { path: request.path } })); + const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + for (const name of names) { + const rawNeeds = record(jobs[name])?.needs; + const needs = (Array.isArray(rawNeeds) ? rawNeeds : rawNeeds === undefined ? [] : [rawNeeds]).filter((value): value is string => typeof value === "string").sort(codePointCompare); + for (const dependency of needs) { + if (!names.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown CI job: ${dependency}`, path: request.path }); continue; } + edges.push({ id: id("edge", dependency, "needs", name, String(edges.length)), source: id("command", dependency), target: id("command", name), kind: "needs", provenance: { path: request.path } }); + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "openapi" && request.input.type === "object") { + const root = record(request.input.value); const paths = record(root?.paths); const schemas = record(record(root?.components)?.schemas) ?? {}; + if (!root || !paths) throw new Error("Malformed OpenAPI input: paths must be an object"); + const nodes: DiagramNode[] = []; const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + const methods = new Set(["get", "put", "post", "delete", "patch", "options", "head", "trace"]); + for (const path of Object.keys(paths).sort(codePointCompare)) { + const pathItem = record(paths[path]) ?? {}; + for (const method of Object.keys(pathItem).filter((key) => methods.has(key.toLowerCase())).sort(codePointCompare)) { + const operationId = id("command", method, path); + nodes.push({ id: operationId, label: `${method.toUpperCase()} ${path}`, kind: "command", provenance: { path: request.path } }); + const refs = new Set(); collectSchemaRefs(pathItem[method], refs); + for (const schema of [...refs].sort(codePointCompare)) { + if (!Object.prototype.hasOwnProperty.call(schemas, schema)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown schema: ${schema}`, path: request.path }); continue; } + edges.push({ id: id("edge", method, path, schema, String(edges.length)), source: operationId, target: id("schema", schema), kind: "schema-reference", provenance: { path: request.path } }); + } + } + } + for (const schema of Object.keys(schemas).sort(codePointCompare)) nodes.push({ id: id("schema", schema), label: schema, kind: "library", provenance: { path: request.path } }); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "sql" && request.input.type === "text") { + const scanned = maskComments(request.input.text, ["--"], false, "'"); + const tables = new Map(); const refs: Array<{ source: string; target: string; line: number }> = []; + const diagnostics: ImportDiagnostic[] = scanned.unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "SQL construct is outside the bounded static subset", path: request.path, line })); + let current: string | undefined; + scanned.text.split(/\r?\n/).forEach((line, index) => { + const declaration = line.match(/^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`\[]?([A-Za-z_]\w*)/i)?.[1]; + if (declaration) { + if (tables.has(declaration)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate table: ${declaration}`, path: request.path, line: index + 1 }); + else tables.set(declaration, index + 1); + current = declaration; + } + const target = line.match(/\bREFERENCES\s+["`\[]?([A-Za-z_]\w*)/i)?.[1]; + if (current && target) refs.push({ source: current, target, line: index + 1 }); + if (/\)\s*;/.test(line)) current = undefined; + }); + const nodes: DiagramNode[] = [...tables].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("table", name), label: name, kind: "table", provenance: { path: request.path, line } })); + const edges: DiagramEdge[] = refs.filter((ref) => tables.has(ref.target)).map((ref, occurrence) => ({ id: id("edge", ref.source, "fk", ref.target, String(occurrence)), source: id("table", ref.source), target: id("table", ref.target), kind: "foreign-key", provenance: { path: request.path, line: ref.line } })); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "docker-compose" && request.input.type === "object") { + const root = record(request.input.value); + const services = record(root?.services); + const volumes = record(root?.volumes) ?? {}; + if (!services || Object.keys(services).length === 0) throw new Error("Malformed Docker Compose input: services must be a non-empty object"); + const serviceNames = Object.keys(services).sort(codePointCompare); const volumeNames = Object.keys(volumes).sort(codePointCompare); + const nodes: DiagramNode[] = [ + ...serviceNames.map((name) => ({ id: id("service", name), label: name, kind: "service", provenance: { path: request.path } })), + ...volumeNames.map((name) => ({ id: id("volume", name), label: name, kind: "volume", provenance: { path: request.path } })), + ]; + const edges: DiagramEdge[] = []; const diagnostics: ImportDiagnostic[] = []; + for (const name of serviceNames) { + const service = record(services[name]) ?? {}; + const depends = Array.isArray(service.depends_on) ? service.depends_on : Object.keys(record(service.depends_on) ?? {}); + for (const dependency of depends.filter((value): value is string => typeof value === "string").sort(codePointCompare)) { + if (!serviceNames.includes(dependency)) { diagnostics.push({ code: "unknown-reference", severity: "warning", message: `Unknown service: ${dependency}`, path: request.path }); continue; } + edges.push({ id: id("edge", name, "depends", dependency, String(edges.length)), source: id("service", name), target: id("service", dependency), kind: "depends-on", provenance: { path: request.path } }); + } + for (const mount of (Array.isArray(service.volumes) ? service.volumes : []).filter((value): value is string => typeof value === "string").sort(codePointCompare)) { + const volume = mount.split(":", 1)[0]; + if (volumeNames.includes(volume)) edges.push({ id: id("edge", name, "mounts", volume, String(edges.length)), source: id("service", name), target: id("volume", volume), kind: "mounts", provenance: { path: request.path } }); + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "kubernetes" && request.input.type === "object") { + const values = Array.isArray(request.input.value) ? request.input.value : [request.input.value]; + const rawObjects = values.map(record).filter((value): value is Record => Boolean(value && typeof value.kind === "string" && objectName(value))); + const occurrences = new Map(); + const objects = rawObjects.map((value) => { + const kind = String(value.kind); + const name = objectName(value)!; + const rawNamespace = record(value.metadata)?.namespace; + const namespace = typeof rawNamespace === "string" && rawNamespace.length > 0 ? rawNamespace : "default"; + const key = JSON.stringify([kind, namespace, name]); + const occurrence = occurrences.get(key) ?? 0; + occurrences.set(key, occurrence + 1); + return { value, kind, name, namespace, nodeId: id("k8s", kind, namespace, name, String(occurrence)) }; + }); + const diagnostics: ImportDiagnostic[] = objects.filter((object) => object.kind === "Secret").map(() => ({ code: "secret-redacted", severity: "warning", message: "Kubernetes Secret payload omitted", path: request.path })); + const nodes: DiagramNode[] = objects.map((object) => ({ + id: object.nodeId, + label: `${object.kind}/${object.namespace}/${object.name}`, + kind: object.kind.toLowerCase(), + provenance: { path: request.path }, + })).sort((a, b) => codePointCompare(a.label, b.label)); + const byKey = new Map(); + for (const object of objects) { + const key = JSON.stringify([object.kind, object.namespace, object.name]); + if (!byKey.has(key)) byKey.set(key, object); + } + const workloadKinds = new Set(["Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job"]); + const edges: DiagramEdge[] = []; + for (const object of objects) { + const secretRefs = new Set(); + if (workloadKinds.has(object.kind)) collectWorkloadSecretRefs(record(record(object.value.spec)?.template)?.spec, secretRefs); + for (const target of [...secretRefs].sort(codePointCompare)) { + const secret = byKey.get(JSON.stringify(["Secret", object.namespace, target])); + if (secret) edges.push({ id: id("edge", object.nodeId, "secret", secret.nodeId, String(edges.length)), source: object.nodeId, target: secret.nodeId, kind: "uses-secret", provenance: { path: request.path } }); + } + if (object.kind === "Service") { + const selector = record(record(object.value.spec)?.selector); + if (!selector || Object.keys(selector).length === 0) continue; + for (const workload of objects.filter((candidate) => workloadKinds.has(candidate.kind) && candidate.namespace === object.namespace)) { + const labels = record(record(record(record(workload.value.spec)?.template)?.metadata)?.labels); + if (labels && Object.entries(selector).every(([key, selected]) => labels[key] === selected)) { + edges.push({ id: id("edge", object.nodeId, "selects", workload.nodeId, String(edges.length)), source: object.nodeId, target: workload.nodeId, kind: "selects", provenance: { path: request.path } }); + } + } + } + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (request.sourceKind === "terraform" && request.input.type === "text") { + const heredocs = maskTerraformHeredocs(request.input.text); + const structural = maskComments(heredocs.text, ["#", "//"]); + const referencesOnly = maskTerraformStrings(structural.text); + const unknownLines = [...new Set([...heredocs.unknownLines, ...structural.unknownLines, ...referencesOnly.unknownLines])].sort((a, b) => a - b); + const diagnostics: ImportDiagnostic[] = unknownLines.map((line) => ({ code: "unknown-construct", severity: "warning", message: "Terraform construct is outside the bounded static subset", path: request.path, line })); + const resources = new Map(); + const references: Array<{ source: string; target: string; line: number }> = []; + const referenceLines = referencesOnly.text.split(/\r?\n/); + let current: string | undefined; + structural.text.split(/\r?\n/).forEach((line, index) => { + const declaration = line.match(/^\s*resource\s+"([^"]+)"\s+"([^"]+)"\s*\{/); + if (declaration) { + current = `${declaration[1]}.${declaration[2]}`; + resources.set(current, index + 1); + if (/\{\s*\}/.test(line)) current = undefined; + return; + } + if (current) { + for (const match of referenceLines[index].matchAll(/\b([A-Za-z_]\w*\.[A-Za-z_]\w*)\.[A-Za-z_]\w*/g)) references.push({ source: current, target: match[1], line: index + 1 }); + if (/^\s*}/.test(line)) current = undefined; + } + }); + const nodes = [...resources].sort(([a], [b]) => codePointCompare(a, b)).map(([name, line]) => ({ id: id("resource", name), label: name, kind: "resource", provenance: { path: request.path, line } })); + const edges = references.filter((ref) => resources.has(ref.target)).sort((a, b) => codePointCompare(a.source, b.source) || codePointCompare(a.target, b.target) || a.line - b.line).map((ref, occurrence) => ({ id: id("edge", ref.source, ref.target, String(occurrence)), source: id("resource", ref.source), target: id("resource", ref.target), kind: "reference", provenance: { path: request.path, line: ref.line } })); + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; + } + if (!(["python", "javascript", "typescript", "go", "rust"] as SourceKind[]).includes(request.sourceKind) || request.input.type !== "text") throw new Error("Unsupported source input"); + const nodes: DiagramNode[] = [{ id: id("module", request.path), label: request.path, kind: "module", provenance: { path: request.path, line: 1 } }]; + const edges: DiagramEdge[] = []; + const diagnostics: ImportDiagnostic[] = []; + const javaScriptScan = request.sourceKind === "javascript" || request.sourceKind === "typescript" ? scanJavaScriptImports(request.input.text) : undefined; + const nonJavaScriptScan = request.sourceKind === "python" ? maskPythonTripleStrings(request.input.text) + : request.sourceKind === "go" ? maskComments(request.input.text, ["//"], false, "`") + : request.sourceKind === "rust" ? maskComments(request.input.text, ["//"], true, "", true) + : { text: request.input.text, unknownLines: [] }; + for (const line of javaScriptScan?.unknownLines ?? nonJavaScriptScan.unknownLines) diagnostics.push({ code: "unknown-construct", severity: "warning", message: `${request.sourceKind} construct is outside the bounded static-import subset`, path: request.path, line }); + nonJavaScriptScan.text.split(/\r?\n/).forEach((line, index) => { + const dynamicPython = request.sourceKind === "python" && /\b(?:__import__|importlib\.import_module)\s*\(\s*[^"']/.test(line); + if (dynamicPython) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Dynamic imports are not evaluated", path: request.path, line: index + 1 }); + }); + const seen = new Set(); + const foundImports = request.sourceKind === "rust" + ? nonJavaScriptScan.text.split(/\r?\n/).flatMap((line, index) => { + const mod = line.match(/^\s*mod\s+([A-Za-z_]\w*)\s*;/)?.[1]; + const use = line.match(/^\s*use\s+([^;]+);/)?.[1]; + if (/\w+!\s*\(/.test(line)) diagnostics.push({ code: "unknown-construct", severity: "warning", message: "Rust macros are not expanded", path: request.path, line: index + 1 }); + if (mod) return [{ name: mod, line: index + 1 }]; + if (use) { + const root = use.includes("::{") ? use.slice(0, use.indexOf("::{")) : use.split("::").slice(0, -1).join("::") || use; + return [{ name: root, line: index + 1 }]; + } + return []; + }) + : javaScriptScan?.imports ?? codeImports(request.sourceKind as "python" | "go", nonJavaScriptScan.text); + for (const found of foundImports) { + if (seen.has(found.name)) continue; + seen.add(found.name); + const targetId = id("library", found.name); + nodes.push({ id: targetId, label: found.name, kind: "library", provenance: { path: request.path, line: found.line } }); + edges.push({ id: id("edge", request.path, found.name, String(edges.length)), source: nodes[0].id, target: targetId, kind: "import", provenance: { path: request.path, line: found.line } }); + } + return { diagram: diagramFor(request, nodes, edges), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; +} + +function isConfined(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot)); +} + +async function openConfined(candidate: string, root: string): Promise<{ handle: FileHandle; canonical: string }> { + let handle: FileHandle; + try { + handle = await open(candidate, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ELOOP") throw new Error("Source symlink escape risk cannot be opened safely"); + throw error; + } + try { + const canonical = await realpath(`/proc/self/fd/${handle.fd}`); + if (!isConfined(root, canonical)) throw new Error("Opened source escapes outside declared root"); + return { handle, canonical }; + } catch (error) { + await handle.close(); + throw error; + } +} + +async function readBounded(handle: FileHandle, limit: number, directory: boolean): Promise { + const initial = await handle.stat(); + if (!initial.isFile()) throw new Error("Source entry must be a regular file"); + if (initial.size > limit) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`); + const chunks: Buffer[] = []; + let consumed = 0; + while (consumed < limit) { + const chunk = Buffer.allocUnsafe(Math.min(65_536, limit - consumed)); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + consumed += bytesRead; + } + const final = await handle.stat(); + if (initial.dev !== final.dev || initial.ino !== final.ino) throw new Error("Opened source identity changed during read"); + if (final.size > limit || consumed < initial.size) throw new Error(`${directory ? "Directory source input" : "Source input"} exceeds maxBytes ${limit}`); + return Buffer.concat(chunks, consumed); +} + +export async function importSource(request: SourceImportRequest): Promise { + validateBoundedInput(request); + if (request.input.type !== "file") return importInline(request); + if (isAbsolute(request.path)) throw new Error("Source path escapes outside declared root"); + const rootPath = await realpath(request.input.root); + const lexicalPath = resolve(rootPath, request.path); + if (!isConfined(rootPath, lexicalPath)) throw new Error("Source path escapes outside declared root"); + const limit = request.maxBytes ?? 1_048_576; + const objectKinds = new Set(["kubernetes", "docker-compose", "openapi", "ci"]); + const extensions: Record> = { + python: new Set([".py"]), javascript: new Set([".js", ".jsx", ".mjs", ".cjs"]), typescript: new Set([".ts", ".tsx", ".mts", ".cts"]), + go: new Set([".go"]), rust: new Set([".rs"]), terraform: new Set([".tf"]), sql: new Set([".sql"]), + kubernetes: new Set([".json", ".yaml", ".yml"]), "docker-compose": new Set([".json", ".yaml", ".yml"]), openapi: new Set([".json", ".yaml", ".yml"]), ci: new Set([".json", ".yaml", ".yml"]), + }; + const parse = async (buffer: Buffer, provenancePath: string): Promise => { + const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer); + let input: SourceInput; + if (objectKinds.has(request.sourceKind)) { + const yaml = /\.ya?ml$/i.test(provenancePath); + try { + if (yaml) { + const documents = loadAll(text, undefined, { schema: JSON_SCHEMA, json: false }).filter((value) => value !== undefined); + if (documents.length === 0) throw new Error("empty"); + if (request.sourceKind !== "kubernetes" && documents.length !== 1) throw new Error("multiple documents"); + input = { type: "object", value: request.sourceKind === "kubernetes" ? documents : documents[0] }; + } else input = { type: "object", value: JSON.parse(text) as unknown }; + } catch { throw new Error(`Malformed ${yaml ? "YAML or disallowed tag" : "JSON"} source: ${provenancePath}`); } + } else input = { type: "text", text }; + return importInline({ ...request, path: provenancePath, input }); + }; + const source = await openConfined(lexicalPath, rootPath); + const info = await source.handle.stat(); + if (info.isFile()) { + try { return await parse(await readBounded(source.handle, limit, false), request.path); } + finally { await source.handle.close(); } + } + if (!info.isDirectory()) { await source.handle.close(); throw new Error("Source path must be a file or directory"); } + + const results: SourceImportResult[] = []; + let consumed = 0; + const walk = async (directory: FileHandle): Promise => { + for (const name of (await readdir(`/proc/self/fd/${directory.fd}`)).sort(codePointCompare)) { + const opened = await openConfined(resolve(`/proc/self/fd/${directory.fd}`, name), rootPath); + try { + const entryInfo = await opened.handle.stat(); + if (entryInfo.isDirectory()) await walk(opened.handle); + else if (entryInfo.isFile()) { + const dot = name.lastIndexOf("."); const extension = dot >= 0 ? name.slice(dot).toLowerCase() : ""; + if (extensions[request.sourceKind].has(extension)) { + const provenancePath = relative(rootPath, opened.canonical).split("\\").join("/"); + const buffer = await readBounded(opened.handle, limit - consumed, true); + consumed += buffer.byteLength; + results.push(await parse(buffer, provenancePath)); + } + } else throw new Error("Source directory contains an unsupported entry type"); + } finally { await opened.handle.close(); } + } + }; + try { await walk(source.handle); } + finally { await source.handle.close(); } + if (results.length === 0) throw new Error(`No supported ${request.sourceKind} source files found`); + const nodeById = new Map(); const edgeById = new Map(); const diagnostics: ImportDiagnostic[] = []; + for (const result of results) { + diagnostics.push(...result.diagnostics); + for (const node of result.diagram.pages[0].nodes) if (!nodeById.has(node.id)) nodeById.set(node.id, node); + for (const edge of result.diagram.pages[0].edges) { + if (edgeById.has(edge.id)) diagnostics.push({ code: "duplicate-id", severity: "warning", message: `Duplicate edge omitted: ${edge.id}`, path: String(edge.provenance?.path ?? request.path) }); + else edgeById.set(edge.id, edge); + } + } + return { diagram: diagramFor(request, [...nodeById.values()], [...edgeById.values()]), diagnostics, provenance: { sourceKind: request.sourceKind, path: request.path } }; +} -- 2.54.0 From ced42f02f9a7f5e22c62906405f207f8d8424389 Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Thu, 3 Sep 2026 19:11:51 +0000 Subject: [PATCH 4/5] [verified] feat: add specialized diagram profiles --- scripts/package.json | 2 +- scripts/pnpm-lock.yaml | 10 +- scripts/src/services/profiles/buildup.test.ts | 33 +++++ scripts/src/services/profiles/buildup.ts | 36 +++++ scripts/src/services/profiles/c4.test.ts | 133 ++++++++++++++++++ scripts/src/services/profiles/c4.ts | 112 +++++++++++++++ .../src/services/profiles/compression.test.ts | 22 +++ scripts/src/services/profiles/compression.ts | 36 +++++ scripts/src/services/profiles/index.test.ts | 7 + scripts/src/services/profiles/index.ts | 14 ++ scripts/src/services/profiles/runbook.test.ts | 18 +++ scripts/src/services/profiles/runbook.ts | 19 +++ .../src/services/profiles/sequence.test.ts | 133 ++++++++++++++++++ scripts/src/services/profiles/sequence.ts | 94 +++++++++++++ .../src/services/profiles/timelapse.test.ts | 22 +++ scripts/src/services/profiles/timelapse.ts | 37 +++++ .../src/services/profiles/tube-map.test.ts | 121 ++++++++++++++++ scripts/src/services/profiles/tube-map.ts | 50 +++++++ 18 files changed, 893 insertions(+), 6 deletions(-) create mode 100644 scripts/src/services/profiles/buildup.test.ts create mode 100644 scripts/src/services/profiles/buildup.ts create mode 100644 scripts/src/services/profiles/c4.test.ts create mode 100644 scripts/src/services/profiles/c4.ts create mode 100644 scripts/src/services/profiles/compression.test.ts create mode 100644 scripts/src/services/profiles/compression.ts create mode 100644 scripts/src/services/profiles/index.test.ts create mode 100644 scripts/src/services/profiles/index.ts create mode 100644 scripts/src/services/profiles/runbook.test.ts create mode 100644 scripts/src/services/profiles/runbook.ts create mode 100644 scripts/src/services/profiles/sequence.test.ts create mode 100644 scripts/src/services/profiles/sequence.ts create mode 100644 scripts/src/services/profiles/timelapse.test.ts create mode 100644 scripts/src/services/profiles/timelapse.ts create mode 100644 scripts/src/services/profiles/tube-map.test.ts create mode 100644 scripts/src/services/profiles/tube-map.ts 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; +} -- 2.54.0 From 1db0ab288560757460459416cc62593c7146b007 Mon Sep 17 00:00:00 2001 From: Jarvis Jr Hermes Date: Thu, 3 Sep 2026 19:15:53 +0000 Subject: [PATCH 5/5] docs: document integrated capability coverage --- README.md | 14 ++++ SKILL.md | 8 ++- references/agents365-capability-coverage.md | 77 +++++++++++++++++++++ references/capabilities.md | 13 ++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 references/agents365-capability-coverage.md diff --git a/README.md b/README.md index 65b9d24..f9b808c 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,20 @@ Commands fail closed on ambiguous identities, invalid view/policy names, input/o --- +### Native service libraries + +The combined package also exposes tested TypeScript service APIs for capabilities that are not registered as CLI actions: + +| Family | Implemented native services | +|---|---| +| Safe source importers | Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI job DAG subsets | +| Toolbox | Five themes, generic offline shape search, Mermaid and Markdown reverse transforms, semantic diff, strict relabeling, and accessible metric heatmaps | +| Specialized profiles | C4, sequence, tube map, executive compression, runbook HTML, timelapse, and dependency-ordered build-up | + +These are conservative, bounded service contracts rather than claims of complete language parsers or peer-compatible command-line interfaces. See [`references/agents365-capability-coverage.md`](references/agents365-capability-coverage.md) for the strict 42-tool comparison and remaining gaps. + +--- + ### Inventory #### `summary` diff --git a/SKILL.md b/SKILL.md index fb4be6a..695c130 100644 --- a/SKILL.md +++ b/SKILL.md @@ -13,12 +13,15 @@ compatibility: Designed for Cline, Claude Code, GitHub Copilot, OpenAI Codex, an # Draw.io Diagram Skill -This skill covers four capability families: +This skill covers seven capability families: 1. **Deterministic YAML generation** — validate v1/v2 semantic Diagram IR and build native multi-page `.drawio` XML with stable IDs, dependency-aware layout, and obstacle-aware routing 2. **Direct XML generation** — create `.drawio` files (and optionally export to PNG/SVG/PDF) from a description or requirements 3. **Diagram analysis** — run the `drawio-tools` CLI to analyse an existing `.drawio` file: inventory shapes and connectors, validate layout quality, detect overlaps/orphans, and recommend page sizes 4. **Semantic lifecycle** — loss-aware Draw.io import, transactional stable-ID edits, linked views, semantic query/policy/what-if analysis, three-way synchronization, and self-contained offline story publishing +5. **Safe source importers** — bounded native TypeScript subsets for Python, JavaScript/TypeScript, Go, Rust, Terraform, Kubernetes, Docker Compose, SQL, OpenAPI, and CI dependency graphs +6. **Toolbox transforms** — themes, offline generic shape search, reverse Mermaid/Markdown, semantic diff, strict relabeling, and accessible heatmaps +7. **Specialized profiles** — C4, sequence, tube map, compression, runbook, timelapse, and dependency-ordered build-up services **Reference files** (read these when using this skill): - [references/capabilities.md](./references/capabilities.md) — full list of capabilities and all CLI analysis actions an agent can execute @@ -29,6 +32,7 @@ This skill covers four capability families: - [references/routing-best-practices.md](./references/routing-best-practices.md) — corridor planning, routing patterns, overlap verification, swimlane routing, validation workflow - [references/maintenance.md](./references/maintenance.md) — maintaining and rebuilding the skill itself - [references/semantic-lifecycle.md](./references/semantic-lifecycle.md) — import, edit, views, query, policy, what-if, sync, story, and doctor workflows +- [references/agents365-capability-coverage.md](./references/agents365-capability-coverage.md) — strict evidence matrix for all 42 compared peer tools, including partial and deferred scope ## Available scripts @@ -66,6 +70,8 @@ task build See [references/capabilities.md](./references/capabilities.md) for what every action outputs. +Source importers, toolbox transforms, and specialized profiles are currently native TypeScript service APIs, not additional CLI actions. Do not invent action names for them; use the exported services or the documented lifecycle actions. + --- Generate draw.io diagrams as native `.drawio` files. Optionally export to PNG, SVG, or PDF with the diagram XML embedded (so the exported file remains editable in draw.io). diff --git a/references/agents365-capability-coverage.md b/references/agents365-capability-coverage.md new file mode 100644 index 0000000..5d7b91d --- /dev/null +++ b/references/agents365-capability-coverage.md @@ -0,0 +1,77 @@ +# Agents365 capability coverage + +> Evidence basis: clean-room behavioral comparison of 42 peer tools. Peer source and bundled assets were not copied because the inspected mirror had no complete license file. + +## Reading the classifications + +- **Implemented** — full mapped peer acceptance contract is available. +- **Partial** — useful native behavior is implemented, but some peer options, adapters, or Command-Line Interface (CLI) exposure remain. +- **Optional deferred** — environment-heavy capability remains outside the mandatory offline core. +- **Rejected** — capability was deliberately excluded from this phase. + +These are strict peer-parity labels. A `partial` row can still contain substantial production-ready native functionality. + +## Aggregate + +| Classification | Count | +|---|---:| +| Implemented | 0 | +| Partial | 29 | +| Optional deferred | 11 | +| Rejected | 2 | +| **Total** | **42** | + +## Capability matrix + +| Peer tool | Classification | Native evidence | Remaining gap | +|---|---|---|---| +| `aiicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Only an eight-entry generic offline shape catalog exists; there is no AI/LLM brand manifest, variant selection, embedding adapter, URL allowlist, or CLI action. | +| `autolayout.py` | **partial** | `scripts/src/services/layout/layout-engine.ts`
`scripts/src/services/layout/layout-engine.test.ts`
`scripts/src/actions/build/action.ts` | Deterministic native layouts exist, but there is no peer-compatible graph-JSON adapter, group-tree contract, monochrome mode, or objective TB/LR tuning action. | +| `buildup.py` | **partial** | `scripts/src/services/profiles/buildup.ts`
`scripts/src/services/profiles/buildup.test.ts` | The service computes deterministic cumulative IR frames, but it does not parse Draw.io through a buildup action, render PNG frames, publish the player HTML, encode GIF, or expose CLI availability semantics. | +| `c4.py` | **partial** | `scripts/src/services/profiles/c4.ts`
`scripts/src/services/profiles/c4.test.ts`
`scripts/src/services/profiles/index.ts` | The service validates hierarchy and creates linked C4 pages, but no C4 CLI/action or input-file adapter is registered, and canonical peer style/direction options are not fully exposed. | +| `ciimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The service imports a bounded generic jobs/needs object, but lacks CLI registration, repository workflow discovery, triggers, runners, matrices, reusable workflows, GitLab stages, and inferred stage dependencies. | +| `composeimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Services, depends_on, and simple named volumes are covered through a service API; links, volumes_from, long-form mounts, network grouping, conventional-file discovery, and CLI exposure remain absent. | +| `compress.py` | **partial** | `scripts/src/services/profiles/compression.ts`
`scripts/src/services/profiles/compression.test.ts` | A deterministic BFS clustering service emits summary and full IR pages, but there is no Draw.io-facing action, exact loss-aware detail-file workflow, label-propagation parity, or member-cell drill-down target contract. | +| `dbxicons.py` | **optional-deferred** | `scripts/src/services/shape-catalog/shape-catalog.ts`
clean-room 42-tool mapping audit | No licensed Databricks manifest, aliases, variants, pinned-ref embedding, refresh operation, host allowlist, or CLI action was delivered. | +| `diagram_ir.py` | **partial** | `scripts/src/model/diagram-ir.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts`
`scripts/src/services/semantic-lifecycle/analysis.ts`
`scripts/src/services/semantic-lifecycle/sync.ts`
`scripts/src/services/semantic-lifecycle/publishing.ts` | Versioned IR, loss-aware import, views, query, policies, failure impact, sync, and story publishing exist, but articulation analysis, a unified architecture-review contract, contrast analysis, multilingual labeling, and explicit peer-IR-v1 compatibility are incomplete. | +| `diagramctl.py` | **partial** | `scripts/src/cli/commands.ts`
`scripts/src/cli/semantic-lifecycle.test.ts`
`scripts/src/actions/doctor/action.ts`
`scripts/src/actions/sync/action.ts` | Lifecycle actions are registered in the existing --action CLI, but integrated importer, profile, transform, and reverse-export services are not registered. There is no uniform peer-equivalent result envelope. | +| `diagramctl_mcp.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional MCP package, JSON-RPC initialization, tools/list, tools/call bridge, closed schemas, timeout handling, or MCP entrypoint exists. | +| `dockerimports.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The importer registry has Docker Compose but no Docker inspect snapshot kind, container/network/volume instance normalization, redaction contract, stdin parity, or action. | +| `drawio2mermaid.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | A deterministic IR-to-Mermaid service exists, but there is no Draw.io-to-Mermaid CLI/action, shape-form mapping, direction/fence controls, or lossy-conversion report. | +| `drawio2pptx.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io renderer adapter, per-page raster loop, PPTX writer, slide sizing, scale option, or structured unavailable result exists. | +| `drawiodiff.py` | **partial** | `scripts/src/services/transforms/semantic-diff.ts`
`scripts/src/services/transforms/semantic-diff.test.ts`
`scripts/src/services/semantic-lifecycle/import-drawio.ts` | The IR service classifies added, removed, changed, moved, and rerouted entities, but no CLI/action composes Draw.io import with diffing, no by-label ambiguity mode exists, and no color-coded graph output or summary diagram is emitted. | +| `drawiohtml.py` | **optional-deferred** | `scripts/src/services/semantic-lifecycle/publishing.ts`
`scripts/src/actions/story/action.ts` | Story HTML is a different semantic publisher; there is no page-to-SVG export adapter, SVG sanitizer, tabbed viewer, pan/zoom/search UI, drill-down link rewrite, or publish-viewer action. | +| `edgeports.py` | **rejected** | `scripts/src/services/authoring-router/orthogonal-router.ts`
`scripts/src/services/authoring-router/orthogonal-router.test.ts`
clean-room 42-tool mapping audit | Only routing during authoring exists. The post-import boundary-port editor, pinned-port preservation, dry-run, idempotence, and transactional Draw.io write path were not selected for this post-phase implementation. | +| `encode_drawio_url.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
`scripts/package.json` | Compression primitives are available transitively, but there is no byte-compatible URL encoder, viewer/edit modes, size policy, privacy warning, or CLI action. | +| `explain.py` | **partial** | `scripts/src/services/transforms/reverse.ts`
`scripts/src/services/transforms/reverse.test.ts` | Structured Markdown for IR pages, nodes, and flows exists as a service, but no Draw.io-facing explain action, tier/type inference, C4 context, unknown-section reporting, or output-file contract is exposed. | +| `goimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded Go import extraction exists, but it emits imported paths as library nodes rather than resolving only intra-module packages; module discovery, grouping, transitive reduction, and CLI exposure are absent. | +| `heatmap.py` | **partial** | `scripts/src/services/transforms/heatmap.ts`
`scripts/src/services/transforms/heatmap.test.ts` | The IR service validates bounded metrics and applies deterministic accessible colors with legend metadata, but it lacks CSV/JSON loading, label matching, geometry scaling, a rendered legend node, Draw.io transactional output, and CLI exposure. | +| `jsimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded scanner handles static import/export and require while avoiding tested comments and strings, but dynamic string imports, project module resolution, external exclusion, directory grouping, transitive reduction, and CLI exposure remain incomplete. | +| `k8simports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Local object/YAML parsing, namespace-qualified identity, Service selectors, Secret references, and Secret payload redaction are tested. Ingress, ConfigMap, PVC, HPA, list-wrapper normalization, ambiguity diagnostics, icon suppression/grouping, stdin action parity, and CLI registration remain absent. | +| `openapiimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | OpenAPI operations and local component-schema references are imported through a service API, but Swagger 2 definitions, nested schema-to-schema edges, method styling, tag groups, no-schema projection, version diagnostics, and CLI exposure are missing. | +| `prdiff.py` | **optional-deferred** | `scripts/src/services/transforms/semantic-diff.ts`
`scripts/src/cli/commands.ts` | Semantic IR diff alone does not provide scoped git revision discovery, A/M/D classification, safe extraction, Draw.io rendering, artifact naming, Markdown reporting, or unavailable-render handling. | +| `pyclasses.py` | **rejected** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | The Python importer extracts imports only. Class declarations, inheritance resolution, multiple inheritance, nested module grouping, ambiguity diagnostics, and transitive reduction were not implemented or registered. | +| `pyimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | Bounded import/from extraction and dynamic-import diagnostics exist, but relative and absolute intra-project resolution, stdlib/third-party exclusion, package grouping, transitive reduction, syntax-error diagnostics, and CLI exposure are absent. | +| `raster2drawio.py` | **partial** | `scripts/src/actions/build/action.ts`
`scripts/src/authoring/ir-to-drawio.ts`
`scripts/src/services/layout/layout-engine.ts`
`scripts/src/model/diagram-ir.ts` | Generic IR build supports explicit geometry, styles, edges, and automatic layout, but there is no raster-extracted graph compatibility schema/action, x/y/w/h shorthand conversion, partial-coordinate policy, confidence/provenance convention, or extraction-warning envelope. | +| `relabel.py` | **partial** | `scripts/src/services/transforms/relabel.ts`
`scripts/src/services/transforms/relabel.test.ts` | A strict complete-map IR relabel service preserves non-label structure, but there is no extraction mode, page-name handling, UserObject traversal contract, partial-map/unmatched reporting, Draw.io transactional write, or CLI action. | +| `repair_png.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No PNG chunk validator, signature-specific repair, atomic in-place replacement, idempotence gate, version gate, or optional action exists. | +| `restyle.py` | **partial** | `scripts/src/services/themes/theme-service.ts`
`scripts/src/services/themes/theme-service.test.ts` | Five validated built-in themes and immutable IR application exist, but the peer-style palette-slot schema, user preset loader, hue/neutral color remapping, global extras, versioned JSON schema, Draw.io transactional action, and CLI exposure are incomplete. | +| `runbook.py` | **partial** | `scripts/src/services/profiles/runbook.ts`
`scripts/src/services/profiles/runbook.test.ts` | The service emits escaped self-contained interactive HTML from an explicit RunbookGraph, but it does not parse Draw.io, infer node types/start nodes/choices, report fallback selection, or expose a publish-runbook action. | +| `rustimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded subset recognizes mod and simple use roots and diagnoses macros, but crate/self/super resolution, module-file discovery, complete brace expansion, external-crate exclusion, grouping, reduction, and CLI exposure are absent. | +| `seqlayout.py` | **partial** | `scripts/src/services/profiles/sequence.ts`
`scripts/src/services/profiles/sequence.test.ts` | Participants, ordered messages, lifelines, activations, return validation, and editable geometry are implemented as a service, but notes are unsupported and no sequence schema file, input action, direction/options contract, or CLI registration exists. | +| `shapesearch.py` | **partial** | `scripts/src/services/shape-catalog/shape-catalog.ts`
`scripts/src/services/shape-catalog/shape-catalog.test.ts` | Deterministic exact/alias/fuzzy search exists for eight hand-curated generic shapes, not the licensed 10k+ palette index; compound/tag/Soundex ranking, dimensions, gzip integrity controls, expected ecosystem queries, and CLI output are missing. | +| `sqlerd.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A narrow line-oriented subset finds simple tables and REFERENCES edges, but columns/types, PK/FK markers, quoted/schema identifiers, composite keys, schema grouping, crow's-foot styles, unsupported-syntax diagnostics, and CLI exposure are missing. | +| `svgflow.py` | **optional-deferred** | `scripts/src/cli/commands.ts`
clean-room 42-tool mapping audit | No optional Draw.io SVG export adapter, SVG parser/sanitizer, connector detection, animation injection, reduced-motion handling, or export-flow-svg action exists. | +| `tfimports.py` | **partial** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | A bounded line-oriented resource/reference subset exists, but modules, multiline/nested HCL handling, comments/string false-positive guarantees, diagnostics for dynamic expressions, cloud styles, grouping, transitive reduction, no-icons mode, and CLI exposure are incomplete. | +| `tfstate.py` | **optional-deferred** | `scripts/src/services/source-importers/index.ts`
`scripts/src/services/source-importers/index.test.ts` | No Terraform show-JSON snapshot source kind, nested module traversal, count/for_each instance expansion, sensitive-value redaction, state relationship extraction, stdin parity, or optional action exists. | +| `timelapse.py` | **partial** | `scripts/src/services/profiles/timelapse.ts`
`scripts/src/services/profiles/timelapse.test.ts` | The service classifies changes across caller-supplied IR snapshots, but it has no scoped git history/archive adapter, deterministic commit sampling, importer allowlist, Draw.io frame rendering, HTML player, resource limits, or CLI action. | +| `tubemap.py` | **partial** | `scripts/src/services/profiles/tube-map.ts`
`scripts/src/services/profiles/tube-map.test.ts` | The native service provides deterministic grid-aligned octilinear routes, interchange semantics, validation, and tested obstacle avoidance, but no versioned input schema, build-tubemap action, file adapter, or CLI exposure exists. | +| `validate.py` | **partial** | `scripts/src/actions/validate/action.ts`
`scripts/src/actions/quality/action.ts`
`scripts/src/actions/page-connectors-validation/action.ts`
`scripts/src/actions/page-shape-bbox-validation/action.ts`
`scripts/src/actions/page-orphans/action.ts` | Structural compilation and several specialized quality checks exist, but findings remain fragmented rather than one stable code/severity/subject/fix schema; duplicate/reserved ID, parent, off-grid, strict-warning, JSON projection, and comparable readability-score acceptance coverage is incomplete. | + +## Architectural boundary + +The retained implementation stays in the existing TypeScript/Node.js stack with pnpm, Taskfile, `@maxgraph/core`, and the established action-based CLI. Python, Graphviz, Eclipse Layout Kernel (ELK), Model Context Protocol (MCP), browser services, network icon retrieval, and Draw.io Desktop are not mandatory dependencies. Optional adapters must report availability honestly. + +## Delivery note + +This matrix describes the combined integrated candidate and deliberate scope decisions. Gitea publication and Hermes runtime installation are separate gates and must not be inferred from this document. \ No newline at end of file diff --git a/references/capabilities.md b/references/capabilities.md index a9abb21..1730259 100644 --- a/references/capabilities.md +++ b/references/capabilities.md @@ -99,3 +99,16 @@ Always prints YAML to stdout. Exit code `0` on success, `1` on error. | `page-recommendations` | Page 0 | Smallest standard page size (A4→A3→A2→A1→custom) that fits content with 80 px margin | > For install/build instructions, source structure, and how to add new actions, see [maintenance.md](./maintenance.md). + +--- + +## Capability 5 — Native service libraries + +The package includes additional strict TypeScript APIs under `scripts/src/services/`: + +- `source-importers/` — conservative, bounded source/configuration topology extraction with diagnostics and no code execution; +- `themes/` and `shape-catalog/` — five validated themes and an offline generic shape catalog; +- `transforms/` — reverse Mermaid/Markdown, semantic diff, complete-map relabeling, and accessible bounded heatmaps; +- `profiles/` — C4, sequence, tube map, compression, runbook, timelapse, and build-up profiles. + +These services are covered by the native test suite but are not registered CLI actions. Their exact peer-relative coverage and deliberate omissions are listed in [agents365-capability-coverage.md](./agents365-capability-coverage.md). -- 2.54.0