@intentius/chant 0.15.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,11 @@
1
1
  import { resolve } from "node:path";
2
2
  import { discoverOps } from "../../op/discover";
3
3
  import { discover } from "../../discovery/index";
4
- import { partitionByLexicon, computeStackGraph } from "../../build";
5
- import { buildGraphIr, type GraphIR } from "../../graph-ir";
4
+ import { partitionByLexicon, computeStackGraph, build } from "../../build";
5
+ import { buildGraphIr, buildLiveGraphIr, overlayGraphs, type GraphIR } from "../../graph-ir";
6
+ import { reconstructEdges, mergeCatalogs, containmentGroups, type ReferenceCatalog, type ContainmentPair } from "../../graph-refs";
7
+ import { observeResources } from "../../lifecycle/observe";
8
+ import { loadChantConfig } from "../../config";
6
9
  import { applyDetail, type DetailLevel } from "../../graph-detail";
7
10
  import { applyLens, parseLens } from "../../graph-lens";
8
11
  import { toMermaid } from "../../graph-mermaid";
@@ -24,7 +27,13 @@ import { computeComponentGraph } from "../../components/cli-support";
24
27
  */
25
28
  export async function runGraph(ctx: CommandContext): Promise<number> {
26
29
  const viewFormats = ["ir", "mermaid", "dot", "layout"] as const;
27
- if ((viewFormats as readonly string[]).includes(ctx.args.format)) {
30
+ const isViewFormat = (viewFormats as readonly string[]).includes(ctx.args.format);
31
+ // `--live` graphs the provisioned (observed) infrastructure, not the declared
32
+ // source (epic #776). It only makes sense as a view format; default to `ir`.
33
+ if (ctx.args.live) {
34
+ return runGraphLive(ctx, isViewFormat ? (ctx.args.format as (typeof viewFormats)[number]) : "ir");
35
+ }
36
+ if (isViewFormat) {
28
37
  return runGraphView(ctx, ctx.args.format as (typeof viewFormats)[number]);
29
38
  }
30
39
  if (ctx.args.components) return runComponentGraph(ctx);
@@ -32,6 +41,116 @@ export async function runGraph(ctx: CommandContext): Promise<number> {
32
41
  return runOpGraph();
33
42
  }
34
43
 
44
+ /**
45
+ * `chant graph --live --env <name> [--format ir|mermaid|dot|layout]` — the
46
+ * **provisioned** graph (C1 of epic #776). Queries each lexicon's
47
+ * `describeResources()` for the environment (managed-only: `owned`) and projects
48
+ * the observed resources into IR nodes. Nodes only — edges are reconstructed by
49
+ * the reference resolver (#778), containment by #779. No lint gate: this reads
50
+ * the cloud, not source.
51
+ */
52
+ async function runGraphLive(
53
+ ctx: CommandContext,
54
+ format: "ir" | "mermaid" | "dot" | "layout",
55
+ ): Promise<number> {
56
+ const { args, plugins } = ctx;
57
+ const environment = args.env;
58
+ if (!environment) {
59
+ console.error(formatError({ message: "chant graph --live needs an environment: --live --env <name>" }));
60
+ return 1;
61
+ }
62
+
63
+ const projectPath = resolve(".");
64
+ const { config } = await loadChantConfig(projectPath);
65
+ if (config.environments && !config.environments.includes(environment)) {
66
+ console.error(formatError({
67
+ message: `Unknown environment "${environment}"`,
68
+ hint: `Defined environments: ${config.environments.join(", ")}`,
69
+ }));
70
+ return 1;
71
+ }
72
+
73
+ // Build to get each lexicon's entity names + output (the scope
74
+ // describeResources needs), mirroring `chant lifecycle snapshot`.
75
+ const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), plugins.map((p) => p.serializer));
76
+ if (buildResult.errors.length > 0) {
77
+ console.error(formatError({ message: "Build failed — fix errors before graphing live state" }));
78
+ return 1;
79
+ }
80
+
81
+ const observing = plugins.filter((p) => p.describeResources);
82
+ if (observing.length === 0) {
83
+ console.error(formatError({ message: "No lexicons implement describeResources — nothing to observe live." }));
84
+ return 1;
85
+ }
86
+
87
+ const { observations, errors } = await observeResources(environment, observing, buildResult, { owned: true });
88
+ for (const e of errors) console.error(formatWarning({ message: e }));
89
+
90
+ let ir: GraphIR = buildLiveGraphIr(observations);
91
+
92
+ // Enrich node attrs from the fuller live config (#784) so references are
93
+ // present for edge reconstruction — describeResources metadata alone is often
94
+ // too thin (e.g. AWS returns stack outputs, not per-resource references).
95
+ for (const p of observing) {
96
+ if (!p.enrichLiveAttrs) continue;
97
+ try {
98
+ const enriched = await p.enrichLiveAttrs({ environment, owned: true });
99
+ ir = {
100
+ ...ir,
101
+ nodes: ir.nodes.map((n) =>
102
+ n.lexicon === p.name && enriched[n.id] ? { ...n, attrs: { ...n.attrs, ...enriched[n.id] } } : n,
103
+ ),
104
+ };
105
+ } catch (err) {
106
+ console.error(formatWarning({ message: `${p.name}: live attr enrichment failed — edges may be sparse (${err instanceof Error ? err.message : String(err)})` }));
107
+ }
108
+ }
109
+
110
+ // Reconstruct edges + containment from live references (#778): merge the
111
+ // observing lexicons' reference catalogs and resolve them over the live nodes.
112
+ const catalogs = observing.map((p) => p.referenceCatalog).filter((c): c is ReferenceCatalog => !!c);
113
+ let containment: ContainmentPair[] = [];
114
+ if (catalogs.length > 0) {
115
+ const reconstructed = reconstructEdges(ir.nodes, mergeCatalogs(catalogs));
116
+ ir = { ...ir, edges: reconstructed.edges };
117
+ containment = reconstructed.containment;
118
+ }
119
+
120
+ // Drift overlay (#780): classify the provisioned graph against declared source
121
+ // (managed / foreign / pending) so a renderer colours the drift.
122
+ if (args.overlay) {
123
+ const declared = await discover(resolve(args.src ?? config.sourceDir ?? "."));
124
+ if (declared.errors.length === 0) {
125
+ ir = overlayGraphs(ir, buildGraphIr(declared.entities, projectPath));
126
+ } else {
127
+ console.error(formatWarning({ message: "overlay: source has discovery errors — showing the provisioned graph without the declared overlay" }));
128
+ }
129
+ }
130
+
131
+ if (args.lens) {
132
+ try {
133
+ ir = applyLens(ir, parseLens(args.lens, { up: args.up, down: args.down }));
134
+ } catch (err) {
135
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
136
+ return 1;
137
+ }
138
+ }
139
+ ir = applyDetail(ir, (args.detail ?? 2) as DetailLevel);
140
+
141
+ // Containment grouping (#779) → boundary boxes. Built after lens/detail and
142
+ // filtered to surviving nodes, so a lens can't leave dangling group refs.
143
+ if (containment.length > 0) {
144
+ const present = new Set(ir.nodes.map((n) => n.id));
145
+ const byContainer = containmentGroups(containment.filter((c) => present.has(c.child) && present.has(c.parent)));
146
+ if (Object.keys(byContainer).length > 0) {
147
+ ir = { ...ir, groups: { ...ir.groups, byContainer } };
148
+ }
149
+ }
150
+
151
+ return emitIr(ir, ctx, format);
152
+ }
153
+
35
154
  /**
36
155
  * `chant graph --components` (#560) — dependency order/waves for discovered
37
156
  * `Component` declarations, mirroring `runStackGraph`'s presentation
@@ -124,7 +243,16 @@ async function runGraphView(
124
243
  }
125
244
  }
126
245
  ir = applyDetail(ir, level as DetailLevel);
246
+ return emitIr(ir, ctx, format);
247
+ }
127
248
 
249
+ /** Emit a built IR in the requested view format — shared by the source
250
+ * (`runGraphView`) and live (`runGraphLive`) paths. */
251
+ async function emitIr(
252
+ ir: GraphIR,
253
+ ctx: CommandContext,
254
+ format: "ir" | "mermaid" | "dot" | "layout",
255
+ ): Promise<number> {
128
256
  switch (format) {
129
257
  case "mermaid":
130
258
  console.log(toMermaid(ir));
package/src/cli/main.ts CHANGED
@@ -92,6 +92,8 @@ export function parseArgs(args: string[]): ParsedArgs {
92
92
  }
93
93
  } else if (arg === "--live") {
94
94
  result.live = true;
95
+ } else if (arg === "--overlay") {
96
+ result.overlay = true;
95
97
  } else if (arg === "--from") {
96
98
  // Shared by `migrate --from <lexicon>` and `import --from <env>`; the
97
99
  // two commands never run together, so one field carries both.
@@ -58,6 +58,9 @@ export interface ParsedArgs {
58
58
  env?: string;
59
59
  /** `chant graph --stacks` — render the cross-stack apply-ordering graph */
60
60
  stacks?: boolean;
61
+ /** `chant graph --live --overlay` — classify the provisioned graph against
62
+ * declared source: managed / foreign / pending (#780). */
63
+ overlay?: boolean;
61
64
  /** `chant list --components` / `chant graph --components` — surface discovered
62
65
  * `Component` declarations (#560) instead of/alongside lexicon resources. */
63
66
  components?: boolean;
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildLiveGraphIr, overlayGraphs, type LiveObservation, type GraphIR } from "./graph-ir";
3
+
4
+ // A fixture "snapshot" — what a lexicon's describeResources() returns for a live
5
+ // environment (managed-only). Two AWS resources; the subnet references the VPC by
6
+ // physical id (that reference is #778's job, not this one — here we only project
7
+ // nodes).
8
+ const observations: LiveObservation[] = [
9
+ {
10
+ lexicon: "aws",
11
+ resources: {
12
+ "web-vpc": {
13
+ type: "AWS::EC2::VPC",
14
+ status: "CREATE_COMPLETE",
15
+ physicalId: "vpc-0a1b",
16
+ attributes: { CidrBlock: "10.0.0.0/16" },
17
+ ownership: "owned",
18
+ },
19
+ "app-subnet": {
20
+ type: "AWS::EC2::Subnet",
21
+ status: "CREATE_COMPLETE",
22
+ physicalId: "subnet-0c2d",
23
+ attributes: { VpcId: "vpc-0a1b", CidrBlock: "10.0.1.0/24" },
24
+ ownership: "owned",
25
+ },
26
+ },
27
+ },
28
+ ];
29
+
30
+ describe("buildLiveGraphIr", () => {
31
+ it("projects observed resources into IR nodes — nodes only, no edges", () => {
32
+ const ir = buildLiveGraphIr(observations);
33
+ expect(ir.nodes.map((n) => n.id)).toEqual(["app-subnet", "web-vpc"]); // sorted
34
+ expect(ir.edges).toEqual([]);
35
+ });
36
+
37
+ it("carries kind, lexicon, physicalId, ownership, and live attrs on each node", () => {
38
+ const ir = buildLiveGraphIr(observations);
39
+ const vpc = ir.nodes.find((n) => n.id === "web-vpc")!;
40
+ expect(vpc.kind).toBe("AWS::EC2::VPC");
41
+ expect(vpc.lexicon).toBe("aws");
42
+ expect(vpc.physicalId).toBe("vpc-0a1b"); // the reference index key for #778
43
+ expect(vpc.ownership).toBe("owned");
44
+ expect(vpc.attrs).toEqual({ CidrBlock: "10.0.0.0/16" });
45
+ });
46
+
47
+ it("groups by lexicon and stack", () => {
48
+ const ir = buildLiveGraphIr(observations);
49
+ expect(ir.groups.byLexicon).toEqual({ aws: ["app-subnet", "web-vpc"] });
50
+ expect(ir.groups.byStack).toEqual({ aws: ["app-subnet", "web-vpc"] });
51
+ });
52
+
53
+ it("omits physicalId/ownership when the observation lacks them", () => {
54
+ const ir = buildLiveGraphIr([
55
+ { lexicon: "k8s", resources: { pod: { type: "Pod", status: "Running" } } },
56
+ ]);
57
+ const node = ir.nodes[0];
58
+ expect(node.physicalId).toBeUndefined();
59
+ expect(node.ownership).toBeUndefined();
60
+ expect(node.attrs).toEqual({});
61
+ });
62
+
63
+ it("is deterministic for a fixed observation set", () => {
64
+ expect(JSON.stringify(buildLiveGraphIr(observations))).toBe(
65
+ JSON.stringify(buildLiveGraphIr(observations)),
66
+ );
67
+ });
68
+ });
69
+
70
+ describe("overlayGraphs (#780 drift overlay)", () => {
71
+ const node = (id: string) => ({ id, kind: "AWS::EC2::VPC", lexicon: "aws", attrs: {} });
72
+ const live: GraphIR = { nodes: [node("web-vpc"), node("rogue-sg")], edges: [], groups: {} };
73
+ const declared: GraphIR = { nodes: [node("web-vpc"), node("planned-db")], edges: [], groups: {} };
74
+
75
+ it("classifies managed / foreign / pending via _status", () => {
76
+ const ir = overlayGraphs(live, declared);
77
+ const statusOf = (id: string) => (ir.nodes.find((n) => n.id === id)!.attrs as { _status?: string })._status;
78
+ expect(statusOf("web-vpc")).toBe("good"); // declared + provisioned
79
+ expect(statusOf("rogue-sg")).toBe("warn"); // provisioned, not declared → foreign
80
+ expect(statusOf("planned-db")).toBe("accent"); // declared, not provisioned → pending
81
+ });
82
+
83
+ it("appends pending nodes and keeps live edges/groups", () => {
84
+ const ir = overlayGraphs({ ...live, edges: [{ from: "rogue-sg", to: "web-vpc", kind: "ref" }] }, declared);
85
+ expect(ir.nodes.map((n) => n.id).sort()).toEqual(["planned-db", "rogue-sg", "web-vpc"]);
86
+ expect(ir.edges).toHaveLength(1);
87
+ });
88
+ });
package/src/graph-ir.ts CHANGED
@@ -5,6 +5,7 @@ import { type Declarable, isDeclarable } from "./declarable";
5
5
  import { isLexiconOutput, type LexiconOutput } from "./lexicon-output";
6
6
  import { getProvenance } from "./provenance";
7
7
  import { INTRINSIC_MARKER } from "./intrinsic";
8
+ import type { ResourceMetadata } from "./lexicon";
8
9
 
9
10
  /**
10
11
  * Graph IR — the engine-neutral, lint-gated representation of a project's
@@ -54,6 +55,17 @@ export interface IRNode {
54
55
  attrs: Record<string, unknown>;
55
56
  /** Where the node was declared. */
56
57
  sourceLoc?: SourceLoc;
58
+ /**
59
+ * Live-only (`chant graph --live`): the observed physical identifier
60
+ * (id / ARN). Absent for source-derived IR. The reference resolver (#778)
61
+ * indexes on this to reconstruct edges from live resource references.
62
+ */
63
+ physicalId?: string;
64
+ /**
65
+ * Live-only: ownership verdict read from the resource's marker (#119/#120).
66
+ * `owned` = chant-managed. Absent for source-derived IR.
67
+ */
68
+ ownership?: "owned" | "foreign";
57
69
  }
58
70
 
59
71
  /** A directed dependency: `from` references an attribute of `to`. */
@@ -76,6 +88,12 @@ export interface IRGroups {
76
88
  * partition today; #513 phase 2 regroups by nested child-project. Consumers
77
89
  * (e.g. pinhole's boundary boxes) read this rather than inferring stacks. */
78
90
  byStack?: Record<string, string[]>;
91
+ /** Live containment (#779): a container node id → the node ids directly inside
92
+ * it (VPC → subnets/SGs, subnet → instances/service). Nested *flatly* — a
93
+ * subnet is both a member of its VPC's entry and a key with its own members —
94
+ * so a boundary-box renderer recurses it. Populated by `chant graph --live`
95
+ * from the reference resolver's containment output; absent for source IR. */
96
+ byContainer?: Record<string, string[]>;
79
97
  }
80
98
 
81
99
  /** A cross-stack export this stack publishes (a `stackOutput`/`output`): its
@@ -331,3 +349,75 @@ function sortKeys(rec: Record<string, string[]>): Record<string, string[]> {
331
349
  for (const k of Object.keys(rec).sort()) out[k] = rec[k];
332
350
  return out;
333
351
  }
352
+
353
+ /** One lexicon's observed resources, keyed by logical name — the output of a
354
+ * plugin's `describeResources()`. The input to {@link buildLiveGraphIr}. */
355
+ export interface LiveObservation {
356
+ lexicon: string;
357
+ resources: Record<string, ResourceMetadata>;
358
+ }
359
+
360
+ /**
361
+ * Build the graph IR from **live-observed** resources (`chant graph --live`) —
362
+ * the provisioned graph, not the declared one. This is the C1 half of epic #776:
363
+ * nodes only. Edges are reconstructed separately by the per-lexicon reference
364
+ * resolver (#778); containment grouping by #779. Pure and deterministic given a
365
+ * fixed observation set (nodes and group ids sorted), so the same snapshot yields
366
+ * identical IR.
367
+ *
368
+ * Each node carries the observed `physicalId` (the reference resolver's index
369
+ * key) and `ownership` marker. `attrs` is the resource's observed attributes.
370
+ */
371
+ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
372
+ const nodes: IRNode[] = [];
373
+ const byLexicon: Record<string, string[]> = {};
374
+ const byStack: Record<string, string[]> = {};
375
+
376
+ for (const { lexicon, resources } of observations) {
377
+ for (const [name, meta] of Object.entries(resources)) {
378
+ const node: IRNode = {
379
+ id: name,
380
+ kind: meta.type,
381
+ lexicon,
382
+ attrs: meta.attributes ?? {},
383
+ };
384
+ if (meta.physicalId) node.physicalId = meta.physicalId;
385
+ if (meta.ownership) node.ownership = meta.ownership;
386
+ nodes.push(node);
387
+ (byLexicon[lexicon] ??= []).push(name);
388
+ // A live lexicon maps to one deployable stack, same as the source IR.
389
+ (byStack[lexicon] ??= []).push(name);
390
+ }
391
+ }
392
+
393
+ nodes.sort((a, b) => a.id.localeCompare(b.id));
394
+ for (const ids of Object.values(byLexicon)) ids.sort();
395
+ for (const ids of Object.values(byStack)) ids.sort();
396
+
397
+ const groups: IRGroups = {};
398
+ if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
399
+ if (Object.keys(byStack).length) groups.byStack = sortKeys(byStack);
400
+
401
+ return { nodes, edges: [], groups };
402
+ }
403
+
404
+ /**
405
+ * Overlay the declared graph on the provisioned one (#780, `chant graph --live
406
+ * --overlay`) and classify each resource, tagging a `_status` a renderer colours:
407
+ * - **managed** (declared + provisioned) → `good`
408
+ * - **foreign** (provisioned, not declared) → `warn`
409
+ * - **pending** (declared, not yet provisioned) → `accent`
410
+ * Live nodes keep their edges/containment; pending nodes are appended (they have
411
+ * no live edges). Sorted; the live groups pass through unchanged.
412
+ */
413
+ export function overlayGraphs(live: GraphIR, declared: GraphIR): GraphIR {
414
+ const declaredIds = new Set(declared.nodes.map((n) => n.id));
415
+ const liveIds = new Set(live.nodes.map((n) => n.id));
416
+ const tagged = (n: IRNode, status: "good" | "warn" | "accent"): IRNode => ({ ...n, attrs: { ...n.attrs, _status: status } });
417
+
418
+ const nodes: IRNode[] = live.nodes.map((n) => tagged(n, declaredIds.has(n.id) ? "good" : "warn"));
419
+ for (const n of declared.nodes) if (!liveIds.has(n.id)) nodes.push(tagged(n, "accent"));
420
+ nodes.sort((a, b) => a.id.localeCompare(b.id));
421
+
422
+ return { ...live, nodes };
423
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reconstructEdges, readPath, mergeCatalogs, containmentGroups, type ReferenceCatalog } from "./graph-refs";
3
+ import type { IRNode } from "./graph-ir";
4
+
5
+ const node = (id: string, kind: string, attrs: Record<string, unknown>, physicalId?: string): IRNode => ({
6
+ id,
7
+ kind,
8
+ lexicon: "test",
9
+ attrs,
10
+ ...(physicalId ? { physicalId } : {}),
11
+ });
12
+
13
+ describe("readPath", () => {
14
+ it("reads nested keys", () => {
15
+ expect(readPath({ a: { b: "x" } }, "a.b")).toEqual(["x"]);
16
+ });
17
+ it("fans out over arrays", () => {
18
+ expect(readPath({ sgs: [{ id: "sg-1" }, { id: "sg-2" }] }, "sgs[].id")).toEqual(["sg-1", "sg-2"]);
19
+ });
20
+ it("reads an array of scalars", () => {
21
+ expect(readPath({ subnets: ["s-1", "s-2"] }, "subnets[]")).toEqual(["s-1", "s-2"]);
22
+ });
23
+ it("returns nothing for a missing path", () => {
24
+ expect(readPath({ a: 1 }, "b.c")).toEqual([]);
25
+ });
26
+ });
27
+
28
+ describe("reconstructEdges", () => {
29
+ // A tiny synthetic model: a "box" contains "things"; things reference a "peer".
30
+ const catalog: ReferenceCatalog = {
31
+ identities: [
32
+ { kind: "Box", ids: ["BoxId"] },
33
+ { kind: "Thing", ids: ["ThingId"] },
34
+ { kind: "Peer", ids: ["PeerId"] },
35
+ ],
36
+ refs: [
37
+ { from: "Thing", path: "boxId", targetKind: "Box", relation: "containment", label: "in box" },
38
+ { from: "Thing", path: "peerIds[]", targetKind: "Peer", relation: "reference", label: "uses" },
39
+ ],
40
+ };
41
+
42
+ const nodes: IRNode[] = [
43
+ node("box", "Box", { BoxId: "box-1" }),
44
+ node("thing", "Thing", { ThingId: "thing-1", boxId: "box-1", peerIds: ["peer-1", "peer-2"] }),
45
+ node("peerA", "Peer", { PeerId: "peer-1" }),
46
+ node("peerB", "Peer", { PeerId: "peer-2" }),
47
+ ];
48
+
49
+ it("emits reference edges (holder → referenced)", () => {
50
+ const { edges } = reconstructEdges(nodes, catalog);
51
+ expect(edges).toEqual([
52
+ { from: "thing", to: "peerA", kind: "ref", viaAttr: "uses" },
53
+ { from: "thing", to: "peerB", kind: "ref", viaAttr: "uses" },
54
+ ]);
55
+ });
56
+
57
+ it("emits containment separately, not as edges", () => {
58
+ const { edges, containment } = reconstructEdges(nodes, catalog);
59
+ expect(containment).toEqual([{ child: "thing", parent: "box", label: "in box" }]);
60
+ // containment is NOT in edges
61
+ expect(edges.some((e) => e.to === "box")).toBe(false);
62
+ });
63
+
64
+ it("surfaces references to absent targets as dangling, never a wrong edge", () => {
65
+ const withDangling = [...nodes, node("orphan", "Thing", { ThingId: "t-2", boxId: "box-1", peerIds: ["peer-GONE"] })];
66
+ const { edges, dangling } = reconstructEdges(withDangling, catalog);
67
+ expect(dangling).toContainEqual({ from: "orphan", path: "peerIds[]", value: "peer-GONE", targetKind: "Peer" });
68
+ expect(edges.some((e) => e.to === undefined)).toBe(false);
69
+ });
70
+
71
+ it("resolves against physicalId too", () => {
72
+ const cat: ReferenceCatalog = { identities: [], refs: [{ from: "Thing", path: "peer", relation: "reference" }] };
73
+ const ns = [node("t", "Thing", { peer: "phys-9" }), node("p", "Peer", {}, "phys-9")];
74
+ expect(reconstructEdges(ns, cat).edges).toEqual([{ from: "t", to: "p", kind: "ref", viaAttr: "peer" }]);
75
+ });
76
+
77
+ it("disambiguates identifier collisions by targetKind", () => {
78
+ const cat: ReferenceCatalog = {
79
+ identities: [{ kind: "A", ids: ["id"] }, { kind: "B", ids: ["id"] }],
80
+ refs: [{ from: "H", path: "ref", targetKind: "B", relation: "reference" }],
81
+ };
82
+ const ns = [node("a", "A", { id: "dup" }), node("b", "B", { id: "dup" }), node("h", "H", { ref: "dup" })];
83
+ expect(reconstructEdges(ns, cat).edges).toEqual([{ from: "h", to: "b", kind: "ref", viaAttr: "ref" }]);
84
+ });
85
+
86
+ it("drops self-references", () => {
87
+ const cat: ReferenceCatalog = { identities: [{ kind: "SG", ids: ["id"] }], refs: [{ from: "SG", path: "peers[]", relation: "reference" }] };
88
+ const ns = [node("sg", "SG", { id: "sg-1", peers: ["sg-1"] })];
89
+ expect(reconstructEdges(ns, cat).edges).toEqual([]);
90
+ });
91
+
92
+ it("is deterministic", () => {
93
+ expect(JSON.stringify(reconstructEdges(nodes, catalog))).toBe(JSON.stringify(reconstructEdges(nodes, catalog)));
94
+ });
95
+ });
96
+
97
+ describe("containmentGroups", () => {
98
+ it("inverts pairs into container → members, representing nesting flatly", () => {
99
+ const groups = containmentGroups([
100
+ { child: "subnetA", parent: "vpc" },
101
+ { child: "subnetB", parent: "vpc" },
102
+ { child: "instance1", parent: "subnetA" },
103
+ ]);
104
+ // vpc contains its subnets; subnetA is a key with its own members (nesting).
105
+ expect(groups).toEqual({ vpc: ["subnetA", "subnetB"], subnetA: ["instance1"] });
106
+ });
107
+
108
+ it("dedupes members and sorts keys + members", () => {
109
+ expect(
110
+ containmentGroups([
111
+ { child: "b", parent: "a" },
112
+ { child: "b", parent: "a" },
113
+ { child: "a", parent: "z" },
114
+ ]),
115
+ ).toEqual({ a: ["b"], z: ["a"] });
116
+ });
117
+
118
+ it("is empty for no containment", () => {
119
+ expect(containmentGroups([])).toEqual({});
120
+ });
121
+ });
122
+
123
+ describe("mergeCatalogs", () => {
124
+ it("concatenates identities and refs", () => {
125
+ const a: ReferenceCatalog = { identities: [{ kind: "A", ids: ["x"] }], refs: [{ from: "A", path: "p", relation: "reference" }] };
126
+ const b: ReferenceCatalog = { identities: [{ kind: "B", ids: ["y"] }], refs: [] };
127
+ const m = mergeCatalogs([a, b]);
128
+ expect(m.identities).toHaveLength(2);
129
+ expect(m.refs).toHaveLength(1);
130
+ });
131
+ });