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; 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(); 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): { 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(); 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([[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(); for (const edge of edges) adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target].sort()); const visiting = new Set(); const done = new Set(); 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 } { const { nodes, edges } = flatten(ir); if (!nodes.some((node) => node.id === failed)) throw new Error(`Unknown node: ${failed}`); const queue = [failed]; const paths: Record = { [failed]: [failed] }; const impacted = new Set(); 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 }; }