/** * Live edge reconstruction (#778, the crux of epic #776). * * A source-derived IR gets its edges from declared AttrRefs. A live IR * (`chant graph --live`) has none — observed resources reference each other by * **physical identifier** buried in their attributes (a subnet's `VpcId`, an * ALB listener's `TargetGroupArn`, an ECS service's `ClusterArn`). This module * reconstructs those relationships from a per-lexicon **reference catalog**. * * Provider-agnostic: the engine here is pure and knows nothing about AWS; each * lexicon ships its own `ReferenceCatalog` (data). Given the live nodes and a * catalog, `reconstructEdges` returns: * - `edges` — peer references → IR edges (holder → referenced) * - `containment` — "inside" references (subnet ∈ VPC) → boundary hints for #779 * - `dangling` — references whose target isn't in the observed set * * The containment / edge split keeps subnet-in-VPC a boundary box (#779), not a * cluttering line. Deterministic given a fixed node set. */ import type { IRNode, IREdge } from "./graph-ir"; /** Which attribute paths identify a resource kind (its id / ARN / name / DNS). */ export interface IdentityRule { kind: string; /** Attr paths whose values are identifiers others reference this kind by. */ ids: string[]; } /** A reference: an attr path on `from` whose value points at another resource. */ export interface RefRule { /** Holder kind. */ from: string; /** Attr path — supports `a.b` and `arr[].id`. */ path: string; /** Which identifier the value is (currently informational; matching is exact). */ match?: "id" | "arn" | "name" | "any"; /** Constrain the target kind (disambiguates identifier collisions). */ targetKind?: string; /** `reference` → an edge; `containment` → a boundary hint (#779), not an edge. */ relation: "reference" | "containment"; /** Edge / containment label (e.g. "in VPC", "sg", "targets"). */ label?: string; /** * What the reconstructed edge's `viaAttr` should be, when traversal needs a * different string than rendering does (#1275). * * `viaAttr` defaulted to `label ?? path`, which serves a renderer well and a * traversal badly. The labels here are human-facing — "sg", "via", "in VPC" — * while a fold like `enrichEffectiveTopology` matches provider attribute * names: `SecurityGroupIds`, `SubnetId`, `LaunchTemplateId`. One field could * not be both, so a rule that is traversed declares the name explicitly and * keeps its label for the picture. * * On a `containment` rule this additionally opts the relation into producing * an edge, on top of the boundary pair it already produces. Containment is * not an edge by default and should not become one — but a fold's first hop * is sometimes exactly a containment relation (an instance is *in* a subnet), * and that hop has to be traversable without duplicating the rule as a * reference and drawing the line twice. */ viaAttr?: string; } /** A lexicon's reference knowledge — its identity map and reference rules. */ export interface ReferenceCatalog { identities: IdentityRule[]; refs: RefRule[]; } /** `child` is contained by `parent` (subnet ∈ VPC). For #779's boundary boxes. */ export interface ContainmentPair { child: string; parent: string; label?: string; } /** A reference whose target isn't in the observed set (cross-account, unmanaged, * deleted) — surfaced, never turned into a wrong edge. */ export interface DanglingRef { from: string; path: string; value: string; targetKind?: string; } export interface ReconstructedEdges { edges: IREdge[]; containment: ContainmentPair[]; /** * The same containment pairs, as edges a query can walk. * * Containment is a boundary when you are drawing it and a relationship when * you are asking about it, and those two consumers had been served by one * decision. `edges` is what a renderer draws as lines, so putting "is in this * VPC" there would draw a line from every resource to its VPC and undo the * boxes; that is why containment is kept out of it, and why it stays out. * * But `->`/`<-` is asking which nodes reach which, and being inside something * is a way of reaching it. "Which subnets have no network interfaces in them" * and "which VPCs have no instances in them" are the same question, and both * are containment. With only `edges` to walk, the negation matched everything * and reported an estate where nothing is anywhere. * * The escape hatch this replaces was per-rule: a containment rule could set * `viaAttr` and become a real edge. That put the query layer's needs in a * field the renderer also reads, and it had to be remembered per rule — * `AWS::EC2::Instance -> Subnet` had it and `AWS::EC2::NetworkInterface -> * Subnet` did not, which is the kind of gap hand-maintained lists always * develop. Deriving them here means a containment rule is traversable because * it is a containment rule, not because someone remembered. */ containmentEdges: IREdge[]; dangling: DanglingRef[]; } function toStr(v: unknown): string | undefined { if (typeof v === "string") return v; if (typeof v === "number" || typeof v === "boolean") return String(v); return undefined; } /** * Read all scalar values at an attr path. Supports nested keys (`a.b`) and array * fan-out (`arr[]`, `arr[].id`). Returns every scalar found — a path through an * array yields one value per element. */ export function readPath(obj: unknown, path: string): string[] { let cur: unknown[] = [obj]; for (const part of path.split(".")) { const isArr = part.endsWith("[]"); const key = isArr ? part.slice(0, -2) : part; const next: unknown[] = []; for (const c of cur) { if (c == null || typeof c !== "object") continue; const val = (c as Record)[key]; if (isArr) { if (Array.isArray(val)) next.push(...val); } else if (val !== undefined) { next.push(val); } } cur = next; } const out: string[] = []; for (const c of cur) { const s = toStr(c); if (s !== undefined) out.push(s); } return out; } /** Merge several lexicons' catalogs into one (concatenate identities + refs). */ export function mergeCatalogs(catalogs: ReferenceCatalog[]): ReferenceCatalog { return { identities: catalogs.flatMap((c) => c.identities), refs: catalogs.flatMap((c) => c.refs), }; } /** * Reconstruct edges + containment from live nodes and a catalog. Pure and * deterministic. Matching is exact on identifier value; identifier collisions * across kinds are disambiguated by `targetKind`. Self-references are dropped. */ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): ReconstructedEdges { // Identity index: identifier value → the node(s) that own it. const index = new Map>(); const add = (value: string, id: string, kind: string) => { (index.get(value) ?? index.set(value, []).get(value)!).push({ id, kind }); }; for (const node of nodes) { // Every node is identified by its own logical id — so references resolved to // a logical id (e.g. a CloudFormation `{Ref: LogicalId}` from exportResources, // #784) match directly — plus its physical id and any catalog identity attrs. add(node.id, node.id, node.kind); if (node.physicalId) add(node.physicalId, node.id, node.kind); for (const rule of catalog.identities) { if (rule.kind !== node.kind) continue; for (const p of rule.ids) for (const v of readPath(node.attrs, p)) add(v, node.id, node.kind); } } const edges: IREdge[] = []; const containment: ContainmentPair[] = []; const containmentEdges: IREdge[] = []; const seenContEdge = new Set(); const dangling: DanglingRef[] = []; const seenEdge = new Set(); const seenCont = new Set(); for (const node of nodes) { for (const rule of catalog.refs) { if (rule.from !== node.kind) continue; for (const value of readPath(node.attrs, rule.path)) { const candidates = index.get(value) ?? []; const match = rule.targetKind ? candidates.find((c) => c.kind === rule.targetKind) : candidates[0]; if (!match) { dangling.push({ from: node.id, path: rule.path, value, ...(rule.targetKind ? { targetKind: rule.targetKind } : {}) }); continue; } if (match.id === node.id) continue; // self-reference (e.g. an SG rule to its own group) const pushEdge = (via: string): void => { const k = `${node.id}|${match.id}|${via}`; if (seenEdge.has(k)) return; seenEdge.add(k); edges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via }); }; if (rule.relation === "containment") { const k = `${node.id}|${match.id}`; if (!seenCont.has(k)) { seenCont.add(k); containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) }); } // Traversable by construction. The attribute the containment was read // through is its traversal name, so `<-attr:` can still discriminate // between two ways of being inside something. const via = rule.viaAttr ?? rule.path; const ke = `${node.id}|${match.id}|${via}`; if (!seenContEdge.has(ke)) { seenContEdge.add(ke); containmentEdges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via }); } // A containment relation is a boundary hint, not an edge — unless the // rule declares a traversal name (#1275). A fold's first hop is // sometimes exactly a containment ("an instance is in a subnet"), and // it must be traversable without duplicating the rule as a reference // and drawing the line twice. if (rule.viaAttr) pushEdge(rule.viaAttr); } else { // Traversal name wins over the rendering label: the labels are // human-facing ("sg", "via"), and a fold matches provider attribute // names (#1275). pushEdge(rule.viaAttr ?? rule.label ?? rule.path); } } } } edges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`)); containmentEdges.sort((a, b) => `${a.from}|${a.to}|${a.viaAttr}`.localeCompare(`${b.from}|${b.to}|${b.viaAttr}`)); containment.sort((a, b) => `${a.child}|${a.parent}`.localeCompare(`${b.child}|${b.parent}`)); dangling.sort((a, b) => `${a.from}|${a.path}|${a.value}`.localeCompare(`${b.from}|${b.path}|${b.value}`)); return { edges, containment, containmentEdges, dangling }; } /** * Invert containment pairs into grouping metadata (#779): container node id → the * node ids directly inside it. The result is the `IRGroups.byContainer` shape a * renderer draws as boundary boxes. It represents the full nesting *flatly* — a * subnet is both a member of its VPC's entry and a key with its own members — * so a boundary-box renderer recurses it (VPC ⊃ subnet ⊃ resources). Sorted for * determinism. */ export function containmentGroups(pairs: ContainmentPair[]): Record { const byParent = new Map>(); for (const { child, parent } of pairs) { (byParent.get(parent) ?? byParent.set(parent, new Set()).get(parent)!).add(child); } const out: Record = {}; for (const parent of [...byParent.keys()].sort()) out[parent] = [...byParent.get(parent)!].sort(); return out; }