@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
@@ -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
  }
@@ -1,11 +1,29 @@
1
1
  import { describe, test, expect } from "vitest";
2
- import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
2
+ import { applyLiveEndpoint, zeroResourcesWarning, endpointEnvVarsFor } from "./live-endpoint";
3
+ import type { EmulatorCapability } from "./op/emulator-lifecycle";
4
+
5
+ /** A lexicon standing in for one whose emulator names an endpoint var. */
6
+ function lexicon(name: string, ...vars: string[]): { name: string; emulator?: EmulatorCapability } {
7
+ if (vars.length === 0) return { name };
8
+ return {
9
+ name,
10
+ emulator: {
11
+ spec: { name: `chant-${name}`, image: `${name}:0`, containerPort: 1, healthPath: "/h" },
12
+ // Credentials come back alongside the endpoint, as they do for real:
13
+ // only the vars carrying the endpoint itself should be injected.
14
+ env: (endpoint) => ({
15
+ ...Object.fromEntries(vars.map((v) => [v, endpoint])),
16
+ [`${name.toUpperCase()}_ACCESS_KEY_ID`]: "test",
17
+ }),
18
+ },
19
+ };
20
+ }
3
21
  import type { EnvironmentDeclaration } from "./config";
4
22
 
5
23
  describe("applyLiveEndpoint (#1166)", () => {
6
24
  test("no-op — and no notice — when the environment declares no endpoint at all", () => {
7
25
  const env: NodeJS.ProcessEnv = {};
8
- const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
26
+ const result = applyLiveEndpoint(["floci", "prod"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
9
27
  expect(result.notice).toBeUndefined();
10
28
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
11
29
  result.restore(); // always safe, even as a no-op
@@ -15,7 +33,7 @@ describe("applyLiveEndpoint (#1166)", () => {
15
33
  test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
16
34
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
17
35
  const env: NodeJS.ProcessEnv = {};
18
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
36
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
19
37
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
20
38
  expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
21
39
  expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
@@ -24,7 +42,7 @@ describe("applyLiveEndpoint (#1166)", () => {
24
42
  test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
25
43
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
26
44
  const env: NodeJS.ProcessEnv = {};
27
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
45
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
28
46
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
29
47
  result.restore();
30
48
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
@@ -33,7 +51,7 @@ describe("applyLiveEndpoint (#1166)", () => {
33
51
  test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
34
52
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
35
53
  const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
36
- const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
54
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
37
55
  expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
38
56
  expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
39
57
  result.restore();
@@ -42,7 +60,7 @@ describe("applyLiveEndpoint (#1166)", () => {
42
60
 
43
61
  test("a bare-string environment entry has no endpoint to apply", () => {
44
62
  const env: NodeJS.ProcessEnv = {};
45
- const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
63
+ const result = applyLiveEndpoint(["floci"], "floci", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
46
64
  expect(result.notice).toBeUndefined();
47
65
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
48
66
  });
@@ -51,7 +69,7 @@ describe("applyLiveEndpoint (#1166)", () => {
51
69
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
52
70
  const env: NodeJS.ProcessEnv = {};
53
71
  // k8s has no ambient-var knob (config-resolved instead) — nothing to set.
54
- const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
72
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("k8s")], env);
55
73
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
56
74
  expect(result.notice).toBeUndefined();
57
75
  });
@@ -59,7 +77,7 @@ describe("applyLiveEndpoint (#1166)", () => {
59
77
  test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
60
78
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
61
79
  const env: NodeJS.ProcessEnv = {};
62
- const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
80
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
63
81
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
64
82
  expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
65
83
  result.restore();
@@ -70,21 +88,42 @@ describe("applyLiveEndpoint (#1166)", () => {
70
88
  test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
71
89
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
72
90
  const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
73
- const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
91
+ const result = applyLiveEndpoint(environments, "floci", [lexicon("aws", "AWS_ENDPOINT_URL"), lexicon("fly", "FLY_FLAPS_BASE_URL")], env);
74
92
  expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
75
93
  expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
76
94
  expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
77
95
  expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
78
96
  });
79
97
 
80
- test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
81
- expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
98
+ test("the endpoint vars come from the lexicon's own emulator, not a map in core (#1345)", () => {
99
+ expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).toEqual(["AWS_ENDPOINT_URL"]);
100
+ });
101
+
102
+ test("credentials the emulator also needs are not treated as endpoint vars", () => {
103
+ // `env()` returns keys and secrets beside the endpoint; injecting those
104
+ // into the ambient shell for a `--live` read is not this function's job.
105
+ expect(endpointEnvVarsFor(lexicon("aws", "AWS_ENDPOINT_URL"))).not.toContain("AWS_ACCESS_KEY_ID");
106
+ });
107
+
108
+ test("a lexicon with no emulator contributes no var — it resolves its target from config", () => {
109
+ expect(endpointEnvVarsFor(lexicon("k8s"))).toEqual([]);
110
+ });
111
+
112
+ test("a lexicon declaring two emulators contributes both vars", () => {
113
+ const fly = {
114
+ name: "fly",
115
+ emulator: [
116
+ lexicon("mudflaps", "FLY_FLAPS_BASE_URL").emulator!,
117
+ lexicon("spritzer", "SPRITES_BASE_URL").emulator!,
118
+ ],
119
+ };
120
+ expect(endpointEnvVarsFor(fly).sort()).toEqual(["FLY_FLAPS_BASE_URL", "SPRITES_BASE_URL"]);
82
121
  });
83
122
 
84
123
  test("a name that isn't declared at all has no endpoint to apply", () => {
85
124
  const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
86
125
  const env: NodeJS.ProcessEnv = {};
87
- const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
126
+ const result = applyLiveEndpoint(environments, "prod", [lexicon("aws", "AWS_ENDPOINT_URL")], env);
88
127
  expect(result.notice).toBeUndefined();
89
128
  expect(env.AWS_ENDPOINT_URL).toBeUndefined();
90
129
  });
@@ -24,38 +24,36 @@
24
24
  * Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
25
25
  * all, since that's the specific footgun — a lexicon whose environment
26
26
  * binding is resolved from `chant.config` itself (not an ambient var) has
27
- * nothing to inject here:
27
+ * nothing to inject here.
28
28
  *
29
- * - **aws** — `AWS_ENDPOINT_URL`, read directly by
30
- * `lexicons/aws/src/components/cloud-executor.ts` / `plugin.ts` before
31
- * every `aws …` shell-out (`applyAwsEndpoint`/`applyAwsEndpointArgv`).
32
- * - **fly** — `FLY_FLAPS_BASE_URL`, read by `resolveEndpoint()` in
33
- * `lexicons/fly/src/op/activities/fly-apply.ts`, the same seam
34
- * `describeResources` (`../describe-resources.ts`) calls through.
35
- * - **gcp**, **k8s** resolve their live target from `chant.config` itself
36
- * (`k8s.profiles.<env>.context` via `resolveClusterTarget`,
37
- * `packages/core/src/kubectl-context.ts`), not an ambient var. Nothing to
38
- * inject: the config *is* the binding already.
39
- * - **azure** — resolves via the `az` CLI's own logged-in
40
- * subscription/session context; no ambient endpoint var exists to miss.
41
- * - **temporal** — resolves its connection from `temporal.profiles.<env>`
42
- * (`resolveProfile`, `lexicons/temporal/src/describe-resources.ts`), the
43
- * same "config is the binding" shape as k8s/gcp.
29
+ * Which var that is per lexicon is no longer written down twice (#1345). It is
30
+ * derived from the lexicon's own {@link EmulatorCapability.env}, which already
31
+ * has to name the var that points tooling at a booted emulator. The map this
32
+ * replaced listed aws and fly, and its prose asserted azure had no ambient
33
+ * endpoint var — while `lexicons/azure/src/describe-resources.ts` and
34
+ * `deep-observe.ts` both read `AZURE_ENDPOINT_URL` on every call, so a
35
+ * `--live --env floci` read against azure silently went to real Azure.
44
36
  */
45
37
 
46
38
  import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
39
+ import { emulatorsOf, endpointEnvVars, type EmulatorDeclaration } from "./op/emulator-lifecycle";
47
40
 
48
41
  /**
49
- * Per-lexicon ambient env var a `--live` read honors for its endpoint. Only
50
- * lexicons with a genuine ambient-var footgun are listed — see the module doc
51
- * for the full audit (gcp/k8s/azure/temporal resolve their target from
52
- * `chant.config` instead, so they have nothing to inject).
42
+ * The ambient endpoint vars a lexicon honors, from its emulator capability.
43
+ *
44
+ * A lexicon with no emulator contributes nothing, which is the same answer the
45
+ * hand-maintained map gave for k8s, gcp and temporal — they resolve their live
46
+ * target from `chant.config` itself, so there is nothing to inject.
53
47
  */
54
- export const LEXICON_ENDPOINT_ENV_VAR: Record<string, string> = {
55
- aws: "AWS_ENDPOINT_URL",
56
- fly: "FLY_FLAPS_BASE_URL",
57
- };
48
+ export function endpointEnvVarsFor(lexicon: EndpointLexicon): string[] {
49
+ return emulatorsOf(lexicon.emulator).flatMap((cap) => endpointEnvVars(cap));
50
+ }
58
51
 
52
+ /** What {@link applyLiveEndpoint} needs of a plugin: its name and its emulators. */
53
+ export interface EndpointLexicon {
54
+ name: string;
55
+ emulator?: EmulatorDeclaration;
56
+ }
59
57
  /** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
60
58
  export interface AppliedEndpoint {
61
59
  /**
@@ -84,7 +82,7 @@ export interface AppliedEndpoint {
84
82
  export function applyLiveEndpoint(
85
83
  environments: EnvironmentDeclaration[] | undefined,
86
84
  environment: string,
87
- lexicons: readonly string[],
85
+ lexicons: readonly EndpointLexicon[],
88
86
  env: NodeJS.ProcessEnv = process.env,
89
87
  ): AppliedEndpoint {
90
88
  const endpoint = environmentEndpoint(environments, environment);
@@ -94,15 +92,16 @@ export function applyLiveEndpoint(
94
92
  const overridden: string[] = [];
95
93
  const seen = new Set<string>(); // a var shared by two lexicons is only reported once
96
94
  for (const lexicon of lexicons) {
97
- const varName = LEXICON_ENDPOINT_ENV_VAR[lexicon];
98
- if (!varName || seen.has(varName)) continue;
99
- seen.add(varName);
100
- if (env[varName]) {
101
- overridden.push(varName);
102
- continue;
95
+ for (const varName of endpointEnvVarsFor(lexicon)) {
96
+ if (seen.has(varName)) continue;
97
+ seen.add(varName);
98
+ if (env[varName]) {
99
+ overridden.push(varName);
100
+ continue;
101
+ }
102
+ env[varName] = endpoint;
103
+ applied.push(varName);
103
104
  }
104
- env[varName] = endpoint;
105
- applied.push(varName);
106
105
  }
107
106
 
108
107
  const notices: string[] = [];
@@ -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
  /**