/** * Carve-out boundary analysis for the strangler-fig emit path (#197). * * The advisor (#214) ranks *what* is cheap to carve. This is the next step: * given a chosen resource, work out the boundary — every dependency edge the * carve would cut — so the surviving Terraform can be patched and the resource * handed off without destroying it. * * Pure analysis over the graph, like the scorer. Emitting the chant source * (via live import) and generating the TF patches live in the command/bridge * layers; this module just classifies the boundary. */ import { inboundEdges, outboundEdges } from "./graph"; import { FOLDS_INTO, resolveTier } from "./tier-map"; import { scoreEstate } from "./score"; import type { TfGraph } from "./types"; /** One resource in the carve set (the selected node plus its folded sub-resources). */ export interface CarveMember { address: string; type?: string; /** Set when this member is a sub-resource inlined into the selected parent. */ foldedInto?: string; } export type BoundaryDirection = "inbound" | "outbound"; /** * A dependency edge the carve cuts. * - inbound → a survivor depends on the carve set. Bridge: rewrite that * reference to a Terraform `data` source. Required immediately, or the * surviving plan breaks. * - outbound → the carve set depends on a survivor. Bridge: the value enters * chant as a deploy-time input. Deferred until apply; only provenance is * recorded at the observe position. * * An inbound edge whose survivor is an `output` block (#1638) is the same * urgency and the same patch — the output's expression is rewritten onto the * data source — but it is called out as `tf-output-rewrite` so a reader can * tell a one-line output edit from a resource's rewiring. */ export interface BoundaryEdge { direction: BoundaryDirection; /** The survivor side of the edge. `output.` for an output block. */ survivor: string; /** The carve-set side of the edge. */ carved: string; attrs: string[]; /** * The referring block's own top-level attribute(s) the reference sits in * (#998). For an outbound edge this names the carved resource's attribute * that reads the survivor — what the deferred input's build parameter is * named after on emit. For an output edge it is `["value"]`. */ via: string[]; bridge: "tf-data-source" | "tf-output-rewrite" | "deferred-input"; required: "immediately" | "at-apply"; } export interface CarveReport { /** The selected resource address. */ target: string; carveSet: CarveMember[]; /** Peelability of the selected resource (from the #214 scorer). */ peelability: number; inbound: BoundaryEdge[]; outbound: BoundaryEdge[]; /** A carve never destroys/recreates; it is reversible via `terraform import`. */ reversible: true; diagnostics: string[]; } /** * The carve set for a selected address: the node itself plus any sub-resources * that fold into it (same name, a `FOLDS_INTO` child type whose parent is the * selection). Those are carried along and inlined, not left behind. */ export function resolveCarveSet(graph: TfGraph, target: string): CarveMember[] { const byAddress = new Map(graph.nodes.map((n) => [n.address, n])); const selected = byAddress.get(target); if (!selected) return []; const members: CarveMember[] = [{ address: selected.address, type: selected.type }]; if (selected.kind === "resource" && selected.type) { for (const node of graph.nodes) { if (node.kind !== "resource" || !node.type) continue; const parentType = FOLDS_INTO[node.type]; if (parentType === selected.type && node.name === selected.name) { members.push({ address: node.address, type: node.type, foldedInto: selected.address }); } } } return members; } /** * Classify the boundary of carving `target`: the inbound edges that need an * immediate data-source patch, and the outbound edges that become deferred * deploy-time inputs. Edges internal to the carve set (e.g. a folded * sub-resource pointing at its parent) are not boundary work. */ export function boundaryReport(graph: TfGraph, target: string): CarveReport | null { const carveSet = resolveCarveSet(graph, target); if (carveSet.length === 0) return null; const inSet = new Set(carveSet.map((m) => m.address)); const inbound: BoundaryEdge[] = []; const outbound: BoundaryEdge[] = []; for (const member of carveSet) { for (const e of inboundEdges(graph, member.address)) { if (inSet.has(e.from)) continue; // internal edge inbound.push({ direction: "inbound", survivor: e.from, carved: e.to, attrs: e.attrs, via: e.via, bridge: e.fromKind === "output" ? "tf-output-rewrite" : "tf-data-source", required: "immediately", }); } for (const e of outboundEdges(graph, member.address)) { if (inSet.has(e.to)) continue; // internal edge outbound.push({ direction: "outbound", survivor: e.to, carved: e.from, attrs: e.attrs, via: e.via, bridge: "deferred-input", required: "at-apply", }); } } const score = scoreEstate(graph).find((r) => r.address === target); const diagnostics: string[] = []; if (resolveTier(carveSet[0].type ?? "") === null && graph.nodes.find((n) => n.address === target)?.kind === "resource") { diagnostics.push(`No known native mapping for ${carveSet[0].type} — emit will have no target type.`); } return { target, carveSet, peelability: score?.score ?? 0, inbound: dedupeEdges(inbound), outbound: dedupeEdges(outbound), reversible: true, diagnostics, }; } /** * The build-parameter name a deferred input's carrying attribute becomes on * emit (#998) — shared so bridge notes and emitted `buildParams` agree. */ export function deferredParamName(tfAttr: string): string { return tfAttr.replace(/\W+/g, "_"); } /** Collapse duplicate edges (same survivor/carved pair), merging attrs. */ function dedupeEdges(edges: BoundaryEdge[]): BoundaryEdge[] { const byKey = new Map(); for (const e of edges) { const key = `${e.survivor}${e.carved}`; const prev = byKey.get(key); if (prev) { prev.attrs = [...new Set([...prev.attrs, ...e.attrs])].sort(); prev.via = [...new Set([...prev.via, ...e.via])].sort(); } else { byKey.set(key, { ...e, attrs: [...e.attrs], via: [...e.via] }); } } return [...byKey.values()].sort((a, b) => (a.survivor < b.survivor ? -1 : a.survivor > b.survivor ? 1 : 0)); }