[verified] feat: add semantic diagram lifecycle

This commit is contained in:
2026-09-03 19:11:50 +00:00
parent 5146b14d43
commit db4129168b
37 changed files with 1626 additions and 58 deletions
@@ -0,0 +1,133 @@
import type { DiagramEdge, DiagramIRV2, DiagramNode } from "../../model/diagram-ir.js";
import { validateDiagramIR } from "../../model/diagram-ir.js";
export type ViewName = "executive" | "system" | "deployment" | "dataflow" | "security";
export interface LinkedView { id: ViewName; title: string; nodes: DiagramNode[]; edges: DiagramEdge[]; sourcePageIds: string[]; fallback: boolean; fallbackReason?: string; hint?: string }
export interface QueryOptions { kind?: string; properties?: Record<string, unknown>; from?: string; to?: string }
export interface QueryResult { nodes: DiagramNode[]; edges: DiagramEdge[]; path?: string[] }
export interface PolicyFinding { rule: string; severity: "error" | "warning"; subject: string; message: string; hint: string }
export interface PolicyReport { errors: number; warnings: number; findings: PolicyFinding[] }
function flatten(ir: DiagramIRV2): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
validateDiagramIR(ir);
const pagesByNodeId = new Map<string, string[]>();
for (const page of ir.pages) {
for (const node of page.nodes) pagesByNodeId.set(node.id, [...(pagesByNodeId.get(node.id) ?? []), page.id]);
}
for (const [id, pageIds] of pagesByNodeId) {
if (pageIds.length > 1) throw new Error(`Ambiguous semantic node ID ${id} appears on pages ${pageIds.join(", ")}`);
}
return { nodes: ir.pages.flatMap((page) => page.nodes), edges: ir.pages.flatMap((page) => page.edges) };
}
function induced(nodes: DiagramNode[], edges: DiagramEdge[], selected: Set<string>): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
return { nodes: nodes.filter((node) => selected.has(node.id)).map((node) => structuredClone(node)), edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)).map((edge) => structuredClone(edge)) };
}
export function projectLinkedViews(ir: DiagramIRV2, requested: ViewName[] = ["executive", "system", "deployment", "dataflow", "security"]): LinkedView[] {
const { nodes, edges } = flatten(ir);
const all = new Set(nodes.map((node) => node.id));
const degree = new Map(nodes.map((node) => [node.id, 0]));
for (const edge of edges) { degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }
return requested.map((name) => {
let selected = new Set<string>();
let reason: string | undefined;
let hint: string | undefined;
if (name === "system") selected = all;
else if (name === "executive") {
const hasImportance = nodes.some((node) => typeof node.properties?.importance === "number");
selected = new Set([...nodes].sort((a, b) => Number(b.properties?.importance ?? 0) - Number(a.properties?.importance ?? 0) || (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || a.id.localeCompare(b.id)).slice(0, 12).map((node) => node.id));
if (!hasImportance) { reason = "no properties.importance metadata; ranked by connection degree"; hint = "set properties.importance on executive-significant components"; }
} else if (name === "deployment") {
selected = new Set(nodes.filter((node) => ["environment", "region", "runtime", "host", "deployment"].some((key) => node.properties?.[key] !== undefined)).map((node) => node.id));
if (!selected.size) { selected = all; reason = "no deployment metadata"; hint = "set properties.environment, region, runtime, host, or deployment"; }
} else if (name === "dataflow") {
const relevant = edges.filter((edge) => ["data", "read", "write", "async"].includes(edge.kind ?? "") || /data|event|read|write|publish|consume/i.test(edge.label ?? ""));
selected = new Set(relevant.flatMap((edge) => [edge.source, edge.target]));
if (!selected.size) { selected = all; reason = "no data-flow edges"; hint = "set edge kind to data, read, write, or async"; }
} else {
selected = new Set(nodes.filter((node) => ["external", "gateway", "database", "actor"].includes(node.kind ?? "") || node.properties?.trust_boundary !== undefined).map((node) => node.id));
for (const edge of edges) {
const source = nodes.find((node) => node.id === edge.source)?.properties?.trust_boundary;
const target = nodes.find((node) => node.id === edge.target)?.properties?.trust_boundary;
if (source !== target && (source !== undefined || target !== undefined)) { selected.add(edge.source); selected.add(edge.target); }
}
if (!selected.size) { selected = all; reason = "no trust-boundary or security-kind metadata"; hint = "set properties.trust_boundary or kind external/database/gateway/actor"; }
}
const projection = induced(nodes, edges, selected);
return { id: name, title: name[0].toUpperCase() + name.slice(1), ...projection, sourcePageIds: ir.pages.map((page) => page.id), fallback: reason !== undefined, ...(reason ? { fallbackReason: reason, hint } : {}) };
});
}
export function queryDiagram(ir: DiagramIRV2, options: QueryOptions): QueryResult {
const { nodes, edges } = flatten(ir);
if ((options.from === undefined) !== (options.to === undefined)) throw new Error("Path query requires both from and to");
if (options.from && options.to) {
const ids = new Set(nodes.map((node) => node.id));
if (!ids.has(options.from) || !ids.has(options.to)) throw new Error("Path query references an unknown node");
const queue = [options.from];
const previous = new Map<string, { node: string; edge: DiagramEdge } | null>([[options.from, null]]);
while (queue.length) {
const current = queue.shift()!;
if (current === options.to) break;
for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) {
if (!previous.has(edge.target)) { previous.set(edge.target, { node: current, edge }); queue.push(edge.target); }
}
}
if (!previous.has(options.to)) return { nodes: [], edges: [], path: [] };
const path: string[] = [];
const pathEdges: DiagramEdge[] = [];
let current = options.to;
while (true) {
path.push(current);
const prior = previous.get(current)!;
if (!prior) break;
pathEdges.push(prior.edge);
current = prior.node;
}
path.reverse(); pathEdges.reverse();
const selected = new Set(path);
return { nodes: nodes.filter((node) => selected.has(node.id)), edges: pathEdges, path };
}
const selectedNodes = nodes.filter((node) => (!options.kind || node.kind === options.kind) && Object.entries(options.properties ?? {}).every(([key, value]) => JSON.stringify(node.properties?.[key]) === JSON.stringify(value)));
const selected = new Set(selectedNodes.map((node) => node.id));
return { nodes: selectedNodes, edges: edges.filter((edge) => selected.has(edge.source) && selected.has(edge.target)) };
}
function cycles(nodes: DiagramNode[], edges: DiagramEdge[]): string[][] {
const adjacency = new Map<string, string[]>();
for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort());
const visiting = new Set<string>(); const done = new Set<string>(); const stack: string[] = []; const found: string[][] = [];
const visit = (id: string): void => {
if (visiting.has(id)) { const start = stack.indexOf(id); found.push([...stack.slice(start), id]); return; }
if (done.has(id)) return;
visiting.add(id); stack.push(id);
for (const target of adjacency.get(id) ?? []) visit(target);
stack.pop(); visiting.delete(id); done.add(id);
};
for (const node of [...nodes].sort((a, b) => a.id.localeCompare(b.id))) visit(node.id);
return found;
}
export const POLICY_IDS = ["no-direct-internet-to-database", "no-cycles", "no-orphans", "every-service-has-owner", "production-has-observability", "external-dependencies-have-timeouts", "trust-boundaries-use-protocol"] as const;
export function runPolicies(ir: DiagramIRV2, enabled: readonly string[] = POLICY_IDS): PolicyReport {
const { nodes, edges } = flatten(ir); const byId = new Map(nodes.map((node) => [node.id, node])); const findings: PolicyFinding[] = [];
const add = (rule: string, severity: "error" | "warning", subject: string, message: string, hint: string): void => { findings.push({ rule, severity, subject, message, hint }); };
if (enabled.includes("no-direct-internet-to-database")) for (const edge of edges) { const source = byId.get(edge.source); const target = byId.get(edge.target); if ((source?.kind === "external" || source?.kind === "actor" || /internet/i.test(source?.label ?? "")) && target?.kind === "database") add("no-direct-internet-to-database", "error", edge.id, "internet-facing component connects directly to a database", "insert an authenticated service boundary"); }
if (enabled.includes("no-cycles")) for (const cycle of cycles(nodes, edges)) add("no-cycles", "warning", cycle.join(" -> "), "cyclic dependency detected", "break the cycle or add an asynchronous boundary");
if (enabled.includes("no-orphans") && nodes.length > 1) for (const node of nodes) if (!node.properties?.intentional_orphan && !edges.some((edge) => edge.source === node.id || edge.target === node.id)) add("no-orphans", "warning", node.id, "component is disconnected", "connect it or set properties.intentional_orphan=true");
if (enabled.includes("every-service-has-owner")) for (const node of nodes) if (["service", "gateway", "database", "queue"].includes(node.kind ?? "") && !node.properties?.owner) add("every-service-has-owner", "warning", node.id, "service has no owner", "set properties.owner");
if (enabled.includes("production-has-observability")) for (const node of nodes) if (["prod", "production"].includes(String(node.properties?.environment ?? "").toLowerCase()) && !node.properties?.observability) add("production-has-observability", "warning", node.id, "production component lacks observability metadata", "set properties.observability");
if (enabled.includes("external-dependencies-have-timeouts")) for (const edge of edges) if (byId.get(edge.target)?.kind === "external" && !edge.properties?.timeout) add("external-dependencies-have-timeouts", "warning", edge.id, "external dependency has no timeout", "set edge properties.timeout");
if (enabled.includes("trust-boundaries-use-protocol")) for (const edge of edges) { const a = byId.get(edge.source)?.properties?.trust_boundary; const b = byId.get(edge.target)?.properties?.trust_boundary; if (a !== b && !edge.properties?.protocol && !edge.label) add("trust-boundaries-use-protocol", "warning", edge.id, "unlabelled connection crosses a trust boundary", "set edge properties.protocol"); }
findings.sort((a, b) => a.rule.localeCompare(b.rule) || a.subject.localeCompare(b.subject));
return { errors: findings.filter((item) => item.severity === "error").length, warnings: findings.filter((item) => item.severity === "warning").length, findings };
}
export function simulateFailure(ir: DiagramIRV2, failed: string): { failed: string; impacted: string[]; paths: Record<string, string[]> } {
const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`);
const queue = [failed]; const paths: Record<string, string[]> = { [failed]: [failed] }; const impacted = new Set<string>();
while (queue.length) { const current = queue.shift()!; for (const edge of edges.filter((item) => item.source === current).sort((a, b) => a.target.localeCompare(b.target) || a.id.localeCompare(b.id))) { if (edge.properties?.isolates_failure === true || paths[edge.target]) continue; paths[edge.target] = [...paths[current], edge.target]; impacted.add(edge.target); queue.push(edge.target); } }
return { failed, impacted: [...impacted].sort(), paths };
}