@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,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
+ });
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { readEnvironmentSnapshots } from "./git";
18
+ import { unqualifiedKey } from "./identity";
18
19
  import type { LiveObservation } from "../graph-ir";
19
20
  import type { LifecycleSnapshot } from "./types";
20
21
 
@@ -72,16 +73,35 @@ export async function replaySnapshots(
72
73
  // account-level: the default security group three stacks each recorded is one
73
74
  // group, not three. Managed resources are stack-qualified below and cannot
74
75
  // collide, so only the unqualified ones need this.
76
+ //
77
+ // "Account-level" is the part that needed qualifying (#1416): a resource that
78
+ // records a region is regional, and two regions' copies are two resources
79
+ // however equal their ids look. `unqualifiedKey` keys those by region, so the
80
+ // set below still collapses one region's resource seen from two stacks and no
81
+ // longer collapses two regions'.
75
82
  const seenUnqualified = new Set<string>();
83
+ // One recorded stack is one region, so there is nothing to merge and nothing
84
+ // to disambiguate — its ids stay exactly what they were recorded as, which is
85
+ // also what `chant search --live` gives that project.
86
+ const merging = stored.size > 1;
76
87
  // A stack's snapshot could only exclude what THAT stack manages, so a stack
77
88
  // declaring no security groups reported the neighbouring stack's as ambient.
78
89
  // The union is only knowable here, with every snapshot in hand.
79
90
  const managedPhysicalIds = new Set<string>();
91
+ // Where a managed resource ends up, by physical id. A duplicate is dropped
92
+ // below rather than rendered twice, and anything that pointed at the
93
+ // duplicate has to be re-pointed at the survivor — dropping the node alone
94
+ // would take its edges with it, since `buildLiveGraphIr` discards an edge
95
+ // whose endpoints were not both observed.
96
+ const managedKeyOf = new Map<string, string>();
80
97
  for (const content of stored.values()) {
81
98
  const snap = JSON.parse(content) as LifecycleSnapshot;
82
- for (const meta of Object.values(snap.resources ?? {})) {
99
+ const snapStack = snap.stack;
100
+ const snapQualifies = snapStack !== undefined && scopedStacks.has(snapStack);
101
+ for (const [id, meta] of Object.entries(snap.resources ?? {})) {
83
102
  if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
84
103
  managedPhysicalIds.add(meta.physicalId);
104
+ managedKeyOf.set(meta.physicalId, snapQualifies ? `${snapStack}::${id}` : id);
85
105
  }
86
106
  }
87
107
  }
@@ -97,32 +117,60 @@ export async function replaySnapshots(
97
117
  // collide, and none of them join the declared canvas, which qualifies.
98
118
  const stack = snapshot.stack;
99
119
  const qualify = stack !== undefined && scopedStacks.has(stack);
100
- // Dependencies (#1273) are keyed by physical id and are account-level: the
101
- // default VPC's route table is one resource however many stacks route
102
- // through it. Qualifying those would split it per stack and break the
103
- // edges into it.
104
- const managed = (id: string, meta: { referencedBy?: string[]; ambient?: boolean }): string =>
105
- qualify && !meta.ambient && !(meta.referencedBy && meta.referencedBy.length > 0)
106
- ? `${stack}::${id}`
107
- : id;
120
+ // Dependencies (#1273) and ambient resources (#1278) are keyed by physical
121
+ // id and are NOT stack-qualified: the default VPC's route table is one
122
+ // resource however many stacks route through it, and qualifying it would
123
+ // split it per stack and break the edges into it.
124
+ const unqualifiedByNature = (meta: { referencedBy?: string[]; ambient?: boolean }): boolean =>
125
+ Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
126
+ // Region qualification (#1416) applies to exactly those, and never to a
127
+ // managed resource: an unscoped project's declared canvas joins on bare
128
+ // ids, so re-keying `web` to `us-east-1::web` would unjoin it from its own
129
+ // declaration.
130
+ const keyOf = (id: string, meta: { referencedBy?: string[]; ambient?: boolean; attributes?: Record<string, unknown> }): string => {
131
+ if (unqualifiedByNature(meta)) return merging ? unqualifiedKey(id, meta) : id;
132
+ return qualify ? `${stack}::${id}` : id;
133
+ };
108
134
  const resources: Record<string, (typeof snapshot.resources)[string]> = {};
135
+ // Duplicates dropped here, and where they went, so edges can follow.
136
+ const merged = new Map<string, string>();
109
137
  for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
110
- const key = managed(id, meta);
111
- if (key === id) {
112
- // Ambient means "nothing manages this". Another stack managing it makes
113
- // that false, and reporting it twice would inflate any count over it.
114
- if (meta.ambient && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) continue;
115
- // Unqualified: account-level, so first sighting wins and the rest are
116
- // the same resource seen again from another stack's snapshot.
117
- if (seenUnqualified.has(id)) continue;
118
- seenUnqualified.add(id);
138
+ const key = keyOf(id, meta);
139
+ if (!qualify || unqualifiedByNature(meta)) {
140
+ // Ambient means "nothing manages this", and being referenced means "the
141
+ // estate reaches this" neither stops it being managed. When a stack
142
+ // manages the same physical resource, this entry is that resource seen
143
+ // from outside, and rendering both inflates any count over it.
144
+ //
145
+ // The dependency half of that was missing, and it was not cosmetic: a
146
+ // subnet declared by its stack was also recorded as `subnet-9af06b90`
147
+ // with `referencedBy`, so an estate with 13 subnets replayed as 16 and
148
+ // "which subnets have no network interfaces" answered 11 where the
149
+ // truth was 8. The three extra were occupied subnets whose second copy
150
+ // had no interface pointing at it — a duplicate reads as empty, because
151
+ // the edges resolved to the other one.
152
+ const duplicate = Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
153
+ if (duplicate && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) {
154
+ const survivor = managedKeyOf.get(meta.physicalId);
155
+ if (survivor && survivor !== key) merged.set(id, survivor);
156
+ continue;
157
+ }
158
+ // Unqualified: one resource per key, so first sighting wins and the
159
+ // rest are the same resource seen again from another stack's snapshot.
160
+ // The key carries the region when there is one, so "the same resource"
161
+ // no longer spans regions.
162
+ if (seenUnqualified.has(key)) continue;
163
+ seenUnqualified.add(key);
119
164
  }
120
165
  resources[key] = meta;
121
166
  }
122
167
  const known = new Set(Object.keys(snapshot.resources ?? {}));
123
168
  const requalify = (id: string): string => {
169
+ // A dropped duplicate's edges belong to the resource that survived it.
170
+ const survivor = merged.get(id);
171
+ if (survivor) return survivor;
124
172
  const meta = (snapshot.resources ?? {})[id];
125
- return known.has(id) && meta ? managed(id, meta) : id;
173
+ return known.has(id) && meta ? keyOf(id, meta) : id;
126
174
  };
127
175
  const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
128
176
  observations.push({
@@ -97,6 +97,18 @@ export interface ResourceDigest {
97
97
  export interface BuildDigest {
98
98
  /** Per-resource digest keyed by logical name */
99
99
  resources: Record<string, ResourceDigest>;
100
+ /**
101
+ * chant #1442 — lexicon name → version that produced this build.
102
+ *
103
+ * Recorded once per lexicon rather than on every {@link ResourceDigest},
104
+ * which would repeat one string across every resource of a stack for no
105
+ * added information.
106
+ *
107
+ * Optional because a digest read back from an older snapshot will not have
108
+ * it. Absent and empty are different: absent means "recorded before this
109
+ * existed", empty means "recorded, and no plugins were loaded".
110
+ */
111
+ lexiconVersions?: Record<string, string>;
100
112
  /** Resource-level dependency graph */
101
113
  dependencies: Record<string, string[]>;
102
114
  /** Cross-lexicon output bridges from BuildManifest */
@@ -117,4 +129,18 @@ export interface DigestDiff {
117
129
  changed: string[];
118
130
  /** Resources where propsHash matches */
119
131
  unchanged: string[];
132
+ /**
133
+ * chant #1442 — lexicons whose VERSION moved between the two digests, even
134
+ * where every resource is unchanged.
135
+ *
136
+ * A build whose only difference is the lexicon that emitted it is a real
137
+ * difference: the lexicon is a generated artifact pinned to an upstream
138
+ * spec, so a bump can change output with no source change. Reported
139
+ * separately from `changed` because no resource's declaration moved — the
140
+ * interpreter did.
141
+ *
142
+ * Empty when neither digest recorded versions, so a comparison against a
143
+ * pre-#1442 snapshot reports nothing rather than inventing a change.
144
+ */
145
+ lexiconVersionChanges: Array<{ lexicon: string; previous?: string; current?: string }>;
120
146
  }
@@ -155,6 +155,11 @@ describe("pruneByOwnership — the shared three-question rule", () => {
155
155
  chantOwned: new Set(["metadata.labels.tier"]),
156
156
  foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
157
157
  foreignContested: new Set(["spec.replicas"]),
158
+ owners: new Map([
159
+ ["metadata.labels.tier", "chant"],
160
+ ["spec.replicas", "hpa-controller"],
161
+ ["metadata.annotations.noise", "kube-controller-manager"],
162
+ ]),
158
163
  };
159
164
 
160
165
  test("never prunes the declared side", () => {
@@ -177,3 +182,48 @@ describe("pruneByOwnership — the shared three-question rule", () => {
177
182
  expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
178
183
  });
179
184
  });
185
+
186
+ // #1189 — the three sets answer "which category owns this path". A reader needs
187
+ // the other question: `hpa-controller` and `kubectl-client-side-apply` are the
188
+ // same category and mean opposite things to an operator.
189
+ describe("buildOwnershipSets — owning manager per path (#1189)", () => {
190
+ const entries = [
191
+ { manager: "chant", operation: "Apply", fieldsV1: { "f:metadata": { "f:labels": { "f:tier": {} } } } },
192
+ { manager: "hpa-controller", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } },
193
+ {
194
+ manager: "kubectl-client-side-apply",
195
+ operation: "Update",
196
+ fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": {} } } } },
197
+ },
198
+ ];
199
+
200
+ test("records which manager owns each path", () => {
201
+ const sets = buildOwnershipSets(
202
+ entries,
203
+ { metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
204
+ { metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
205
+ (m) => m === "chant",
206
+ );
207
+ expect(sets.owners.get("metadata.labels.tier")).toBe("chant");
208
+ expect(sets.owners.get("spec.replicas")).toBe("hpa-controller");
209
+ expect(sets.owners.get("spec.template.spec.containers")).toBe("kubectl-client-side-apply");
210
+ });
211
+
212
+ test("the categories are unchanged by recording owners", () => {
213
+ const sets = buildOwnershipSets(
214
+ entries,
215
+ { metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
216
+ { metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
217
+ (m) => m === "chant",
218
+ );
219
+ expect(sets.chantOwned.has("metadata.labels.tier")).toBe(true);
220
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
221
+ // Declared AND foreign-owned — contested, so still diffable.
222
+ expect(sets.foreignContested.has("spec.replicas")).toBe(true);
223
+ });
224
+
225
+ test("is empty when the object carries no managedFields at all", () => {
226
+ const sets = buildOwnershipSets([], { spec: {} }, { spec: {} }, (m) => m === "chant");
227
+ expect(sets.owners.size).toBe(0);
228
+ });
229
+ });
@@ -144,6 +144,17 @@ export interface OwnershipSets {
144
144
  foreignOwned: ReadonlySet<string>;
145
145
  /** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
146
146
  foreignContested: ReadonlySet<string>;
147
+ /**
148
+ * Path → the name of the manager that owns it (#1189).
149
+ *
150
+ * The three sets above answer *which category* owns a path, which is all the
151
+ * prune rule needs. A reader needs the other question — "owned by
152
+ * `kubectl-client-side-apply`" and "owned by `hpa-controller`" are the same
153
+ * category and mean very different things to an operator. Last writer wins
154
+ * where several managers touch one path, matching what the API server itself
155
+ * reports.
156
+ */
157
+ owners: ReadonlyMap<string, string>;
147
158
  }
148
159
 
149
160
  function sameJson(a: unknown, b: unknown): boolean {
@@ -188,6 +199,9 @@ function walkOwnership(
188
199
  path: string,
189
200
  owned: Set<string>,
190
201
  contested: Set<string>,
202
+ /** Records path → manager as it walks (#1189); omitted by callers that only need the sets. */
203
+ owners?: Map<string, string>,
204
+ manager?: string,
191
205
  ): void {
192
206
  if (fieldsNode === null || typeof fieldsNode !== "object" || Array.isArray(fieldsNode)) return;
193
207
 
@@ -196,6 +210,7 @@ function walkOwnership(
196
210
  if (path !== "") {
197
211
  owned.add(path);
198
212
  if (declaredNode !== undefined) contested.add(path);
213
+ if (owners && manager) owners.set(path, manager);
199
214
  }
200
215
  continue;
201
216
  }
@@ -208,7 +223,8 @@ function walkOwnership(
208
223
  const childPath = joinField(path, name);
209
224
  owned.add(childPath);
210
225
  if (childDeclared !== undefined) contested.add(childPath);
211
- walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
226
+ if (owners && manager) owners.set(childPath, manager);
227
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
212
228
  continue;
213
229
  }
214
230
 
@@ -220,7 +236,8 @@ function walkOwnership(
220
236
  const childPath = joinIndex(path, idx);
221
237
  owned.add(childPath);
222
238
  if (childDeclared !== undefined) contested.add(childPath);
223
- walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
239
+ if (owners && manager) owners.set(childPath, manager);
240
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
224
241
  continue;
225
242
  }
226
243
 
@@ -255,7 +272,8 @@ function walkOwnership(
255
272
  const childPath = joinIndex(path, liveIdx);
256
273
  owned.add(childPath);
257
274
  if (childDeclared !== undefined) contested.add(childPath);
258
- walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
275
+ if (owners && manager) owners.set(childPath, manager);
276
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
259
277
  continue;
260
278
  }
261
279
  // An unrecognized prefix (a future fieldsV1 encoding) — skip.
@@ -286,6 +304,7 @@ export function buildOwnershipSets(
286
304
  const chantOwned = new Set<string>();
287
305
  const foreignOwned = new Set<string>();
288
306
  const foreignContested = new Set<string>();
307
+ const owners = new Map<string, string>();
289
308
 
290
309
  for (const entry of entries) {
291
310
  if (typeof entry.manager !== "string" || entry.manager.length === 0) continue;
@@ -294,13 +313,13 @@ export function buildOwnershipSets(
294
313
  if (isChantManager(entry.manager)) {
295
314
  // Chant-owned paths are always diffable, regardless of who else is
296
315
  // involved — "contested" only matters for a *foreign* owner.
297
- walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set());
316
+ walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set(), owners, entry.manager);
298
317
  } else {
299
- walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested);
318
+ walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested, owners, entry.manager);
300
319
  }
301
320
  }
302
321
 
303
- return { chantOwned, foreignOwned, foreignContested };
322
+ return { chantOwned, foreignOwned, foreignContested, owners };
304
323
  }
305
324
 
306
325
  /**