134 lines
12 KiB
TypeScript
134 lines
12 KiB
TypeScript
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 };
|
|
}
|