@intentius/chant 0.33.1 → 0.34.1

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.
Files changed (51) hide show
  1. package/dist/cli/commands/onboard.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  4. package/dist/cli/handlers/search.d.ts +49 -1
  5. package/dist/cli/handlers/search.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +26 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/graph-ir.d.ts +14 -0
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/graph-refs.d.ts +19 -0
  12. package/dist/graph-refs.d.ts.map +1 -1
  13. package/dist/lexicon.d.ts +141 -0
  14. package/dist/lexicon.d.ts.map +1 -1
  15. package/dist/lifecycle/deep-observe.d.ts +4 -0
  16. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  17. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  18. package/dist/lifecycle/observe.d.ts +55 -1
  19. package/dist/lifecycle/observe.d.ts.map +1 -1
  20. package/dist/lifecycle/replay.d.ts +47 -0
  21. package/dist/lifecycle/replay.d.ts.map +1 -0
  22. package/dist/lifecycle/snapshot.d.ts +6 -0
  23. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  24. package/dist/lifecycle/types.d.ts +46 -0
  25. package/dist/lifecycle/types.d.ts.map +1 -1
  26. package/package.json +1 -1
  27. package/src/cli/commands/onboard.ts +10 -25
  28. package/src/cli/handlers/graph.test.ts +74 -0
  29. package/src/cli/handlers/graph.ts +77 -36
  30. package/src/cli/handlers/lifecycle.test.ts +86 -0
  31. package/src/cli/handlers/lifecycle.ts +43 -10
  32. package/src/cli/handlers/search.test.ts +200 -4
  33. package/src/cli/handlers/search.ts +383 -29
  34. package/src/cli/main.ts +9 -0
  35. package/src/cli/registry.ts +27 -0
  36. package/src/codegen/lexicon-wiring.test.ts +53 -0
  37. package/src/codegen/publish-order.test.ts +133 -0
  38. package/src/codegen/release-wiring.test.ts +92 -0
  39. package/src/graph-ir-live.test.ts +83 -0
  40. package/src/graph-ir.ts +51 -1
  41. package/src/graph-refs.test.ts +59 -0
  42. package/src/graph-refs.ts +39 -8
  43. package/src/lexicon.ts +145 -0
  44. package/src/lifecycle/deep-observe.ts +5 -0
  45. package/src/lifecycle/live-diff.test.ts +38 -0
  46. package/src/lifecycle/live-diff.ts +45 -2
  47. package/src/lifecycle/observe.ts +186 -4
  48. package/src/lifecycle/replay.ts +141 -0
  49. package/src/lifecycle/snapshot.test.ts +179 -0
  50. package/src/lifecycle/snapshot.ts +88 -3
  51. package/src/lifecycle/types.ts +47 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Publishing a package runs its prepack, and a prepack builds against and
3
+ * imports its workspace dependencies' *generated* output — output that only
4
+ * exists once that dependency has been published. So publish order has to be a
5
+ * topological order, and for a long time directory order stood in for one.
6
+ *
7
+ * It held until it did not. chant-v0.34.0 published twelve of fourteen packages
8
+ * and stranded lexicon-forgejo and lexicon-helm a version behind: forgejo needs
9
+ * github's `src/generated/index` and helm needs k8s's `dist/generated`, but
10
+ * `forgejo` < `github` and `helm` < `k8s`, so both ran before the thing they
11
+ * import existed. Half a release shipped and the failure only surfaced from
12
+ * npm, after the tag.
13
+ *
14
+ * publish-packages.sh now derives the order instead of assuming one. This
15
+ * asserts the derivation is actually topological, so the next lexicon that
16
+ * depends on an alphabetically earlier one fails here rather than mid-release.
17
+ */
18
+
19
+ import { describe, expect, it } from "vitest";
20
+ import { execFileSync } from "node:child_process";
21
+ import { readFileSync } from "node:fs";
22
+ import { join, dirname } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const REPO = join(dirname(fileURLToPath(import.meta.url)), "../../../..");
26
+
27
+ /** Run one of publish-packages.sh's own functions and read back what it prints. */
28
+ function ask(fn: string): string[] {
29
+ const script = readFileSync(join(REPO, "scripts/publish-packages.sh"), "utf8");
30
+ const body = script.match(new RegExp(`^${fn}\\(\\) \\{$.*?^\\}$`, "ms"));
31
+ if (!body) throw new Error(`${fn}() not found in scripts/publish-packages.sh`);
32
+ return execFileSync("bash", ["-c", `${body[0]}\n${fn}`], { cwd: REPO, encoding: "utf8" })
33
+ .split("\n")
34
+ .filter(Boolean);
35
+ }
36
+
37
+ const publishOrder = () => ask("publishable_dirs");
38
+
39
+ function manifest(dir: string) {
40
+ return JSON.parse(readFileSync(join(REPO, dir, "package.json"), "utf8"));
41
+ }
42
+
43
+ describe("publish order", () => {
44
+ const order = publishOrder();
45
+
46
+ it("covers every publishable workspace package", () => {
47
+ // A package missing from the order never publishes at all, which is the
48
+ // same stranding by a different route.
49
+ const all = execFileSync(
50
+ "bash",
51
+ ["-c", 'for d in packages/*/ lexicons/*/; do [ -f "$d/package.json" ] && echo "${d%/}"; done'],
52
+ { cwd: REPO, encoding: "utf8" },
53
+ )
54
+ .split("\n")
55
+ .filter(Boolean)
56
+ .filter((d) => !manifest(d).private);
57
+
58
+ expect([...order].sort()).toEqual([...all].sort());
59
+ });
60
+
61
+ it("places every package after the workspace packages it depends on", () => {
62
+ const position = new Map(order.map((d, i) => [d, i]));
63
+ const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
64
+
65
+ for (const dir of order) {
66
+ const pkg = manifest(dir);
67
+ const deps = Object.keys({
68
+ ...pkg.dependencies,
69
+ ...pkg.peerDependencies,
70
+ ...pkg.optionalDependencies,
71
+ });
72
+ for (const name of deps) {
73
+ const depDir = owner.get(name);
74
+ if (!depDir || depDir === dir) continue;
75
+ expect(
76
+ position.get(depDir)!,
77
+ `${pkg.name} (${dir}) publishes before its dependency ${name} (${depDir}), ` +
78
+ `so ${name}'s generated output will not exist when ${pkg.name} prepacks`,
79
+ ).toBeLessThan(position.get(dir)!);
80
+ }
81
+ }
82
+ });
83
+
84
+ it("puts the two packages that stranded chant-v0.34.0 after what they import", () => {
85
+ // The specific regression, named, so the failure says what broke rather
86
+ // than only that some invariant did.
87
+ expect(order.indexOf("lexicons/github")).toBeLessThan(order.indexOf("lexicons/forgejo"));
88
+ expect(order.indexOf("lexicons/k8s")).toBeLessThan(order.indexOf("lexicons/helm"));
89
+ });
90
+ });
91
+
92
+ /**
93
+ * Ordering alone did not fix the release. The rerun published nothing, found
94
+ * k8s already at 0.34.0, skipped it — and skipping the publish skipped the
95
+ * prepack that builds `dist/generated`, so helm failed on the same missing
96
+ * module. Anything another package compiles against has to be built whether or
97
+ * not it needs publishing.
98
+ */
99
+ describe("packages built even when their publish is skipped", () => {
100
+ const built = new Set(ask("depended_on_dirs"));
101
+ const order = publishOrder();
102
+
103
+ it("covers every workspace dependency, transitively", () => {
104
+ for (const dir of order) {
105
+ const pkg = manifest(dir);
106
+ const owner = new Map(order.map((d) => [manifest(d).name as string, d]));
107
+ for (const name of Object.keys({
108
+ ...pkg.dependencies,
109
+ ...pkg.peerDependencies,
110
+ ...pkg.optionalDependencies,
111
+ })) {
112
+ const depDir = owner.get(name);
113
+ if (!depDir || depDir === dir) continue;
114
+ expect(
115
+ built.has(depDir),
116
+ `${name} (${depDir}) is a dependency of ${pkg.name} but would not be built when ` +
117
+ `its own publish is skipped, so ${pkg.name} compiles against nothing`,
118
+ ).toBe(true);
119
+ }
120
+ }
121
+ });
122
+
123
+ it("includes the two whose skipped build failed the chant-v0.34.0 rerun", () => {
124
+ expect(built.has("lexicons/k8s")).toBe(true);
125
+ expect(built.has("lexicons/github")).toBe(true);
126
+ });
127
+
128
+ it("does not build a leaf nothing depends on", () => {
129
+ // The set is the reason a rerun is not a full 14-package rebuild.
130
+ expect(built.has("lexicons/helm")).toBe(false);
131
+ expect(built.has("lexicons/forgejo")).toBe(false);
132
+ });
133
+ });
@@ -80,3 +80,95 @@ describe("release wiring: justfile tags vs publish.yml trigger", () => {
80
80
  expect(examples).toContain("lexicon-docker-v9.9.9");
81
81
  });
82
82
  });
83
+
84
+ /**
85
+ * The body of one justfile recipe, up to the next top-level recipe header.
86
+ * Line-based on purpose: a recipe header sits at column 0 and every body line
87
+ * is indented, which a regex over the whole file gets wrong (`^` under /m
88
+ * matches the start of the slice too).
89
+ */
90
+ const RECIPE_HEADER = /^[a-z_][a-z0-9_-]*(\s+[^:]*)?:/;
91
+
92
+ function recipeBody(name: string): string {
93
+ const lines = readFileSync(join(repoRoot, "justfile"), "utf-8").split("\n");
94
+ const start = lines.findIndex((l) => new RegExp(`^${name}(\\s|:)`).test(l));
95
+ if (start === -1) throw new Error(`justfile: recipe "${name}" not found`);
96
+
97
+ let end = lines.length;
98
+ for (let i = start + 1; i < lines.length; i++) {
99
+ if (RECIPE_HEADER.test(lines[i])) {
100
+ end = i;
101
+ break;
102
+ }
103
+ }
104
+ return lines.slice(start, end).join("\n");
105
+ }
106
+
107
+ /**
108
+ * #1255.2 — `just release` rewrites the `@intentius/*` peer ranges alongside
109
+ * `.version` (added in #411, because ranges frozen at `^0.1.0` break clean
110
+ * installs). `release-lexicon` only set `.version`, so a single-lexicon patch
111
+ * needing a newer core silently shipped a stale range.
112
+ *
113
+ * The two recipes pin to different things on purpose: a whole-repo release
114
+ * moves everything together so `^$next` is right, while a single-lexicon
115
+ * patch leaves core where it is, so the range must track the CURRENT core
116
+ * version. Pinning it to `$next` would demand a core that does not exist —
117
+ * which is why this asserts the mechanism, not a shared literal.
118
+ */
119
+ describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
120
+ it("the whole-repo release rewrites both @intentius peer ranges", () => {
121
+ const body = recipeBody("release");
122
+ expect(body).toMatch(/peerDependencies\["@intentius\/chant"\]/);
123
+ expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
124
+ });
125
+
126
+ it("the single-lexicon release rewrites them too", () => {
127
+ const body = recipeBody("release-lexicon");
128
+ expect(
129
+ body.includes('peerDependencies["@intentius/chant"]'),
130
+ "release-lexicon sets .version without touching the @intentius/chant peer range — " +
131
+ "a lexicon patch that needs a newer core would ship a stale range (#411, #1255)",
132
+ ).toBe(true);
133
+ expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
134
+ });
135
+
136
+ it("the single-lexicon release pins peers to core, not to its own new version", () => {
137
+ const body = recipeBody("release-lexicon");
138
+ // It reads the current core version rather than reusing $next.
139
+ expect(body).toMatch(/jq -r \.version packages\/core\/package\.json/);
140
+ expect(
141
+ /peerDependencies\["@intentius\/chant"\]\s*=\s*"\^"\s*\+\s*\$next/.test(body),
142
+ "release-lexicon must not pin the core peer range to the lexicon's own new version",
143
+ ).toBe(false);
144
+ });
145
+
146
+ it("both recipes keep the committed lockfile in step", () => {
147
+ for (const recipe of ["release", "release-lexicon"]) {
148
+ expect(recipeBody(recipe)).toMatch(/npm install --package-lock-only/);
149
+ }
150
+ });
151
+ });
152
+
153
+ /**
154
+ * #1255.3 — both recipes pushed the bump commit straight to main, bypassing
155
+ * branch protection, so a release tag could point at a commit CI never ran.
156
+ */
157
+ describe("release wiring: preflight gates both recipes (#1255)", () => {
158
+ it("every release recipe runs the preflight before tagging", () => {
159
+ for (const recipe of ["release", "release-lexicon"]) {
160
+ const body = recipeBody(recipe);
161
+ expect(
162
+ body.includes("scripts/release-preflight.sh"),
163
+ `justfile recipe "${recipe}" tags and pushes without running the preflight — ` +
164
+ "it could release a commit CI never ran (#1255)",
165
+ ).toBe(true);
166
+ // Ordering matters: a check that runs after `git tag` proves nothing.
167
+ expect(body.indexOf("scripts/release-preflight.sh")).toBeLessThan(body.indexOf("git tag"));
168
+ }
169
+ });
170
+
171
+ it("the whole-repo release requires main, since it pushes main", () => {
172
+ expect(recipeBody("release")).toMatch(/release-preflight\.sh main/);
173
+ });
174
+ });
@@ -157,6 +157,34 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
157
157
  expect(vpc.ownership).toBe("owned");
158
158
  });
159
159
 
160
+ it("carries observed attributes onto a managed declared node (#1279)", () => {
161
+ // Before this, the overlay copied identity but dropped everything observed
162
+ // ABOUT the resource, so a fact that only exists in the account — an
163
+ // instance's VpcId — was unreachable from the overlaid graph and
164
+ // `search --show VpcId` printed a blank column.
165
+ const observed: GraphIR = {
166
+ ...live,
167
+ nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { CidrBlock: "10.0.0.0/16" } } : x)),
168
+ };
169
+ const vpc = sourceOverlayGraphs(declared, observed).nodes.find((x) => x.id === "web-vpc")!;
170
+ expect(vpc.attrs.CidrBlock).toBe("10.0.0.0/16");
171
+ });
172
+
173
+ it("lets an observed value beat the declared one of the same name (#1279)", () => {
174
+ // The declared side holds an unresolved reference to another entity; the
175
+ // account holds the id it actually resolved to.
176
+ const withRef: GraphIR = {
177
+ ...declared,
178
+ nodes: declared.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "${vpc.id}" } } : x)),
179
+ };
180
+ const observed: GraphIR = {
181
+ ...live,
182
+ nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "vpc-0a1b" } } : x)),
183
+ };
184
+ const vpc = sourceOverlayGraphs(withRef, observed).nodes.find((x) => x.id === "web-vpc")!;
185
+ expect(vpc.attrs.VpcId).toBe("vpc-0a1b");
186
+ });
187
+
160
188
  it("appends foreign nodes with their live edges, and keeps declared groups", () => {
161
189
  const ir = sourceOverlayGraphs(declared, live);
162
190
  expect(ir.nodes.map((x) => x.id)).toContain("rogue-sg");
@@ -208,3 +236,58 @@ describe("collectUnobserved (#1089)", () => {
208
236
  ).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
209
237
  });
210
238
  });
239
+
240
+ // #1271 — an observation can report relationships, not just existence. Without
241
+ // these the live side of the graph has no edges, so a topology fold has nothing
242
+ // to traverse and a lexicon has to compute derived answers itself.
243
+ describe("observed edges (#1271)", () => {
244
+ const twoNodes = {
245
+ lexicon: "aws",
246
+ resources: {
247
+ "app-subnet": { type: "AWS::EC2::Subnet", status: "CREATE_COMPLETE", physicalId: "subnet-0c2d" },
248
+ "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE", physicalId: "vpc-0a1b" },
249
+ },
250
+ } satisfies LiveObservation;
251
+
252
+ it("projects an observed edge between two observed nodes", () => {
253
+ const ir = buildLiveGraphIr([
254
+ { ...twoNodes, edges: [{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }] },
255
+ ]);
256
+ expect(ir.edges).toEqual([{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }]);
257
+ });
258
+
259
+ it("no edges reported → no edges, unchanged from before", () => {
260
+ expect(buildLiveGraphIr([twoNodes]).edges).toEqual([]);
261
+ });
262
+
263
+ it("drops an edge whose endpoint was never observed", () => {
264
+ // A dangling reference would traverse to nothing during a fold, which reads
265
+ // as "no such relationship" rather than "the other end was not read".
266
+ const ir = buildLiveGraphIr([
267
+ { ...twoNodes, edges: [{ from: "app-subnet", to: "never-read", kind: "ref", viaAttr: "VpcId" }] },
268
+ ]);
269
+ expect(ir.edges).toEqual([]);
270
+ });
271
+
272
+ it("dedupes identical edges reported by more than one observation", () => {
273
+ const edge = { from: "app-subnet", to: "web-vpc", kind: "ref" as const, viaAttr: "VpcId" };
274
+ const ir = buildLiveGraphIr([
275
+ { ...twoNodes, edges: [edge] },
276
+ { ...twoNodes, edges: [edge] },
277
+ ]);
278
+ expect(ir.edges).toHaveLength(1);
279
+ });
280
+
281
+ it("orders edges deterministically — the IR is compared and committed", () => {
282
+ const ir = buildLiveGraphIr([
283
+ {
284
+ ...twoNodes,
285
+ edges: [
286
+ { from: "web-vpc", to: "app-subnet", kind: "ref", viaAttr: "Z" },
287
+ { from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "A" },
288
+ ],
289
+ },
290
+ ]);
291
+ expect(ir.edges?.map((e) => e.from)).toEqual(["app-subnet", "web-vpc"]);
292
+ });
293
+ });
package/src/graph-ir.ts CHANGED
@@ -489,6 +489,20 @@ export interface LiveObservation {
489
489
  * overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
490
490
  */
491
491
  unobserved?: Record<string, UnobservedEntity>;
492
+ /**
493
+ * Relationships the lexicon observed between the resources it read (#1271).
494
+ *
495
+ * Without these an observation can only say what exists, never how it
496
+ * connects — so a fold over topology has nothing to traverse on the live side
497
+ * and a lexicon has to compute derived answers itself and inject them as
498
+ * attributes. Reporting the edges instead lets the one graph fold in
499
+ * {@link import("./graph-effective").enrichEffectiveTopology} do the work,
500
+ * for every lexicon, over live and recorded observations alike.
501
+ *
502
+ * `from`/`to` are node ids in the same space as {@link LiveObservation.resources}
503
+ * keys, so an edge can only reference something the observation also reported.
504
+ */
505
+ edges?: IREdge[];
492
506
  }
493
507
 
494
508
  /**
@@ -540,7 +554,31 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
540
554
  if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
541
555
  if (Object.keys(byStack).length) groups.byStack = sortKeys(byStack);
542
556
 
543
- return { nodes, edges: [], groups };
557
+ // Observed relationships (#1271). An edge whose endpoints were not both
558
+ // observed is dropped rather than kept as a dangling reference: the fold
559
+ // resolves ids to nodes, and a half-edge would silently traverse to nothing.
560
+ // Deduped and sorted for the same reason nodes are — the IR is compared and
561
+ // committed, so it has to be stable across reads.
562
+ const observedIds = new Set(nodes.map((n) => n.id));
563
+ const seen = new Set<string>();
564
+ const edges: IREdge[] = [];
565
+ for (const observation of observations) {
566
+ for (const edge of observation.edges ?? []) {
567
+ if (!observedIds.has(edge.from) || !observedIds.has(edge.to)) continue;
568
+ const key = `${edge.from}${edge.to}${edge.viaAttr ?? ""}${edge.toAttr ?? ""}`;
569
+ if (seen.has(key)) continue;
570
+ seen.add(key);
571
+ edges.push(edge);
572
+ }
573
+ }
574
+ edges.sort(
575
+ (a, b) =>
576
+ a.from.localeCompare(b.from) ||
577
+ a.to.localeCompare(b.to) ||
578
+ (a.viaAttr ?? "").localeCompare(b.viaAttr ?? ""),
579
+ );
580
+
581
+ return { nodes, edges, groups };
544
582
  }
545
583
 
546
584
  /** How an overlay learns which declared nodes were never looked at (#1089). */
@@ -647,6 +685,18 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
647
685
  const merged: IRNode = { ...n }; // managed — carry the observed identity
648
686
  if (obs.physicalId) merged.physicalId = obs.physicalId;
649
687
  if (obs.ownership) merged.ownership = obs.ownership;
688
+ // ...and what was observed ABOUT it (#1279). The declared canvas holds what
689
+ // the source says; the observation holds what the account says, and a fact
690
+ // like `VpcId` exists only on the second. Dropping it here made every
691
+ // observed property invisible to anything reading the overlaid graph —
692
+ // `search --show VpcId` printed six blank columns, which reads as an estate
693
+ // with no VPCs rather than a read that never happened.
694
+ //
695
+ // Observed wins a collision. The declared value for something like `VpcId`
696
+ // is an unresolved reference to another entity, not an id — the account has
697
+ // the id. Same precedence `enrichLiveAttrs` already uses when it folds live
698
+ // facts onto this graph, so both paths agree on what a name means.
699
+ if (obs.attrs && Object.keys(obs.attrs).length > 0) merged.attrs = { ...n.attrs, ...obs.attrs };
650
700
  return tagStatus(merged, "good");
651
701
  });
652
702
  for (const n of live.nodes) {
@@ -129,3 +129,62 @@ describe("mergeCatalogs", () => {
129
129
  expect(m.refs).toHaveLength(1);
130
130
  });
131
131
  });
132
+
133
+ // #1275 — `viaAttr` defaulted to `label ?? path`, which serves a renderer and
134
+ // starves a traversal: the catalog's labels are human-facing ("sg", "via")
135
+ // while a fold matches provider attribute names ("SecurityGroupIds").
136
+ describe("traversal name vs rendering label (#1275)", () => {
137
+ const nodes: IRNode[] = [
138
+ { id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: { SubnetId: "subnet-1", SecurityGroups: [{ GroupId: "sg-1" }] } },
139
+ { id: "sub", kind: "AWS::EC2::Subnet", lexicon: "aws", attrs: { SubnetId: "subnet-1" } },
140
+ { id: "sg", kind: "AWS::EC2::SecurityGroup", lexicon: "aws", attrs: { GroupId: "sg-1" } },
141
+ ];
142
+ const identities = [
143
+ { kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
144
+ { kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
145
+ ];
146
+
147
+ it("viaAttr wins over label, so the fold can match the attribute name", () => {
148
+ const { edges } = reconstructEdges(nodes, {
149
+ identities,
150
+ refs: [
151
+ { from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
152
+ ],
153
+ });
154
+ expect(edges).toEqual([{ from: "web", to: "sg", kind: "ref", viaAttr: "SecurityGroupIds" }]);
155
+ });
156
+
157
+ it("without viaAttr the label still wins, unchanged", () => {
158
+ const { edges } = reconstructEdges(nodes, {
159
+ identities,
160
+ refs: [
161
+ { from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
162
+ ],
163
+ });
164
+ expect(edges[0].viaAttr).toBe("sg");
165
+ });
166
+
167
+ it("a containment rule with viaAttr yields both the boundary pair and a traversable edge", () => {
168
+ // An instance is *in* a subnet — a boundary for the picture, and the first
169
+ // hop of internetFacing. It has to be both without being drawn twice.
170
+ const { edges, containment } = reconstructEdges(nodes, {
171
+ identities,
172
+ refs: [
173
+ { from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
174
+ ],
175
+ });
176
+ expect(containment).toEqual([{ child: "web", parent: "sub", label: "in subnet" }]);
177
+ expect(edges).toEqual([{ from: "web", to: "sub", kind: "ref", viaAttr: "SubnetId" }]);
178
+ });
179
+
180
+ it("a containment rule without viaAttr stays a boundary hint only", () => {
181
+ const { edges, containment } = reconstructEdges(nodes, {
182
+ identities,
183
+ refs: [
184
+ { from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
185
+ ],
186
+ });
187
+ expect(containment).toHaveLength(1);
188
+ expect(edges).toEqual([]);
189
+ });
190
+ });
package/src/graph-refs.ts CHANGED
@@ -40,6 +40,25 @@ export interface RefRule {
40
40
  relation: "reference" | "containment";
41
41
  /** Edge / containment label (e.g. "in VPC", "sg", "targets"). */
42
42
  label?: string;
43
+ /**
44
+ * What the reconstructed edge's `viaAttr` should be, when traversal needs a
45
+ * different string than rendering does (#1275).
46
+ *
47
+ * `viaAttr` defaulted to `label ?? path`, which serves a renderer well and a
48
+ * traversal badly. The labels here are human-facing — "sg", "via", "in VPC" —
49
+ * while a fold like `enrichEffectiveTopology` matches provider attribute
50
+ * names: `SecurityGroupIds`, `SubnetId`, `LaunchTemplateId`. One field could
51
+ * not be both, so a rule that is traversed declares the name explicitly and
52
+ * keeps its label for the picture.
53
+ *
54
+ * On a `containment` rule this additionally opts the relation into producing
55
+ * an edge, on top of the boundary pair it already produces. Containment is
56
+ * not an edge by default and should not become one — but a fold's first hop
57
+ * is sometimes exactly a containment relation (an instance is *in* a subnet),
58
+ * and that hop has to be traversable without duplicating the rule as a
59
+ * reference and drawing the line twice.
60
+ */
61
+ viaAttr?: string;
43
62
  }
44
63
 
45
64
  /** A lexicon's reference knowledge — its identity map and reference rules. */
@@ -154,17 +173,29 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
154
173
  continue;
155
174
  }
156
175
  if (match.id === node.id) continue; // self-reference (e.g. an SG rule to its own group)
157
- if (rule.relation === "containment") {
158
- const k = `${node.id}|${match.id}`;
159
- if (seenCont.has(k)) continue;
160
- seenCont.add(k);
161
- containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
162
- } else {
163
- const via = rule.label ?? rule.path;
176
+ const pushEdge = (via: string): void => {
164
177
  const k = `${node.id}|${match.id}|${via}`;
165
- if (seenEdge.has(k)) continue;
178
+ if (seenEdge.has(k)) return;
166
179
  seenEdge.add(k);
167
180
  edges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
181
+ };
182
+ if (rule.relation === "containment") {
183
+ const k = `${node.id}|${match.id}`;
184
+ if (!seenCont.has(k)) {
185
+ seenCont.add(k);
186
+ containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
187
+ }
188
+ // A containment relation is a boundary hint, not an edge — unless the
189
+ // rule declares a traversal name (#1275). A fold's first hop is
190
+ // sometimes exactly a containment ("an instance is in a subnet"), and
191
+ // it must be traversable without duplicating the rule as a reference
192
+ // and drawing the line twice.
193
+ if (rule.viaAttr) pushEdge(rule.viaAttr);
194
+ } else {
195
+ // Traversal name wins over the rendering label: the labels are
196
+ // human-facing ("sg", "via"), and a fold matches provider attribute
197
+ // names (#1275).
198
+ pushEdge(rule.viaAttr ?? rule.label ?? rule.path);
168
199
  }
169
200
  }
170
201
  }