@intentius/chant 0.38.0 → 0.41.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 (106) hide show
  1. package/dist/apply.d.ts +171 -0
  2. package/dist/apply.d.ts.map +1 -0
  3. package/dist/build.d.ts +21 -0
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/cli/commands/build.d.ts.map +1 -1
  6. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  7. package/dist/cli/commands/doctor.d.ts.map +1 -1
  8. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  9. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  10. package/dist/cli/handlers/search.d.ts +30 -1
  11. package/dist/cli/handlers/search.d.ts.map +1 -1
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/plugins.d.ts +20 -0
  14. package/dist/cli/plugins.d.ts.map +1 -1
  15. package/dist/codegen/naming.d.ts +48 -1
  16. package/dist/codegen/naming.d.ts.map +1 -1
  17. package/dist/codegen/registry.d.ts +23 -0
  18. package/dist/codegen/registry.d.ts.map +1 -0
  19. package/dist/codegen/validate.d.ts +31 -0
  20. package/dist/codegen/validate.d.ts.map +1 -1
  21. package/dist/components/sandbox/driver.d.ts.map +1 -1
  22. package/dist/composite.d.ts +23 -4
  23. package/dist/composite.d.ts.map +1 -1
  24. package/dist/deep-observation.d.ts +11 -0
  25. package/dist/deep-observation.d.ts.map +1 -1
  26. package/dist/discovery/index.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/index.d.ts +1 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/kubectl-context.d.ts.map +1 -1
  36. package/dist/lexicon-config.d.ts +61 -0
  37. package/dist/lexicon-config.d.ts.map +1 -0
  38. package/dist/lexicon.d.ts +19 -0
  39. package/dist/lexicon.d.ts.map +1 -1
  40. package/dist/lifecycle/deep-diff.d.ts +11 -0
  41. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  42. package/dist/lifecycle/digest.d.ts.map +1 -1
  43. package/dist/lifecycle/identity.d.ts +52 -0
  44. package/dist/lifecycle/identity.d.ts.map +1 -0
  45. package/dist/lifecycle/observe.d.ts +5 -0
  46. package/dist/lifecycle/observe.d.ts.map +1 -1
  47. package/dist/lifecycle/replay.d.ts.map +1 -1
  48. package/dist/lifecycle/types.d.ts +30 -0
  49. package/dist/lifecycle/types.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/package.json +1 -1
  53. package/src/apply.test.ts +169 -0
  54. package/src/apply.ts +249 -0
  55. package/src/build.ts +24 -0
  56. package/src/cli/commands/build.ts +10 -0
  57. package/src/cli/commands/check-lexicon.ts +20 -1
  58. package/src/cli/commands/doctor.test.ts +45 -0
  59. package/src/cli/commands/doctor.ts +40 -0
  60. package/src/cli/handlers/lifecycle.ts +10 -0
  61. package/src/cli/handlers/run-client.ts +3 -1
  62. package/src/cli/handlers/search-kind.test.ts +45 -0
  63. package/src/cli/handlers/search.ts +102 -4
  64. package/src/cli/main.ts +32 -10
  65. package/src/cli/param-flag-scope.test.ts +69 -0
  66. package/src/cli/plugins.test.ts +33 -1
  67. package/src/cli/plugins.ts +55 -0
  68. package/src/codegen/naming.test.ts +129 -0
  69. package/src/codegen/naming.ts +72 -1
  70. package/src/codegen/registry.test.ts +56 -0
  71. package/src/codegen/registry.ts +69 -0
  72. package/src/codegen/validate.test.ts +86 -0
  73. package/src/codegen/validate.ts +74 -0
  74. package/src/components/SPRAWL-VALIDATION.md +5 -5
  75. package/src/components/sandbox/driver.test.ts +27 -0
  76. package/src/components/sandbox/driver.ts +12 -0
  77. package/src/composite.ts +33 -4
  78. package/src/deep-observation.ts +11 -0
  79. package/src/discovery/index.ts +59 -0
  80. package/src/discovery/params-cjs-warning.test.ts +75 -0
  81. package/src/discovery/sandbox/driver.test.ts +34 -0
  82. package/src/discovery/sandbox/driver.ts +19 -0
  83. package/src/graph-declared.test.ts +86 -0
  84. package/src/graph-declared.ts +14 -2
  85. package/src/graph-ir.ts +32 -8
  86. package/src/graph-refs.test.ts +56 -0
  87. package/src/graph-refs.ts +37 -1
  88. package/src/index.ts +1 -0
  89. package/src/kubectl-context.ts +4 -1
  90. package/src/lexicon-config.test.ts +111 -0
  91. package/src/lexicon-config.ts +92 -0
  92. package/src/lexicon.ts +20 -0
  93. package/src/lifecycle/deep-diff.test.ts +48 -1
  94. package/src/lifecycle/deep-diff.ts +16 -0
  95. package/src/lifecycle/digest.test.ts +81 -0
  96. package/src/lifecycle/digest.ts +34 -3
  97. package/src/lifecycle/identity.test.ts +39 -0
  98. package/src/lifecycle/identity.ts +61 -0
  99. package/src/lifecycle/observe.test.ts +75 -1
  100. package/src/lifecycle/observe.ts +28 -2
  101. package/src/lifecycle/replay.test.ts +251 -0
  102. package/src/lifecycle/replay.ts +67 -19
  103. package/src/lifecycle/types.ts +26 -0
  104. package/src/managed-fields.test.ts +50 -0
  105. package/src/managed-fields.ts +25 -6
  106. package/src/meta/peer-deps.test.ts +111 -14
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The config namespace a lexicon owns (#1344).
3
+ *
4
+ * Three lexicons read a top-level `chant.config.ts` key named after themselves —
5
+ * `k8s.profiles.<env>.context`, `temporal.profiles`, `forgejo.runnerLabels` and
6
+ * `forgejo.actionsRoot` — all documented for users, none declared anywhere.
7
+ * `ChantConfigSchema` is `.passthrough()`, so at runtime any key is accepted and
8
+ * a typo is silently ignored: write `runnerLabel` and the Forgejo dialect just
9
+ * uses its defaults, with nothing said. The `ChantConfig` interface is closed,
10
+ * so the documented forgejo example did not even compile:
11
+ *
12
+ * error TS2353: Object literal may only specify known properties,
13
+ * and 'forgejo' does not exist in type 'ChantConfig'.
14
+ *
15
+ * Three lexicons had arrived at three different workarounds: temporal exported
16
+ * its own widened `TemporalChantConfig`, k8s's docs dropped `satisfies`, and
17
+ * forgejo's kept it and were wrong.
18
+ *
19
+ * A lexicon now declares the shape of its namespace. The declaration is both
20
+ * halves of the fix at once: core validates against it at load, and the lexicon
21
+ * derives the type it augments `ChantConfig` with from the same schema, so the
22
+ * runtime rule and the compile-time one cannot disagree.
23
+ */
24
+
25
+ import type { ZodObject, ZodRawShape } from "zod";
26
+ import type { ChantConfig } from "./config";
27
+
28
+ /**
29
+ * A lexicon's config schema. A `ZodObject` specifically, so core can apply
30
+ * `.strict()` itself rather than trusting each lexicon to remember — an unknown
31
+ * key at the top of a declared namespace is a typo, and silently ignoring it is
32
+ * the behavior this replaces.
33
+ *
34
+ * Nested objects are the lexicon's own responsibility: author them with
35
+ * `z.strictObject` so a typo in `k8s.profiles.prod.contxt` fails too. `.strict()`
36
+ * applies to one level.
37
+ */
38
+ export type LexiconConfigSchema = ZodObject<ZodRawShape>;
39
+
40
+ /** What a lexicon needs to expose for its namespace to be validated. */
41
+ export interface ConfigOwningLexicon {
42
+ name: string;
43
+ configSchema?: LexiconConfigSchema;
44
+ }
45
+
46
+ export interface LexiconConfigProblem {
47
+ /** The lexicon whose namespace failed. */
48
+ lexicon: string;
49
+ /** Dotted path to the offending value, e.g. `forgejo.runnerLabel`. */
50
+ path: string;
51
+ message: string;
52
+ }
53
+
54
+ /**
55
+ * Validate each lexicon's own namespace against the schema it declares.
56
+ *
57
+ * A lexicon that declares nothing keeps today's passthrough — silence for an
58
+ * unknown key — because tightening a namespace nobody described would fail
59
+ * configs that are working. Declaring is the opt-in.
60
+ *
61
+ * An absent namespace is not a problem: every one of them is optional.
62
+ */
63
+ export function validateLexiconConfig(
64
+ lexicons: readonly ConfigOwningLexicon[],
65
+ config: ChantConfig | undefined,
66
+ ): LexiconConfigProblem[] {
67
+ if (!config) return [];
68
+ const problems: LexiconConfigProblem[] = [];
69
+ const raw = config as unknown as Record<string, unknown>;
70
+
71
+ for (const lexicon of lexicons) {
72
+ const schema = lexicon.configSchema;
73
+ if (!schema) continue;
74
+ const value = raw[lexicon.name];
75
+ if (value === undefined) continue;
76
+
77
+ const result = schema.strict().safeParse(value);
78
+ if (result.success) continue;
79
+
80
+ for (const issue of result.error.issues) {
81
+ const path = [lexicon.name, ...issue.path.map(String)].join(".");
82
+ problems.push({ lexicon: lexicon.name, path, message: issue.message });
83
+ }
84
+ }
85
+
86
+ return problems;
87
+ }
88
+
89
+ /** One line per problem, for a CLI error. */
90
+ export function formatLexiconConfigProblems(problems: readonly LexiconConfigProblem[]): string {
91
+ return problems.map((p) => ` ${p.path}: ${p.message}`).join("\n");
92
+ }
package/src/lexicon.ts CHANGED
@@ -10,6 +10,7 @@ import type { McpToolContribution, McpResourceContribution } from "./mcp/types";
10
10
  import type { DriverComponent } from "./components/driver";
11
11
  import type { EmulatorDeclaration } from "./op/emulator-lifecycle";
12
12
  import type { OwnershipChannel } from "./ownership";
13
+ import type { LexiconConfigSchema } from "./lexicon-config";
13
14
  import type { RuleMeta } from "./audit/catalog";
14
15
  import type { ReferenceCatalog } from "./graph-refs";
15
16
  import type { IREdge } from "./graph-ir";
@@ -440,6 +441,25 @@ export interface LexiconPlugin {
440
441
  /** Package lexicon into distributable tarball */
441
442
  package(options?: { verbose?: boolean; force?: boolean }): Promise<void>;
442
443
 
444
+ /**
445
+ * The shape of this lexicon's own `chant.config.ts` namespace — the top-level
446
+ * key named after the lexicon (#1344).
447
+ *
448
+ * k8s reads `k8s.profiles.<env>.context`, temporal `temporal.profiles`,
449
+ * forgejo `forgejo.runnerLabels` and `forgejo.actionsRoot`. All were
450
+ * documented for users and declared nowhere: the config schema is
451
+ * `.passthrough()`, so a typo was accepted and silently ignored, and the
452
+ * `ChantConfig` interface is closed, so the documented examples did not
453
+ * compile.
454
+ *
455
+ * Declaring the schema makes an unknown key inside the namespace an error
456
+ * rather than a default, and gives the lexicon a single source to derive the
457
+ * type it augments `ChantConfig` with — so the runtime rule and the
458
+ * compile-time one cannot disagree. Omit it and the namespace keeps today's
459
+ * passthrough.
460
+ */
461
+ readonly configSchema?: LexiconConfigSchema;
462
+
443
463
  /**
444
464
  * Local emulator(s) (#920), if this lexicon has any: Floci for aws, floci-az
445
465
  * for azure, floci-gcp for gcp, mudflaps and spritzer for fly. Drives
@@ -4,7 +4,7 @@ import { UNRESOLVED, type NormalizedDeepObservation } from "../deep-observation"
4
4
  import type { BaselineLexicon } from "./observation-baseline";
5
5
 
6
6
  const live = (
7
- resources: Record<string, { type: string; properties: Record<string, unknown> }>,
7
+ resources: Record<string, { type: string; properties: Record<string, unknown>; fieldOwners?: Record<string, string> }>,
8
8
  unobserved: NormalizedDeepObservation["unobserved"] = {},
9
9
  ): NormalizedDeepObservation => ({ resources, unobserved });
10
10
 
@@ -155,3 +155,50 @@ describe("diffDeep with an accepted baseline", () => {
155
155
  expect(result.drifted[0].changes.map((c) => c.path)).toEqual(["Extra"]);
156
156
  });
157
157
  });
158
+
159
+ // #1189 — `kind` says a path is undeclared or changed; `owner` says who did it.
160
+ // The two are independent: `hpa-controller` owning `spec.replicas` and somebody
161
+ // running `kubectl edit` are the same kind and opposite situations.
162
+ describe("diffDeep — owning field manager (#1189)", () => {
163
+ const declared = { web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 2 } } } };
164
+
165
+ test("names the manager on a drifted path", () => {
166
+ const result = diffDeep({
167
+ declared,
168
+ live: live({
169
+ web: {
170
+ type: "K8s::Apps::Deployment",
171
+ properties: { spec: { replicas: 5 } },
172
+ fieldOwners: { "spec.replicas": "hpa-controller" },
173
+ },
174
+ }),
175
+ });
176
+ expect(result.drifted[0].changes[0]).toMatchObject({
177
+ path: "spec.replicas",
178
+ kind: "changed",
179
+ owner: "hpa-controller",
180
+ });
181
+ });
182
+
183
+ test("is absent when the substrate records no per-field ownership", () => {
184
+ // Every substrate but k8s. The field must not appear at all rather than
185
+ // appear empty — a consumer branches on its presence.
186
+ const result = diffDeep({
187
+ declared,
188
+ live: live({ web: { type: "K8s::Apps::Deployment", properties: { spec: { replicas: 5 } } } }),
189
+ });
190
+ expect(result.drifted[0].changes[0]).not.toHaveProperty("owner");
191
+ });
192
+
193
+ test("is absent for a path with no live value — nobody owns a field that is not there", () => {
194
+ const result = diffDeep({
195
+ declared,
196
+ live: live({
197
+ web: { type: "K8s::Apps::Deployment", properties: {}, fieldOwners: { "spec.replicas": "someone" } },
198
+ }),
199
+ });
200
+ const change = result.drifted[0].changes.find((c) => c.path === "spec.replicas")!;
201
+ expect(change.kind).toBe("absent");
202
+ expect(change).not.toHaveProperty("owner");
203
+ });
204
+ });
@@ -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)}`,