@intentius/chant 0.37.2 → 0.39.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.
Files changed (130) hide show
  1. package/dist/build.d.ts +21 -0
  2. package/dist/build.d.ts.map +1 -1
  3. package/dist/cli/commands/build.d.ts.map +1 -1
  4. package/dist/cli/commands/check-lexicon-mcp.d.ts +44 -0
  5. package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
  6. package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
  7. package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
  8. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  9. package/dist/cli/handlers/emulator.d.ts.map +1 -1
  10. package/dist/cli/handlers/graph.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  13. package/dist/cli/handlers/search.d.ts +30 -1
  14. package/dist/cli/handlers/search.d.ts.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/mcp/server.d.ts +26 -2
  17. package/dist/cli/mcp/server.d.ts.map +1 -1
  18. package/dist/cli/plugins.d.ts +20 -0
  19. package/dist/cli/plugins.d.ts.map +1 -1
  20. package/dist/codegen/registry.d.ts +23 -0
  21. package/dist/codegen/registry.d.ts.map +1 -0
  22. package/dist/components/sandbox/driver.d.ts.map +1 -1
  23. package/dist/composite.d.ts +23 -4
  24. package/dist/composite.d.ts.map +1 -1
  25. package/dist/deep-observation.d.ts +11 -0
  26. package/dist/deep-observation.d.ts.map +1 -1
  27. package/dist/discovery/sandbox/driver.d.ts.map +1 -1
  28. package/dist/graph-declared.d.ts.map +1 -1
  29. package/dist/graph-ir.d.ts +17 -3
  30. package/dist/graph-ir.d.ts.map +1 -1
  31. package/dist/graph-refs.d.ts +24 -0
  32. package/dist/graph-refs.d.ts.map +1 -1
  33. package/dist/kubectl-context.d.ts.map +1 -1
  34. package/dist/lexicon-config.d.ts +61 -0
  35. package/dist/lexicon-config.d.ts.map +1 -0
  36. package/dist/lexicon.d.ts +85 -37
  37. package/dist/lexicon.d.ts.map +1 -1
  38. package/dist/lifecycle/deep-diff.d.ts +11 -0
  39. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/digest.d.ts.map +1 -1
  41. package/dist/lifecycle/identity.d.ts +52 -0
  42. package/dist/lifecycle/identity.d.ts.map +1 -0
  43. package/dist/lifecycle/observe.d.ts +5 -0
  44. package/dist/lifecycle/observe.d.ts.map +1 -1
  45. package/dist/lifecycle/replay.d.ts.map +1 -1
  46. package/dist/lifecycle/types.d.ts +30 -0
  47. package/dist/lifecycle/types.d.ts.map +1 -1
  48. package/dist/live-endpoint.d.ts +21 -22
  49. package/dist/live-endpoint.d.ts.map +1 -1
  50. package/dist/managed-fields.d.ts +11 -0
  51. package/dist/managed-fields.d.ts.map +1 -1
  52. package/dist/op/emulator-freshness.d.ts +44 -0
  53. package/dist/op/emulator-freshness.d.ts.map +1 -0
  54. package/dist/op/emulator-lifecycle.d.ts +36 -0
  55. package/dist/op/emulator-lifecycle.d.ts.map +1 -1
  56. package/dist/op/index.d.ts +4 -2
  57. package/dist/op/index.d.ts.map +1 -1
  58. package/dist/ownership.d.ts +33 -0
  59. package/dist/ownership.d.ts.map +1 -1
  60. package/dist/serializer.d.ts +15 -0
  61. package/dist/serializer.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/audit/catalog.test.ts +58 -6
  64. package/src/build.ts +24 -0
  65. package/src/cli/commands/build.ts +10 -0
  66. package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
  67. package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
  68. package/src/cli/commands/check-lexicon-mcp.ts +103 -0
  69. package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
  70. package/src/cli/commands/check-lexicon-plugin.ts +115 -0
  71. package/src/cli/commands/check-lexicon.ts +176 -26
  72. package/src/cli/handlers/components.test.ts +17 -0
  73. package/src/cli/handlers/components.ts +1 -1
  74. package/src/cli/handlers/emulator.ts +12 -8
  75. package/src/cli/handlers/graph.test.ts +71 -12
  76. package/src/cli/handlers/graph.ts +46 -5
  77. package/src/cli/handlers/lifecycle.test.ts +25 -4
  78. package/src/cli/handlers/lifecycle.ts +19 -3
  79. package/src/cli/handlers/run-client.ts +3 -1
  80. package/src/cli/handlers/search-kind.test.ts +45 -0
  81. package/src/cli/handlers/search.ts +102 -4
  82. package/src/cli/main.ts +32 -10
  83. package/src/cli/mcp/server.test.ts +82 -0
  84. package/src/cli/mcp/server.ts +40 -5
  85. package/src/cli/param-flag-scope.test.ts +69 -0
  86. package/src/cli/plugins.test.ts +33 -1
  87. package/src/cli/plugins.ts +55 -0
  88. package/src/codegen/registry.test.ts +56 -0
  89. package/src/codegen/registry.ts +69 -0
  90. package/src/components/SPRAWL-VALIDATION.md +5 -5
  91. package/src/components/sandbox/driver.test.ts +27 -0
  92. package/src/components/sandbox/driver.ts +12 -0
  93. package/src/composite.ts +33 -4
  94. package/src/deep-observation.ts +11 -0
  95. package/src/discovery/sandbox/driver.test.ts +34 -0
  96. package/src/discovery/sandbox/driver.ts +19 -0
  97. package/src/graph-declared.test.ts +86 -0
  98. package/src/graph-declared.ts +14 -2
  99. package/src/graph-ir.ts +32 -8
  100. package/src/graph-refs.test.ts +56 -0
  101. package/src/graph-refs.ts +37 -1
  102. package/src/kubectl-context.ts +4 -1
  103. package/src/lexicon-config.test.ts +111 -0
  104. package/src/lexicon-config.ts +92 -0
  105. package/src/lexicon-doc-coverage.test.ts +128 -0
  106. package/src/lexicon-seams.test.ts +113 -0
  107. package/src/lexicon.ts +88 -38
  108. package/src/lifecycle/deep-diff.test.ts +48 -1
  109. package/src/lifecycle/deep-diff.ts +16 -0
  110. package/src/lifecycle/digest.test.ts +81 -0
  111. package/src/lifecycle/digest.ts +34 -3
  112. package/src/lifecycle/identity.test.ts +39 -0
  113. package/src/lifecycle/identity.ts +61 -0
  114. package/src/lifecycle/observe.test.ts +75 -1
  115. package/src/lifecycle/observe.ts +28 -2
  116. package/src/lifecycle/replay.test.ts +251 -0
  117. package/src/lifecycle/replay.ts +67 -19
  118. package/src/lifecycle/types.ts +26 -0
  119. package/src/live-endpoint.test.ts +51 -12
  120. package/src/live-endpoint.ts +32 -33
  121. package/src/managed-fields.test.ts +50 -0
  122. package/src/managed-fields.ts +25 -6
  123. package/src/meta/peer-deps.test.ts +111 -14
  124. package/src/op/emulator-declaration.test.ts +63 -0
  125. package/src/op/emulator-freshness.test.ts +135 -0
  126. package/src/op/emulator-freshness.ts +102 -0
  127. package/src/op/emulator-lifecycle.ts +49 -0
  128. package/src/op/index.ts +4 -2
  129. package/src/ownership.ts +41 -0
  130. package/src/serializer.ts +16 -0
@@ -59,6 +59,17 @@ export interface PropertyDrift {
59
59
  * accepted value there would lose the most useful column in the report.
60
60
  */
61
61
  baseline?: unknown;
62
+ /**
63
+ * The field manager that owns this path live, where the substrate records one
64
+ * (#1189) — Kubernetes' `managedFields`, and nowhere else today.
65
+ *
66
+ * `kind` says a path is `undeclared` or `changed`; this says who did it.
67
+ * "Owned by `kubectl-client-side-apply`" and "owned by `hpa-controller`" are
68
+ * the same `kind` and mean opposite things: one is somebody bypassing the
69
+ * pipeline, the other is a controller doing its job. Absent on a substrate
70
+ * with no per-field ownership, which is every substrate but k8s.
71
+ */
72
+ owner?: string;
62
73
  }
63
74
 
64
75
  /** Property-level drift for one declared entity. */
@@ -173,11 +184,16 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
173
184
  if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
174
185
 
175
186
  const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
187
+ // Who owns the path live, where the substrate records it (#1189). Only
188
+ // meaningful for a path that exists live — an `absent` drift has no live
189
+ // field for anyone to own.
190
+ const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
176
191
  const drift: PropertyDrift = {
177
192
  path,
178
193
  kind,
179
194
  ...(hasDeclared ? { declared: declaredValue } : {}),
180
195
  ...(hasLive ? { live: liveValue } : {}),
196
+ ...(owner ? { owner } : {}),
181
197
  };
182
198
 
183
199
  const acceptedEntry = acceptedDeviation(baseline, name, path);
@@ -115,3 +115,84 @@ describe("diffDigests", () => {
115
115
  expect(result.removed.sort()).toEqual(["c"]);
116
116
  });
117
117
  });
118
+
119
+ /**
120
+ * chant #1442 — a digest records what interpreted the declarations, not only
121
+ * what was declared.
122
+ */
123
+ function withVersions(versions: Record<string, string> | undefined): BuildResult {
124
+ const result = makeBuildResult({ k8s: [{ name: "app", type: "K8s::Apps::Deployment", props: { replicas: 2 } }] });
125
+ return { ...result, lexiconVersions: versions } as unknown as BuildResult;
126
+ }
127
+
128
+ describe("computeBuildDigest — lexicon versions (#1442)", () => {
129
+ test("records the version of each lexicon that served the build", () => {
130
+ expect(computeBuildDigest(withVersions({ k8s: "0.38.0" })).lexiconVersions).toEqual({ k8s: "0.38.0" });
131
+ });
132
+
133
+ test("records once per lexicon, not once per resource", () => {
134
+ const many = makeBuildResult({
135
+ k8s: [
136
+ { name: "a", type: "K8s::Apps::Deployment", props: {} },
137
+ { name: "b", type: "K8s::Core::Service", props: {} },
138
+ ],
139
+ });
140
+ const digest = computeBuildDigest({ ...many, lexiconVersions: { k8s: "0.38.0" } } as unknown as BuildResult);
141
+ expect(Object.keys(digest.resources)).toHaveLength(2);
142
+ expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
143
+ });
144
+
145
+ test("a build with no plugins records an empty map, not absence", () => {
146
+ // Absent and empty mean different things when read back: absent is
147
+ // "recorded before #1442", empty is "recorded, nothing loaded".
148
+ expect(computeBuildDigest(withVersions(undefined)).lexiconVersions).toEqual({});
149
+ });
150
+
151
+ test("the recorded map is a copy, so later mutation cannot rewrite history", () => {
152
+ const versions = { k8s: "0.38.0" };
153
+ const digest = computeBuildDigest(withVersions(versions));
154
+ versions.k8s = "0.39.0";
155
+ expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
156
+ });
157
+ });
158
+
159
+ describe("diffDigests — lexicon version changes (#1442)", () => {
160
+ const resources = { app: { type: "K8s::Apps::Deployment", lexicon: "k8s", propsHash: "same" } };
161
+ const digest = (lexiconVersions?: Record<string, string>): BuildDigest =>
162
+ ({ resources, dependencies: {}, outputs: {}, deployOrder: ["k8s"], lexiconVersions }) as BuildDigest;
163
+
164
+ test("reports a bump even when every resource is unchanged", () => {
165
+ const diff = diffDigests(digest({ k8s: "0.39.0" }), digest({ k8s: "0.38.0" }));
166
+ expect(diff.changed).toEqual([]);
167
+ expect(diff.unchanged).toEqual(["app"]);
168
+ expect(diff.lexiconVersionChanges).toEqual([{ lexicon: "k8s", previous: "0.38.0", current: "0.39.0" }]);
169
+ });
170
+
171
+ test("reports nothing when versions match", () => {
172
+ expect(diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
173
+ });
174
+
175
+ test("reports a lexicon added to or dropped from the build", () => {
176
+ const added = diffDigests(digest({ k8s: "0.38.0", aws: "0.38.0" }), digest({ k8s: "0.38.0" }));
177
+ expect(added.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: undefined, current: "0.38.0" }]);
178
+
179
+ const dropped = diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0", aws: "0.38.0" }));
180
+ expect(dropped.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: "0.38.0", current: undefined }]);
181
+ });
182
+
183
+ test("a pre-#1442 snapshot reports no change rather than inventing one", () => {
184
+ // The older digest never recorded versions. Every lexicon would otherwise
185
+ // look newly-added on the first comparison after upgrading chant.
186
+ expect(diffDigests(digest({ k8s: "0.38.0" }), digest(undefined)).lexiconVersionChanges).toEqual([]);
187
+ expect(diffDigests(digest(undefined), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
188
+ });
189
+
190
+ test("with no previous digest at all, there is no version change", () => {
191
+ expect(diffDigests(digest({ k8s: "0.38.0" }), undefined).lexiconVersionChanges).toEqual([]);
192
+ });
193
+
194
+ test("changes are ordered by lexicon name, so output is stable", () => {
195
+ const diff = diffDigests(digest({ k8s: "2", aws: "2", gcp: "2" }), digest({ k8s: "1", aws: "1", gcp: "1" }));
196
+ expect(diff.lexiconVersionChanges.map((c) => c.lexicon)).toEqual(["aws", "gcp", "k8s"]);
197
+ });
198
+ });
@@ -41,6 +41,10 @@ export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
41
41
 
42
42
  return {
43
43
  resources,
44
+ // chant #1442 — what interpreted the declarations, alongside what was
45
+ // declared. Always present on a freshly computed digest, so "absent"
46
+ // unambiguously means "recorded before #1442".
47
+ lexiconVersions: { ...buildResult.lexiconVersions },
44
48
  dependencies,
45
49
  outputs: buildResult.manifest.outputs,
46
50
  deployOrder: buildResult.manifest.deployOrder,
@@ -60,9 +64,10 @@ export function diffDigests(
60
64
  const unchanged: string[] = [];
61
65
 
62
66
  if (!previous) {
63
- // No previous digest — everything is added
67
+ // No previous digest — everything is added, and there is no version to
68
+ // have moved away from.
64
69
  added.push(...Object.keys(current.resources));
65
- return { added, removed, changed, unchanged };
70
+ return { added, removed, changed, unchanged, lexiconVersionChanges: [] };
66
71
  }
67
72
 
68
73
  // Check current resources against previous
@@ -84,5 +89,31 @@ export function diffDigests(
84
89
  }
85
90
  }
86
91
 
87
- return { added, removed, changed, unchanged };
92
+ return { added, removed, changed, unchanged, lexiconVersionChanges: diffLexiconVersions(current, previous) };
93
+ }
94
+
95
+ /**
96
+ * Lexicons whose version moved between two digests (chant #1442).
97
+ *
98
+ * A digest recorded before this existed has no `lexiconVersions` at all. That
99
+ * is reported as no change rather than as every lexicon appearing — comparing
100
+ * against an older snapshot must not manufacture a difference that the older
101
+ * build simply never recorded.
102
+ */
103
+ function diffLexiconVersions(
104
+ current: BuildDigest,
105
+ previous: BuildDigest,
106
+ ): Array<{ lexicon: string; previous?: string; current?: string }> {
107
+ if (!current.lexiconVersions || !previous.lexiconVersions) return [];
108
+
109
+ const changes: Array<{ lexicon: string; previous?: string; current?: string }> = [];
110
+ for (const lexicon of new Set([
111
+ ...Object.keys(current.lexiconVersions),
112
+ ...Object.keys(previous.lexiconVersions),
113
+ ])) {
114
+ const now = current.lexiconVersions[lexicon];
115
+ const before = previous.lexiconVersions[lexicon];
116
+ if (now !== before) changes.push({ lexicon, previous: before, current: now });
117
+ }
118
+ return changes.sort((a, b) => a.lexicon.localeCompare(b.lexicon));
88
119
  }
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { regionOf, unqualifiedKey } from "./identity";
3
+
4
+ describe("regionOf", () => {
5
+ it("reads the region a lexicon stamped", () => {
6
+ expect(regionOf({ attributes: { region: "us-west-1" } })).toBe("us-west-1");
7
+ });
8
+
9
+ it("treats a missing, empty or non-string region as none", () => {
10
+ expect(regionOf({})).toBeUndefined();
11
+ expect(regionOf({ attributes: {} })).toBeUndefined();
12
+ expect(regionOf({ attributes: { region: "" } })).toBeUndefined();
13
+ expect(regionOf({ attributes: { region: 1 } })).toBeUndefined();
14
+ });
15
+ });
16
+
17
+ describe("unqualifiedKey", () => {
18
+ it("carries the region when there is one", () => {
19
+ expect(unqualifiedKey("sg-1", { attributes: { region: "us-east-1" } })).toBe("us-east-1::sg-1");
20
+ });
21
+
22
+ it("leaves an id with no region bare", () => {
23
+ expect(unqualifiedKey("arn:aws:iam::1:policy/p", {})).toBe("arn:aws:iam::1:policy/p");
24
+ });
25
+
26
+ it("is idempotent, so a second pass does not double-qualify", () => {
27
+ const meta = { attributes: { region: "us-east-1" } };
28
+ expect(unqualifiedKey(unqualifiedKey("sg-1", meta), meta)).toBe("us-east-1::sg-1");
29
+ });
30
+
31
+ it("qualifies an id that already carries a DIFFERENT region", () => {
32
+ // Not a case the readers produce, but the guard is a prefix test and it
33
+ // should not silently accept `us-west-1::sg-1` as already being in
34
+ // us-east-1.
35
+ expect(unqualifiedKey("us-west-1::sg-1", { attributes: { region: "us-east-1" } })).toBe(
36
+ "us-east-1::us-west-1::sg-1",
37
+ );
38
+ });
39
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * What makes an unqualified resource one resource (#1416).
3
+ *
4
+ * Managed resources are stack-qualified (`${stack}::${id}`, #1162) and cannot
5
+ * collide. Everything else — dependencies (#1273) and ambient resources (#1278)
6
+ * — is keyed by physical id and merged across stacks, because the account's
7
+ * default VPC route table is one resource however many stacks route through it.
8
+ *
9
+ * That is right for a resource that really is account-level, and wrong for a
10
+ * regional one. A VPC, subnet or security group in `us-west-1` is a different
11
+ * resource from one in `us-east-1`, and merging them by id drops all but the
12
+ * first sighting.
13
+ *
14
+ * On AWS the difference is invisible: VPC, subnet and security-group ids are
15
+ * globally unique, so keying by id and keying by (region, id) give the same
16
+ * answer. It shows up against an emulator that reuses ids across regions
17
+ * (lex00/floci#21), where a three-region estate's nine default subnets merged
18
+ * to three and "how many subnets are empty" came back 2 instead of 8.
19
+ *
20
+ * The rule is deliberately not a table of regional kinds. A lexicon stamps a
21
+ * region on a resource when region is part of that resource's identity — the
22
+ * AWS lexicon does it in `stampRegion` for ambient resources and its own
23
+ * property reads — and this reads that stamp back. A genuinely account-level
24
+ * resource carries no region and keeps merging as it always did, which is why
25
+ * dependencies (never stamped) are untouched by this.
26
+ */
27
+
28
+ import type { ResourceMetadata } from "../lexicon";
29
+
30
+ /**
31
+ * The region a resource records itself as being in, if it records one.
32
+ *
33
+ * Read from `attributes.region`, which is where a lexicon stamps it. Absent
34
+ * means the resource is account-level, or the lexicon has no region to give —
35
+ * both of which are read the same way, as "region is not part of this id".
36
+ */
37
+ export function regionOf(meta: Pick<ResourceMetadata, "attributes">): string | undefined {
38
+ const region = meta.attributes?.region;
39
+ return typeof region === "string" && region.length > 0 ? region : undefined;
40
+ }
41
+
42
+ /**
43
+ * The key an unqualified resource merges under.
44
+ *
45
+ * `${region}::${id}` for a resource that carries a region, matching the shape
46
+ * stack qualification already uses, and the bare id for one that does not.
47
+ *
48
+ * Applied only where results from more than one region are actually merged —
49
+ * `collectAmbient` across stacks, `replaySnapshots` across recorded stacks.
50
+ * Recording one region's observation qualifies nothing, so a stored snapshot
51
+ * keeps the keys it has always had and the first snapshot taken after this
52
+ * lands does not diff as "every ambient resource replaced".
53
+ *
54
+ * Idempotent, so an id that already carries this region survives a second pass
55
+ * unchanged rather than becoming `us-east-1::us-east-1::sg-1`.
56
+ */
57
+ export function unqualifiedKey(id: string, meta: Pick<ResourceMetadata, "attributes">): string {
58
+ const region = regionOf(meta);
59
+ if (!region) return id;
60
+ return id.startsWith(`${region}::`) ? id : `${region}::${id}`;
61
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import { observeResources } from "./observe";
2
+ import { observeResources, collectAmbient } from "./observe";
3
3
  import { observation } from "../observation";
4
4
  import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
5
5
  import type { BuildResult } from "../build";
@@ -246,3 +246,77 @@ describe("observeResources", () => {
246
246
  expect(calls[0]).not.toHaveProperty("stack");
247
247
  });
248
248
  });
249
+
250
+ describe("collectAmbient — one resource per region, not per account (#1416)", () => {
251
+ const subnets = (region: string): Record<string, ResourceMetadata> =>
252
+ Object.fromEntries(
253
+ ["subnet-default-a", "subnet-default-b"].map((id) => [
254
+ id,
255
+ {
256
+ type: "AWS::EC2::Subnet",
257
+ status: "OBSERVED",
258
+ physicalId: id,
259
+ ambient: true,
260
+ attributes: { region },
261
+ } as ResourceMetadata,
262
+ ]),
263
+ );
264
+
265
+ function ambientPlugin(): ObservationLexicon {
266
+ return {
267
+ name: "aws",
268
+ describeResources: async () => ({}),
269
+ observeAmbient: async (opts: { region?: string }) => subnets(opts.region ?? "us-east-1"),
270
+ } as unknown as ObservationLexicon;
271
+ }
272
+
273
+ it("keeps every region's copy when the ids collide across regions", async () => {
274
+ const found = await collectAmbient(ambientPlugin(), {
275
+ environment: "prod",
276
+ kinds: ["AWS::EC2::Subnet"],
277
+ observed: {},
278
+ stacks: [
279
+ { name: "east", region: "us-east-1" },
280
+ { name: "west1", region: "us-west-1" },
281
+ { name: "west2", region: "us-west-2" },
282
+ ],
283
+ warnings: [],
284
+ });
285
+ expect(Object.keys(found).sort()).toEqual([
286
+ "us-east-1::subnet-default-a",
287
+ "us-east-1::subnet-default-b",
288
+ "us-west-1::subnet-default-a",
289
+ "us-west-1::subnet-default-b",
290
+ "us-west-2::subnet-default-a",
291
+ "us-west-2::subnet-default-b",
292
+ ]);
293
+ });
294
+
295
+ it("still merges two stacks in the same region to one resource each", async () => {
296
+ const found = await collectAmbient(ambientPlugin(), {
297
+ environment: "prod",
298
+ kinds: ["AWS::EC2::Subnet"],
299
+ observed: {},
300
+ stacks: [
301
+ { name: "web", region: "us-east-1" },
302
+ { name: "api", region: "us-east-1" },
303
+ ],
304
+ warnings: [],
305
+ });
306
+ expect(Object.keys(found).sort()).toEqual([
307
+ "us-east-1::subnet-default-a",
308
+ "us-east-1::subnet-default-b",
309
+ ]);
310
+ });
311
+
312
+ it("leaves a single stack's ids bare — one region is not a merge", async () => {
313
+ const found = await collectAmbient(ambientPlugin(), {
314
+ environment: "prod",
315
+ kinds: ["AWS::EC2::Subnet"],
316
+ observed: {},
317
+ stacks: [{ name: "main", region: "us-east-1" }],
318
+ warnings: [],
319
+ });
320
+ expect(Object.keys(found).sort()).toEqual(["subnet-default-a", "subnet-default-b"]);
321
+ });
322
+ });
@@ -24,6 +24,7 @@ import {
24
24
  type NormalizedObservation,
25
25
  } from "../observation";
26
26
  import { zeroResourcesWarning } from "../live-endpoint";
27
+ import { unqualifiedKey } from "./identity";
27
28
 
28
29
  export interface ObserveResult {
29
30
  observations: LiveObservation[];
@@ -208,7 +209,21 @@ export async function observeResources(
208
209
  warnings,
209
210
  })
210
211
  : {};
211
- for (const [id, meta] of Object.entries(ambient)) dependencies.resources[id] ??= meta;
212
+ // A resource can be reached as a dependency AND enumerated as ambient —
213
+ // the default VPC an instance sits in is both. It is one resource, and
214
+ // the dependency entry is the one carrying `referencedBy`, so it wins.
215
+ // Matched on physical id rather than on key, because ambient keys carry a
216
+ // region (#1416) and dependency keys do not, so the keys no longer
217
+ // collide even when the resource is the same one.
218
+ const dependedOn = new Set(
219
+ Object.values(dependencies.resources)
220
+ .map((m) => m.physicalId)
221
+ .filter((id): id is string => typeof id === "string"),
222
+ );
223
+ for (const [id, meta] of Object.entries(ambient)) {
224
+ if (meta.physicalId && dependedOn.has(meta.physicalId)) continue;
225
+ dependencies.resources[id] ??= meta;
226
+ }
212
227
  pushObservation(
213
228
  observations,
214
229
  warnings,
@@ -268,6 +283,11 @@ function scopeToStack(
268
283
  * Ask a lexicon what exists of the kinds it manages, beyond what is declared
269
284
  * (#1278). Once per stack for the region, merged by physical id — the same
270
285
  * ambient resource seen from two stacks is one resource.
286
+ *
287
+ * "The same" is per region (#1416). Two stacks in one region reporting the
288
+ * account's default security group is one group; two regions' default security
289
+ * groups are two, whatever their ids look like, so the merge key carries the
290
+ * region a resource records itself in.
271
291
  */
272
292
  export async function collectAmbient(
273
293
  plugin: ObservationLexicon,
@@ -282,6 +302,10 @@ export async function collectAmbient(
282
302
  if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
283
303
  const found: Record<string, ResourceMetadata> = {};
284
304
  const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
305
+ // Only a merge needs region-qualified keys, and one ref is not a merge. This
306
+ // is what keeps a single-region project's ids exactly what they were, and a
307
+ // recorded snapshot — always one stack, so always one ref — bare.
308
+ const merging = refs.length > 1;
285
309
  for (const ref of refs) {
286
310
  try {
287
311
  const part = await plugin.observeAmbient({
@@ -291,7 +315,9 @@ export async function collectAmbient(
291
315
  ...(ref.name ? { stack: ref.name } : {}),
292
316
  ...(ref.region ? { region: ref.region } : {}),
293
317
  });
294
- for (const [id, meta] of Object.entries(part)) found[id] ??= meta;
318
+ for (const [id, meta] of Object.entries(part)) {
319
+ found[merging ? unqualifiedKey(id, meta) : id] ??= meta;
320
+ }
295
321
  } catch (err) {
296
322
  opts.warnings.push(
297
323
  `${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
@@ -0,0 +1,251 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import type { LifecycleSnapshot } from "./types";
3
+
4
+ // `replaySnapshots` reads the orphan branch through this one function, so the
5
+ // stored set is the whole input surface.
6
+ const stored = new Map<string, string>();
7
+ vi.mock("./git", () => ({
8
+ readEnvironmentSnapshots: async (): Promise<Map<string, string>> => stored,
9
+ }));
10
+
11
+ const { replaySnapshots } = await import("./replay");
12
+
13
+ /** One region's recorded stack: an instance it manages, plus ambient resources. */
14
+ function snapshot(
15
+ stack: string,
16
+ region: string,
17
+ opts: {
18
+ ambient?: Record<string, { type: string; physicalId?: string }>;
19
+ managed?: Record<string, string>;
20
+ edges?: Array<{ from: string; to: string; kind: "ref" }>;
21
+ } = {},
22
+ ): string {
23
+ const snap: LifecycleSnapshot = {
24
+ lexicon: "aws",
25
+ environment: "prod",
26
+ stack,
27
+ commit: "abc123",
28
+ timestamp: "2026-08-03T00:00:00.000Z",
29
+ resources: {
30
+ ...Object.fromEntries(
31
+ Object.entries(opts.managed ?? {}).map(([name, physicalId]) => [
32
+ name,
33
+ { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId },
34
+ ]),
35
+ ),
36
+ ...Object.fromEntries(
37
+ Object.entries(opts.ambient ?? {}).map(([id, meta]) => [
38
+ id,
39
+ {
40
+ type: meta.type,
41
+ status: "OBSERVED",
42
+ physicalId: meta.physicalId ?? id,
43
+ ambient: true,
44
+ ownership: "foreign" as const,
45
+ // What `stampRegion` puts there on the real path (#1279).
46
+ attributes: { region },
47
+ },
48
+ ]),
49
+ ),
50
+ },
51
+ ...(opts.edges ? { edges: opts.edges } : {}),
52
+ };
53
+ return JSON.stringify(snap);
54
+ }
55
+
56
+ function keys(observations: Array<{ resources: Record<string, unknown> }>): string[] {
57
+ return observations.flatMap((o) => Object.keys(o.resources)).sort();
58
+ }
59
+
60
+ beforeEach(() => {
61
+ stored.clear();
62
+ });
63
+
64
+ describe("replaySnapshots — ambient identity across regions (#1416)", () => {
65
+ it("keeps each region's copy when the ids collide", async () => {
66
+ // What Floci does (lex00/floci#21): the default VPC and its subnets carry
67
+ // the same id strings in every region. Real AWS ids are globally unique, so
68
+ // this is only observable against the emulator — but the merge is the same
69
+ // merge either way.
70
+ for (const region of ["us-east-1", "us-west-1", "us-west-2"]) {
71
+ stored.set(`${region}__aws`, snapshot(region, region, {
72
+ ambient: {
73
+ "vpc-default": { type: "AWS::EC2::VPC" },
74
+ "subnet-default-a": { type: "AWS::EC2::Subnet" },
75
+ "subnet-default-b": { type: "AWS::EC2::Subnet" },
76
+ },
77
+ }));
78
+ }
79
+
80
+ const result = await replaySnapshots("prod", "latest", new Set());
81
+ if ("error" in result) throw new Error(result.error);
82
+
83
+ // Nine ambient resources went in; before this fix three came out.
84
+ expect(keys(result.observations)).toEqual([
85
+ "us-east-1::subnet-default-a",
86
+ "us-east-1::subnet-default-b",
87
+ "us-east-1::vpc-default",
88
+ "us-west-1::subnet-default-a",
89
+ "us-west-1::subnet-default-b",
90
+ "us-west-1::vpc-default",
91
+ "us-west-2::subnet-default-a",
92
+ "us-west-2::subnet-default-b",
93
+ "us-west-2::vpc-default",
94
+ ]);
95
+ });
96
+
97
+ it("still merges one region's resource seen from two stacks", async () => {
98
+ // The case the account-level rule was written for: two stacks in the same
99
+ // region each recorded the region's default security group. That is one
100
+ // group, and counting it twice would inflate every count over it.
101
+ stored.set("web__aws", snapshot("web", "us-east-1", {
102
+ ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
103
+ }));
104
+ stored.set("api__aws", snapshot("api", "us-east-1", {
105
+ ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
106
+ }));
107
+
108
+ const result = await replaySnapshots("prod", "latest", new Set());
109
+ if ("error" in result) throw new Error(result.error);
110
+ expect(keys(result.observations)).toEqual(["us-east-1::sg-default"]);
111
+ });
112
+
113
+ it("leaves a single recorded stack's ids exactly as recorded", async () => {
114
+ // One stack is one region: nothing to merge, so nothing to disambiguate.
115
+ // A single-region project's ids must not move.
116
+ stored.set("main__aws", snapshot("main", "us-east-1", {
117
+ ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
118
+ managed: { web: "i-123" },
119
+ }));
120
+
121
+ const result = await replaySnapshots("prod", "latest", new Set());
122
+ if ("error" in result) throw new Error(result.error);
123
+ expect(keys(result.observations)).toEqual(["sg-default", "web"]);
124
+ });
125
+
126
+ it("does not region-qualify a managed resource", async () => {
127
+ // Managed ids join the declared canvas, which qualifies by stack or not at
128
+ // all. Region-qualifying `web` would unjoin it from its own declaration.
129
+ for (const region of ["us-east-1", "us-west-1"]) {
130
+ stored.set(`${region}__aws`, snapshot(region, region, { managed: { web: `i-${region}` } }));
131
+ }
132
+
133
+ const result = await replaySnapshots("prod", "latest", new Set(["us-east-1", "us-west-1"]));
134
+ if ("error" in result) throw new Error(result.error);
135
+ expect(keys(result.observations)).toEqual(["us-east-1::web", "us-west-1::web"]);
136
+ });
137
+
138
+ it("leaves an ambient resource with no recorded region alone", async () => {
139
+ // A resource carrying no region is account-level as far as anything here
140
+ // can tell, and account-level is what the original merge assumed. IAM is
141
+ // the shape: one policy, seen from every region's stack.
142
+ const global = (stack: string): string =>
143
+ JSON.stringify({
144
+ lexicon: "aws",
145
+ environment: "prod",
146
+ stack,
147
+ commit: "abc123",
148
+ timestamp: "2026-08-03T00:00:00.000Z",
149
+ resources: {
150
+ "arn:aws:iam::1:policy/p": {
151
+ type: "AWS::IAM::Policy",
152
+ status: "OBSERVED",
153
+ physicalId: "arn:aws:iam::1:policy/p",
154
+ ambient: true,
155
+ },
156
+ },
157
+ } satisfies LifecycleSnapshot);
158
+ stored.set("us-east-1__aws", global("us-east-1"));
159
+ stored.set("us-west-1__aws", global("us-west-1"));
160
+
161
+ const result = await replaySnapshots("prod", "latest", new Set());
162
+ if ("error" in result) throw new Error(result.error);
163
+ expect(keys(result.observations)).toEqual(["arn:aws:iam::1:policy/p"]);
164
+ });
165
+
166
+ it("re-points edges at the keys the resources ended up under", async () => {
167
+ // An edge naming `subnet-default-a` has to reach the copy in ITS region,
168
+ // not whichever region sorted first. A dangling edge is worse than the
169
+ // collision it replaced: the node is present and simply unreachable.
170
+ for (const region of ["us-east-1", "us-west-1"]) {
171
+ stored.set(`${region}__aws`, snapshot(region, region, {
172
+ ambient: {
173
+ "subnet-default-a": { type: "AWS::EC2::Subnet" },
174
+ "eni-1": { type: "AWS::EC2::NetworkInterface" },
175
+ },
176
+ edges: [{ from: "eni-1", to: "subnet-default-a", kind: "ref" }],
177
+ }));
178
+ }
179
+
180
+ const result = await replaySnapshots("prod", "latest", new Set());
181
+ if ("error" in result) throw new Error(result.error);
182
+ const edges = result.observations.flatMap((o) => o.edges ?? []);
183
+ expect(edges).toEqual([
184
+ { from: "us-east-1::eni-1", to: "us-east-1::subnet-default-a", kind: "ref" },
185
+ { from: "us-west-1::eni-1", to: "us-west-1::subnet-default-a", kind: "ref" },
186
+ ]);
187
+ // Every endpoint resolves to a node that exists.
188
+ const nodes = new Set(keys(result.observations));
189
+ for (const edge of edges) {
190
+ expect(nodes.has(edge.from)).toBe(true);
191
+ expect(nodes.has(edge.to)).toBe(true);
192
+ }
193
+ });
194
+ });
195
+
196
+ describe("a resource recorded twice is one node (#1432 follow-up)", () => {
197
+ // A subnet its stack declares, ALSO recorded as a dependency because
198
+ // instances reference it. Same physical subnet, two entries.
199
+ function snap(stack: string): string {
200
+ return JSON.stringify({
201
+ lexicon: "aws", environment: "prod", stack,
202
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
203
+ resources: {
204
+ publicSubnet: { type: "AWS::EC2::Subnet", status: "OBSERVED", physicalId: "subnet-9af06b90" },
205
+ "subnet-9af06b90": {
206
+ type: "AWS::EC2::Subnet", status: "OBSERVED", physicalId: "subnet-9af06b90",
207
+ referencedBy: ["webServer"],
208
+ },
209
+ webServer: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" },
210
+ },
211
+ edges: [{ from: "webServer", to: "subnet-9af06b90", kind: "ref", viaAttr: "SubnetId" }],
212
+ });
213
+ }
214
+
215
+ it("drops the duplicate rather than counting the subnet twice", async () => {
216
+ stored.set("main__aws", snap("main"));
217
+ const result = await replaySnapshots("prod", "latest", new Set());
218
+ if ("error" in result) throw new Error(result.error);
219
+ expect(keys(result.observations)).toEqual(["publicSubnet", "webServer"]);
220
+ });
221
+
222
+ it("re-points the dropped duplicate's edges at the survivor", async () => {
223
+ // Dropping the node alone would take this edge with it: buildLiveGraphIr
224
+ // discards an edge whose endpoints were not both observed, so the instance
225
+ // would stop being in any subnet at all.
226
+ stored.set("main__aws", snap("main"));
227
+ const result = await replaySnapshots("prod", "latest", new Set());
228
+ if ("error" in result) throw new Error(result.error);
229
+ expect(result.observations.flatMap((o) => o.edges ?? [])).toEqual([
230
+ { from: "webServer", to: "publicSubnet", kind: "ref", viaAttr: "SubnetId" },
231
+ ]);
232
+ });
233
+
234
+ it("keeps a dependency nothing manages", async () => {
235
+ // The account's default route table: referenced, managed by nobody. It is
236
+ // not a duplicate and must survive.
237
+ stored.set("main__aws", JSON.stringify({
238
+ lexicon: "aws", environment: "prod", stack: "main",
239
+ commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
240
+ resources: {
241
+ "rtb-default": {
242
+ type: "AWS::EC2::RouteTable", status: "OBSERVED", physicalId: "rtb-default",
243
+ referencedBy: ["webServer"],
244
+ },
245
+ },
246
+ }));
247
+ const result = await replaySnapshots("prod", "latest", new Set());
248
+ if ("error" in result) throw new Error(result.error);
249
+ expect(keys(result.observations)).toEqual(["rtb-default"]);
250
+ });
251
+ });