Merge commit 'fa62a5fc4cdf127b919c90ca7451d24f7e25817c' into 1.0.0.0/IIAA-XYZ-agents365-lifecycle-002

# Conflicts:
#	scripts/src/authoring/ir-to-drawio.ts
This commit is contained in:
2026-09-03 19:13:26 +00:00
14 changed files with 961 additions and 3 deletions
@@ -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);
}
});
@@ -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;
@@ -0,0 +1,134 @@
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, /<diagram[^>]* data-extensions="\{&quot;owner&quot;:&quot;platform&quot;\}"[^>]* custom="kept"/);
assert.match(xml, /<mxGraphModel[^>]* background="#111827"/);
assert.match(xml, /<mxGraphModel[^>]* pageScale="1.25"/);
assert.doesNotMatch(xml, /data-extensions="[^"]*\$drawio/);
});
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);
});
@@ -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<ThemeRoleStyle, "strokeColor" | "fontColor">;
sketch?: boolean;
}
function deepFreeze<T>(value: T): T {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested);
Object.freeze(value);
}
return value;
}
export const BUILT_IN_THEMES: Readonly<Record<BuiltInThemeName, ThemeDefinition>> = 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<ThemeRoleStyle>, sketch: boolean, backgroundColor?: string): string {
const entries = parseStyle(style);
const originalFillNone = entries.some(({ key, value }) => key === "fillColor" && value?.toLowerCase() === "none");
const visual = new Map<string, string>();
if (colors.fillColor) visual.set("fillColor", originalFillNone ? "none" : colors.fillColor);
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<string>();
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<string, unknown>
: {};
const attributes = drawio.attributes && typeof drawio.attributes === "object" && !Array.isArray(drawio.attributes)
? drawio.attributes as Record<string, unknown>
: {};
return {
...page,
extensions: {
...extensions,
$drawio: { ...drawio, attributes: { ...attributes, "model:background": selectedTheme.backgroundColor } },
},
nodes: page.nodes.map(applyNode),
edges: page.edges.map(applyEdge),
};
}),
};
}
@@ -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);
});
@@ -0,0 +1,85 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface HeatmapRequest {
metric: string;
min: number;
max: number;
values: Readonly<Record<string, number>>;
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;
@@ -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);
});
@@ -0,0 +1,35 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
export interface CompleteRelabelRequest {
mode: "complete";
labels: Readonly<Record<string, string>>;
}
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;
@@ -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: {}}%% <script>alert(1)</script> [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 &#124; inject"\| n1/);
assert.doesNotMatch(output, /<script>|%%\{init|danger"\]/);
assert.match(output, /&#37;&#37;/);
});
test("reverse Markdown explains pages, nodes, and flows without executable HTML or broken tables", () => {
const output = diagramIRToStructuredMarkdown(IR);
assert.equal(output, diagramIRToStructuredMarkdown(IR));
assert.match(output, /## System/);
assert.match(output, /### Nodes/);
assert.match(output, /### Flows/);
assert.match(output, /calls \\| inject/);
assert.doesNotMatch(output, /<script>/);
assert.doesNotMatch(output, /\]\(javascript:/);
assert.match(output, /&lt;script&gt;/);
});
@@ -0,0 +1,62 @@
import type { DiagramIRV2 } from "../../model/diagram-ir.js";
function mermaidEscape(value: string): string {
const entities: Record<string, string> = {
"&": "&#38;", "<": "&#60;", ">": "&#62;", "\"": "&#34;", "'": "&#39;",
"[": "&#91;", "]": "&#93;", "{": "&#123;", "}": "&#125;", "(": "&#40;", ")": "&#41;",
"|": "&#124;", "`": "&#96;", "%": "&#37;", "\n": "&#10;", "\r": "",
};
return [...value].map((character) => entities[character] ?? character).join("");
}
function markdownEscape(value: string): string {
return value
.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
.replaceAll("\\", "\\\\").replace(/([`*_{}\[\]()#+.!-])/g, "\\$1").replaceAll("|", "\\|").replace(/[\r\n]+/g, " ");
}
export function diagramIRToMermaid(ir: DiagramIRV2): string {
const direction = ir.pages[0]?.layout?.direction === "vertical" ? "TD" : "LR";
const lines = [`flowchart ${direction}`];
let nextNode = 0;
const nodeIds = new Map<string, string>();
for (const [pageIndex, page] of ir.pages.entries()) {
if (ir.pages.length > 1) lines.push(` subgraph page${pageIndex}["${mermaidEscape(page.title)}"]`);
const indent = ir.pages.length > 1 ? " " : " ";
for (const node of page.nodes) {
const safeId = `n${nextNode}`;
nextNode += 1;
nodeIds.set(`${pageIndex}:${node.id}`, safeId);
lines.push(`${indent}${safeId}["${mermaidEscape(node.label)}"]`);
}
if (ir.pages.length > 1) lines.push(" end");
for (const edge of page.edges) {
const source = nodeIds.get(`${pageIndex}:${edge.source}`);
const target = nodeIds.get(`${pageIndex}:${edge.target}`);
if (!source || !target) throw new Error(`Edge ${edge.id} references an unknown endpoint`);
const label = edge.label === undefined ? "" : `|"${mermaidEscape(edge.label)}"|`;
lines.push(` ${source} -->${label} ${target}`);
}
}
return `${lines.join("\n")}\n`;
}
export function diagramIRToStructuredMarkdown(ir: DiagramIRV2): string {
const lines = [`# ${markdownEscape(ir.title ?? "Diagram")}`, ""];
for (const page of ir.pages) {
lines.push(`## ${markdownEscape(page.title)}`, "", "### Nodes", "", "| ID | Label | Kind | Parent |", "| --- | --- | --- | --- |");
for (const node of page.nodes) {
lines.push(`| ${markdownEscape(node.id)} | ${markdownEscape(node.label)} | ${markdownEscape(node.kind ?? "node")} | ${markdownEscape(node.parentId ?? "—")} |`);
}
lines.push("", "### Flows", "", "| ID | From | To | Label |", "| --- | --- | --- | --- |");
if (page.edges.length === 0) lines.push("| — | — | — | No flows |" );
for (const edge of page.edges) {
lines.push(`| ${markdownEscape(edge.id)} | ${markdownEscape(edge.source)} | ${markdownEscape(edge.target)} | ${markdownEscape(edge.label ?? "—")} |`);
}
lines.push("");
}
return lines.join("\n");
}
export const toMermaidFlowchart = diagramIRToMermaid;
export const toMarkdownExplanation = diagramIRToStructuredMarkdown;
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { semanticDiff } from "./semantic-diff.js";
const before: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "removed", label: "Old" },
{ id: "moved", label: "Move", geometry: { x: 0, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "Before", style: "shape=rectangle;" },
{ id: "target", label: "Target" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 20, y: 10 }] },
{ id: "gone", source: "removed", target: "target" },
] }],
};
const after: DiagramIRV2 = {
version: 2,
pages: [{ id: "p", title: "Page", nodes: [
{ id: "moved", label: "Move", geometry: { x: 40, y: 0, width: 100, height: 60 } },
{ id: "changed", label: "After", style: "shape=ellipse;" },
{ id: "target", label: "Target" },
{ id: "added", label: "New" },
], edges: [
{ id: "route", source: "moved", target: "target", waypoints: [{ x: 30, y: 10 }] },
{ id: "new-edge", source: "added", target: "target" },
] }],
};
test("semantic diff classifies added, removed, changed, moved, and rerouted elements", () => {
const diff = semanticDiff(before, after);
assert.deepEqual(diff.added.map((item) => item.id), ["p/added", "p/new-edge"]);
assert.deepEqual(diff.removed.map((item) => item.id), ["p/gone", "p/removed"]);
assert.deepEqual(diff.changed.map((item) => item.id), ["p/changed"]);
assert.deepEqual(diff.moved.map((item) => item.id), ["p/moved"]);
assert.deepEqual(diff.rerouted.map((item) => item.id), ["p/route"]);
});
test("semantic diff values are deeply isolated from both inputs", () => {
const oldDiagram = structuredClone(before);
const newDiagram = structuredClone(after);
newDiagram.pages[0].nodes.find(({ id }) => id === "added")!.extensions = { details: { owner: "platform" } };
const oldSnapshot = structuredClone(oldDiagram);
const newSnapshot = structuredClone(newDiagram);
const diff = semanticDiff(oldDiagram, newDiagram);
(diff.moved[0].before as DiagramNode).geometry!.x = 999;
((diff.added.find(({ id }) => id === "p/added")!.after!.extensions!.details as { owner: string })).owner = "changed";
diff.removed.find(({ id }) => id === "p/removed")!.before!.label = "changed";
assert.deepEqual(oldDiagram, oldSnapshot);
assert.deepEqual(newDiagram, newSnapshot);
});
test("semantic diff is deterministic and does not mutate either input", () => {
const beforeSnapshot = structuredClone(before);
const afterSnapshot = structuredClone(after);
assert.deepEqual(semanticDiff(before, after), semanticDiff(before, after));
assert.deepEqual(before, beforeSnapshot);
assert.deepEqual(after, afterSnapshot);
});
@@ -0,0 +1,100 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
export type SemanticEntity = "node" | "edge";
export interface SemanticDiffItem {
id: string;
entity: SemanticEntity;
before?: DiagramNode | DiagramEdge;
after?: DiagramNode | DiagramEdge;
}
export interface SemanticDiffResult {
added: SemanticDiffItem[];
removed: SemanticDiffItem[];
changed: SemanticDiffItem[];
moved: SemanticDiffItem[];
rerouted: SemanticDiffItem[];
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
const object = value as Record<string, unknown>;
return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function equal(left: unknown, right: unknown): boolean {
return canonical(left) === canonical(right);
}
function without<T extends object>(value: T, omitted: readonly string[]): Record<string, unknown> {
return Object.fromEntries(Object.entries(value).filter(([key]) => !omitted.includes(key)));
}
function position(node: DiagramNode): unknown {
return { parentId: node.parentId, x: node.geometry?.x, y: node.geometry?.y };
}
function nodeMeaning(node: DiagramNode): unknown {
const semantic = without(node, ["geometry", "parentId"]);
if (node.geometry) semantic.geometrySize = { width: node.geometry.width, height: node.geometry.height };
return semantic;
}
function edgeRoute(edge: DiagramEdge): unknown {
return { source: edge.source, target: edge.target, waypoints: edge.waypoints ?? [] };
}
function edgeMeaning(edge: DiagramEdge): unknown {
return without(edge, ["source", "target", "waypoints"]);
}
function index(ir: DiagramIRV2): Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }> {
const result = new Map<string, { entity: SemanticEntity; value: DiagramNode | DiagramEdge }>();
for (const page of ir.pages) {
for (const node of page.nodes) result.set(`${page.id}/${node.id}`, { entity: "node", value: node });
for (const edge of page.edges) result.set(`${page.id}/${edge.id}`, { entity: "edge", value: edge });
}
return result;
}
function ordered(items: SemanticDiffItem[]): SemanticDiffItem[] {
return items.sort((left, right) => left.id.localeCompare(right.id, "en-US") || left.entity.localeCompare(right.entity, "en-US"));
}
function clone<T>(value: T): T {
return structuredClone(value);
}
export function semanticDiff(before: DiagramIRV2, after: DiagramIRV2): SemanticDiffResult {
const oldItems = index(before);
const newItems = index(after);
const result: SemanticDiffResult = { added: [], removed: [], changed: [], moved: [], rerouted: [] };
for (const [id, current] of newItems) {
const previous = oldItems.get(id);
if (!previous || previous.entity !== current.entity) {
result.added.push({ id, entity: current.entity, after: clone(current.value) });
continue;
}
if (current.entity === "node") {
const oldNode = previous.value as DiagramNode;
const newNode = current.value as DiagramNode;
if (!equal(position(oldNode), position(newNode))) result.moved.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
if (!equal(nodeMeaning(oldNode), nodeMeaning(newNode))) result.changed.push({ id, entity: "node", before: clone(oldNode), after: clone(newNode) });
} else {
const oldEdge = previous.value as DiagramEdge;
const newEdge = current.value as DiagramEdge;
if (!equal(edgeRoute(oldEdge), edgeRoute(newEdge))) result.rerouted.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
if (!equal(edgeMeaning(oldEdge), edgeMeaning(newEdge))) result.changed.push({ id, entity: "edge", before: clone(oldEdge), after: clone(newEdge) });
}
}
for (const [id, previous] of oldItems) {
const current = newItems.get(id);
if (!current || current.entity !== previous.entity) result.removed.push({ id, entity: previous.entity, before: clone(previous.value) });
}
for (const values of Object.values(result)) ordered(values);
return result;
}
export const diffDiagramIR = semanticDiff;