@intentius/chant 0.44.13 → 0.45.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 (175) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +1 -1
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/yaml.d.ts +0 -8
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__snapshots__/okf.test.ts.snap +20 -0
  79. package/src/audit/discover.test.ts +55 -1
  80. package/src/audit/discover.ts +70 -2
  81. package/src/audit/edge-init-safety.test.ts +149 -18
  82. package/src/audit/report-model.ts +12 -1
  83. package/src/audit/rules-doc.ts +1 -0
  84. package/src/build-params.test.ts +52 -0
  85. package/src/cli/build-params-cli.test.ts +25 -8
  86. package/src/cli/build-params-cli.ts +37 -23
  87. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  90. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  91. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  94. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  95. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  96. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  97. package/src/cli/commands/audit.test.ts +83 -1
  98. package/src/cli/commands/audit.ts +121 -28
  99. package/src/cli/commands/build.test.ts +144 -5
  100. package/src/cli/commands/build.ts +66 -14
  101. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  102. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  103. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  104. package/src/cli/commands/check-lexicon.test.ts +1 -1
  105. package/src/cli/commands/check-lexicon.ts +19 -4
  106. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  107. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  108. package/src/cli/commands/init-lexicon.test.ts +42 -1
  109. package/src/cli/commands/init-lexicon.ts +3 -1
  110. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  111. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  112. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  113. package/src/cli/commands/onboard.test.ts +319 -225
  114. package/src/cli/commands/onboard.ts +144 -58
  115. package/src/cli/handlers/build.test.ts +3 -3
  116. package/src/cli/handlers/build.ts +2 -0
  117. package/src/cli/handlers/components.ts +2 -2
  118. package/src/cli/handlers/graph.test.ts +44 -0
  119. package/src/cli/handlers/graph.ts +22 -2
  120. package/src/cli/handlers/lifecycle.test.ts +117 -0
  121. package/src/cli/handlers/lifecycle.ts +16 -0
  122. package/src/cli/handlers/run.test.ts +5 -5
  123. package/src/cli/handlers/run.ts +1 -0
  124. package/src/cli/handlers/search-live.test.ts +217 -0
  125. package/src/cli/handlers/search.test.ts +41 -1
  126. package/src/cli/handlers/search.ts +55 -6
  127. package/src/cli/main.ts +5 -3
  128. package/src/cli/security-boundary.test.ts +2 -1
  129. package/src/codegen/docs-pages.test.ts +194 -0
  130. package/src/codegen/docs-pages.ts +138 -0
  131. package/src/codegen/docs-sections.ts +0 -8
  132. package/src/codegen/docs-sidebar.ts +50 -37
  133. package/src/codegen/docs-types.ts +32 -4
  134. package/src/codegen/docs.ts +78 -40
  135. package/src/codegen/lexicon-regen.ts +3 -1
  136. package/src/codegen/rolling-upgrade.test.ts +1 -1
  137. package/src/codegen/rolling-upgrade.ts +1 -1
  138. package/src/codegen/surface-snapshot.test.ts +139 -0
  139. package/src/codegen/surface-snapshot.ts +297 -122
  140. package/src/codegen/validate.test.ts +16 -3
  141. package/src/codegen/validate.ts +17 -7
  142. package/src/components/auto-release.ts +1 -1
  143. package/src/components/capability-plugin.ts +23 -2
  144. package/src/components/driver.test.ts +45 -0
  145. package/src/components/driver.ts +64 -27
  146. package/src/components/index.ts +2 -0
  147. package/src/components/starter-plugin.ts +5 -2
  148. package/src/config.test.ts +56 -1
  149. package/src/config.ts +152 -8
  150. package/src/discovery/fold-import.test.ts +427 -2
  151. package/src/discovery/fold-import.ts +163 -7
  152. package/src/discovery/sandbox/driver.test.ts +11 -0
  153. package/src/fold/fold.test.ts +41 -0
  154. package/src/fold/fold.ts +372 -2
  155. package/src/graph-ir-live.test.ts +42 -0
  156. package/src/graph-ir.ts +42 -2
  157. package/src/graph-ops.test.ts +69 -0
  158. package/src/graph-ops.ts +42 -0
  159. package/src/lifecycle/change-set.test.ts +41 -0
  160. package/src/lifecycle/change-set.ts +30 -2
  161. package/src/lifecycle/deep-observe.ts +4 -0
  162. package/src/lifecycle/observe.ts +29 -2
  163. package/src/lifecycle/replay.ts +3 -0
  164. package/src/lifecycle/snapshot.ts +3 -0
  165. package/src/lifecycle/types.ts +7 -0
  166. package/src/lint/policy.ts +14 -3
  167. package/src/managed-fields.test.ts +15 -42
  168. package/src/managed-fields.ts +43 -26
  169. package/src/observation.test.ts +24 -8
  170. package/src/observation.ts +48 -4
  171. package/src/op/builders-exports.test.ts +60 -0
  172. package/src/op/index.ts +1 -1
  173. package/src/ownership.test.ts +63 -1
  174. package/src/yaml.test.ts +15 -0
  175. package/src/yaml.ts +19 -2
@@ -0,0 +1,42 @@
1
+ import { basename, resolve } from "node:path";
2
+ import type { Declarable } from "./declarable";
3
+ import { discoverOps } from "./op/discover";
4
+ import { Op } from "./op/builders";
5
+ import { setProvenance } from "./provenance";
6
+
7
+ /**
8
+ * #1675 — the graph IR is built from `discover(sourceDir)`, but the Op
9
+ * convention keeps `*.op.ts` files OUTSIDE `sourceDir` (`ops/` or the project
10
+ * root beside `sourceDir: "src"`), and `chant op` / `chant graph` find them via
11
+ * {@link discoverOps}, which scans from the git root. Join the two scopes: every
12
+ * Op `discoverOps` finds that discovery did not already load becomes a
13
+ * `Temporal::Op` entity in the map, keyed the way discovery keys a default
14
+ * export (the file's basename sans `.op.ts`), so the IR carries the declared
15
+ * DAG whatever the layout. Ops discovery already loaded (same file) are left
16
+ * alone. Returns the op-discovery errors for the caller to surface.
17
+ */
18
+ export async function mergeProjectOps(
19
+ entities: Map<string, Declarable>,
20
+ sourceFiles: readonly string[],
21
+ projectPath: string,
22
+ ): Promise<{ added: string[]; errors: string[] }> {
23
+ const added: string[] = [];
24
+ let found: Awaited<ReturnType<typeof discoverOps>>;
25
+ try {
26
+ found = await discoverOps({ cwd: projectPath });
27
+ } catch (err) {
28
+ // Not a git checkout — nothing beyond sourceDir to join.
29
+ return { added, errors: [err instanceof Error ? err.message : String(err)] };
30
+ }
31
+ const loaded = new Set(sourceFiles.map((f) => resolve(f)));
32
+ for (const { config, filePath } of found.ops.values()) {
33
+ if (loaded.has(resolve(filePath))) continue;
34
+ const key = basename(filePath).replace(/\.ts$/, "").replace(/\.op$/, "");
35
+ if (entities.has(key)) continue;
36
+ const entity = Op(config) as unknown as Declarable;
37
+ setProvenance(entity, { sourceFile: resolve(filePath) });
38
+ entities.set(key, entity);
39
+ added.push(key);
40
+ }
41
+ return { added, errors: found.errors };
42
+ }
@@ -346,6 +346,47 @@ describe("buildChangeSet: not-observed is not absent (#1089)", () => {
346
346
  expect(e.queried).toBe("from-entry");
347
347
  });
348
348
 
349
+ // #1674 — lexicon attribution and the provider's physical id per entry.
350
+ test("stamps every entry with the lexicon it was built for, when given one", () => {
351
+ const cs = buildChangeSet("prod", {
352
+ declared: new Set(["web", "api"]),
353
+ observedNow: { web: meta(), "sg-0abc123": meta({ ownership: "foreign" }) },
354
+ observedThen: undefined,
355
+ }, { lexicon: "aws" });
356
+ expect(cs.entries.map((e) => e.lexicon)).toEqual(["aws", "aws", "aws"]);
357
+ const without = buildChangeSet("prod", { declared: new Set(["web"]), observedNow: {}, observedThen: undefined });
358
+ expect(without.entries[0]).not.toHaveProperty("lexicon");
359
+ });
360
+
361
+ test("physicalId comes from the live observation — name stays the lexicon's key", () => {
362
+ const cs = buildChangeSet("prod", {
363
+ declared: new Set(["web"]),
364
+ observedNow: {
365
+ web: meta({ physicalId: "arn:aws:elasticloadbalancing:us-east-1:123:loadbalancer/app/web/abc" }),
366
+ "sg-0abc123": meta({ physicalId: "sg-0abc123", ownership: "foreign" }),
367
+ "pod-xyz": meta({ physicalId: "default/pod-xyz", ownerChain: { root: "declared", entity: "web" } }),
368
+ owned: meta({ physicalId: "i-0deadbeef", ownership: "owned" }),
369
+ },
370
+ observedThen: undefined,
371
+ });
372
+ const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e]));
373
+ expect(byName.web.physicalId).toBe("arn:aws:elasticloadbalancing:us-east-1:123:loadbalancer/app/web/abc");
374
+ expect(byName["sg-0abc123"]).toMatchObject({ action: "adopt", physicalId: "sg-0abc123", name: "sg-0abc123" });
375
+ expect(byName["pod-xyz"]).toMatchObject({ action: "runtime", physicalId: "default/pod-xyz" });
376
+ expect(byName.owned).toMatchObject({ action: "delete", physicalId: "i-0deadbeef" });
377
+ });
378
+
379
+ test("physicalId is absent when no side reported one, and falls back to the snapshot's when the resource is gone", () => {
380
+ const cs = buildChangeSet("prod", {
381
+ declared: new Set(["web"]),
382
+ observedNow: {},
383
+ observedThen: { gone: meta({ physicalId: "i-gone" }) },
384
+ });
385
+ const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e]));
386
+ expect(byName.web).not.toHaveProperty("physicalId");
387
+ expect(byName.gone).toMatchObject({ action: "noop", physicalId: "i-gone" });
388
+ });
389
+
349
390
  test("a returned resource wins over an unobserved claim for the same name", () => {
350
391
  const cs = buildChangeSet("prod", {
351
392
  declared: new Set(["queue"]),
@@ -45,10 +45,27 @@ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime"
45
45
  export type Ownership = "owned" | "foreign" | "unknown";
46
46
 
47
47
  export interface ChangeSetEntry {
48
- /** chant entity name. */
48
+ /**
49
+ * chant entity name for a declared entity. For an undeclared live resource
50
+ * (`adopt`, `delete`, `runtime`) this is the lexicon's live key — not an
51
+ * IR-joinable entity name; read `physicalId` for the provider id (#1674).
52
+ */
49
53
  name: string;
50
54
  /** Resource type, when known from either side. */
51
55
  type?: string;
56
+ /**
57
+ * The lexicon whose observation produced this entry (#1674). Set when the
58
+ * change set is built for one lexicon; `lifecycle plan` merges every
59
+ * lexicon's change set into one `entries[]`, and this is what keeps the
60
+ * attribution through the merge.
61
+ */
62
+ lexicon?: string;
63
+ /**
64
+ * Provider-assigned physical id (ARN, resource id, pod name) from the live
65
+ * observation's `ResourceMetadata.physicalId`, falling back to the snapshot's
66
+ * when the resource is gone (#1674). Absent when neither side reported one.
67
+ */
68
+ physicalId?: string;
52
69
  action: ChangeAction;
53
70
  /** The three-way evidence the classification was derived from. */
54
71
  evidence: {
@@ -101,7 +118,12 @@ export interface ChangeSet {
101
118
  * classifies as `unobserved` and nothing else: no `create` is ever synthesized
102
119
  * from a read that did not happen.
103
120
  */
104
- export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
121
+ export interface ChangeSetOptions {
122
+ /** Stamp every entry with the lexicon it was observed by (#1674). */
123
+ lexicon?: string;
124
+ }
125
+
126
+ export function buildChangeSet(env: string, input: DiffLiveInput, options?: ChangeSetOptions): ChangeSet {
105
127
  const diff = diffLive(input);
106
128
  const { declared, observedNow } = input;
107
129
  const observedThen = input.observedThen ?? {};
@@ -184,9 +206,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
184
206
  // classification above never reads it.
185
207
  const queried = unobservedEntry?.queried ?? input.queried?.[name];
186
208
 
209
+ // The provider's id for the row (#1674). Live first; the snapshot's only
210
+ // when the resource is no longer live (a snapshot-only noop).
211
+ const physicalId = observedNow[name]?.physicalId ?? observedThen[name]?.physicalId;
212
+
187
213
  entries.push({
188
214
  name,
189
215
  type,
216
+ ...(options?.lexicon ? { lexicon: options.lexicon } : {}),
217
+ ...(physicalId ? { physicalId } : {}),
190
218
  action,
191
219
  evidence,
192
220
  deltas,
@@ -143,6 +143,10 @@ export function diffDeepObservation(
143
143
  normalizedLive[name] = {
144
144
  type: liveEntity.type || entity.entityType,
145
145
  ...(liveEntity.physicalId ? { physicalId: liveEntity.physicalId } : {}),
146
+ // Per-path owners (#1189) ride through unchanged; the diff looks them
147
+ // up by the flattened path, so a keyed list element (`[#name]`) has
148
+ // no owner today — its raw index path is what the reader recorded.
149
+ ...(liveEntity.fieldOwners ? { fieldOwners: liveEntity.fieldOwners } : {}),
146
150
  properties: normalizeDeepProperties(liveRaw, {
147
151
  entityType: liveEntity.type || entity.entityType,
148
152
  side: "live",
@@ -31,6 +31,14 @@ export interface ObserveResult {
31
31
  observations: LiveObservation[];
32
32
  warnings: string[];
33
33
  errors: string[];
34
+ /**
35
+ * Run-level notices from the lexicons (#1265), each said once however many
36
+ * stacks or lexicons reported it — "ownership filter unavailable on this
37
+ * read path" is the canonical one. Kept apart from `warnings`, which are
38
+ * per-entity, so a caller can print them where a note belongs: after the
39
+ * answer, not ahead of it.
40
+ */
41
+ notes: string[];
34
42
  }
35
43
 
36
44
  /**
@@ -43,7 +51,14 @@ export interface ObserveResult {
43
51
  function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
44
52
  const q = <T>(m: Record<string, T>): Record<string, T> =>
45
53
  Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
46
- return { resources: q(obs.resources), unobserved: q(obs.unobserved), queried: q(obs.queried) };
54
+ return {
55
+ resources: q(obs.resources),
56
+ unobserved: q(obs.unobserved),
57
+ queried: q(obs.queried),
58
+ notes: obs.notes,
59
+ // Exports are already keyed by stack (#1279); nothing to qualify.
60
+ ...(obs.stackExports ? { stackExports: obs.stackExports } : {}),
61
+ };
47
62
  }
48
63
 
49
64
  /**
@@ -110,6 +125,7 @@ export async function observeResources(
110
125
  const observations: LiveObservation[] = [];
111
126
  const warnings: string[] = [];
112
127
  const errors: string[] = [];
128
+ const notes: string[] = [];
113
129
 
114
130
  for (const plugin of plugins) {
115
131
  if (!plugin.describeResources) continue;
@@ -200,6 +216,13 @@ export async function observeResources(
200
216
  }),
201
217
  );
202
218
  }
219
+ // Run-level notices (#1265): one line per distinct note per run,
220
+ // whatever the stack count. `mergeObservations` already folded the
221
+ // per-stack copies; this folds across lexicons.
222
+ for (const note of observed.notes) {
223
+ const line = `[${plugin.name}] ${note}`;
224
+ if (!notes.includes(line)) notes.push(line);
225
+ }
203
226
  // What the estate depends on but does not declare (#1273). Read after the
204
227
  // managed resources, because the declared observation is the closure's
205
228
  // roots — there is nothing to reference out from until it exists.
@@ -260,6 +283,7 @@ export async function observeResources(
260
283
  resources: {},
261
284
  unobserved: unobservedAll(entityNames, "read-failed", message, entities),
262
285
  queried: {},
286
+ notes: [],
263
287
  },
264
288
  environment,
265
289
  entityNames.length,
@@ -267,7 +291,7 @@ export async function observeResources(
267
291
  }
268
292
  }
269
293
 
270
- return { observations, warnings, errors };
294
+ return { observations, warnings, errors, notes };
271
295
  }
272
296
 
273
297
  /**
@@ -451,5 +475,8 @@ function pushObservation(
451
475
  : observed.resources,
452
476
  ...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
453
477
  ...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
478
+ ...(observed.stackExports && Object.keys(observed.stackExports).length > 0
479
+ ? { stackExports: observed.stackExports }
480
+ : {}),
454
481
  });
455
482
  }
@@ -177,6 +177,9 @@ export async function replaySnapshots(
177
177
  lexicon,
178
178
  resources,
179
179
  ...(edges.length > 0 ? { edges } : {}),
180
+ ...(snapshot.stackExports && Object.keys(snapshot.stackExports).length > 0
181
+ ? { stackExports: snapshot.stackExports }
182
+ : {}),
180
183
  });
181
184
  commit ||= snapshot.commit ?? "";
182
185
  // Report the OLDEST timestamp across stacks: a caller asking how stale this
@@ -140,6 +140,7 @@ export async function takeSnapshot(
140
140
  let resources: Record<string, ResourceMetadata> = {};
141
141
  let artifacts: Record<string, ArtifactMetadata> = {};
142
142
  let unobserved: Record<string, UnobservedEntity> = {};
143
+ let stackExports: Record<string, Record<string, unknown>> | undefined;
143
144
 
144
145
  try {
145
146
  if (plugin.describeResources) {
@@ -163,6 +164,7 @@ export async function takeSnapshot(
163
164
  // entity nobody could read must not be recorded as "was not there",
164
165
  // because the next diff would then read it back as absent.
165
166
  unobserved = observed.unobserved;
167
+ stackExports = observed.stackExports;
166
168
  for (const [name, entry] of Object.entries(unobserved)) {
167
169
  warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
168
170
  }
@@ -258,6 +260,7 @@ export async function takeSnapshot(
258
260
  timestamp,
259
261
  resources: withDependencies,
260
262
  ...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
263
+ ...(stackExports && Object.keys(stackExports).length > 0 ? { stackExports } : {}),
261
264
  ...(Object.keys(unobserved).length > 0 && { unobserved }),
262
265
  ...(Object.keys(artifacts).length > 0 && { artifacts }),
263
266
  // Only written when deep. An absent field means identity, which is what
@@ -75,6 +75,13 @@ export interface LifecycleSnapshot {
75
75
  * relationships recorded" rather than "no relationships existed".
76
76
  */
77
77
  edges?: IREdge[];
78
+ /**
79
+ * What the deployed stack publishes (#1279), keyed by stack name — a
80
+ * CloudFormation stack's outputs. Recorded once here rather than on every
81
+ * resource's `attributes`, which is where they used to be copied. Absent on
82
+ * every snapshot written before this, and on a lexicon with no such notion.
83
+ */
84
+ stackExports?: Record<string, Record<string, unknown>>;
78
85
  /** Build digest at snapshot time — what was declared when this snapshot was taken */
79
86
  digest?: BuildDigest;
80
87
  }
@@ -4,7 +4,8 @@
4
4
  * `policyGate` Op step runs this to gate an apply on the same checks.
5
5
  */
6
6
  import { resolve, dirname } from "node:path";
7
- import { loadChantConfigUpward } from "../config";
7
+ import { loadChantConfigUpward, resolveOwnershipEnv, resolveOwnershipMarker } from "../config";
8
+ import { resolveBuildParams } from "../build-params";
8
9
  import { resolveProjectLexicons, loadPlugins } from "../cli/plugins";
9
10
  import { build } from "../build";
10
11
  import { runPostSynthChecks, isPostSynthCheck } from "./post-synth";
@@ -86,9 +87,19 @@ export async function evaluateProjectPolicies(opts: {
86
87
  const loaded = await loadChantConfigUpward(buildPath);
87
88
  const config = loaded.config;
88
89
  const configDir = loaded.configPath ? dirname(loaded.configPath) : buildPath;
89
- const env = opts.env ?? config.ownership?.env;
90
+ // #1396 `ownership.env` may reference a build parameter, so the declared
91
+ // parameters are resolved first (from their env mappings and defaults, the
92
+ // same inputs an Op step has) and the build sees them too.
93
+ const params = resolveBuildParams(config.buildParams, { env: process.env });
94
+ if (params.errors.length > 0) {
95
+ throw new Error(`Build parameters did not resolve — cannot evaluate policy:\n ${params.errors.join("\n ")}`);
96
+ }
97
+ const env = opts.env ?? resolveOwnershipEnv(config, params.provenance);
90
98
 
91
- const result = await build(buildPath, serializers);
99
+ const result = await build(buildPath, serializers, undefined, {
100
+ ownership: resolveOwnershipMarker(config, params.provenance),
101
+ buildParams: params.provenance,
102
+ });
92
103
  if (result.errors.length > 0) {
93
104
  throw new Error("Build failed — cannot evaluate policy on a broken build");
94
105
  }
@@ -2,28 +2,16 @@ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
4
4
  k8sListMapOrderKey,
5
+ K8S_SYSTEM_METADATA_PRUNE_PATTERNS,
5
6
  buildOwnershipSets,
6
- pruneByOwnership,
7
- type OwnershipSets,
8
7
  } from "./managed-fields";
9
- import { normalizeDeepProperties, type DeepNode } from "./deep-observation";
8
+ import { normalizeDeepProperties } from "./deep-observation";
10
9
 
11
10
  /** The naming scheme both the k8s lexicon (via `@intentius/chant-k8s-client`'s `isChantFieldManager`) and gcp restate use in their own tests. */
12
11
  function isChantManager(manager: string | undefined): boolean {
13
12
  return !!manager && (manager === "chant" || manager.startsWith("chant:"));
14
13
  }
15
14
 
16
- function node(partial: Partial<DeepNode> & Pick<DeepNode, "path" | "pattern">): DeepNode {
17
- return {
18
- entityType: "Test::Entity",
19
- key: partial.pattern,
20
- value: undefined,
21
- side: "live",
22
- counterpart: "unknown",
23
- ...partial,
24
- };
25
- }
26
-
27
15
  describe("K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS — the generic Kubernetes object envelope", () => {
28
16
  test("covers status and the server-minted metadata fields", () => {
29
17
  for (const p of [
@@ -150,36 +138,21 @@ describe("buildOwnershipSets — resolving managedFields against live and declar
150
138
  });
151
139
  });
152
140
 
153
- describe("pruneByOwnership — the shared three-question rule", () => {
154
- const sets: OwnershipSets = {
155
- chantOwned: new Set(["metadata.labels.tier"]),
156
- foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
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
- ]),
163
- };
164
-
165
- test("never prunes the declared side", () => {
166
- expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas", side: "declared" }), sets)).toBe(false);
167
- });
168
-
169
- test("never prunes a chant-owned path", () => {
170
- expect(pruneByOwnership(node({ path: "metadata.labels.tier", pattern: "metadata.labels.tier" }), sets)).toBe(false);
171
- });
172
-
173
- test("prunes a foreign-owned, uncontested (undeclared) path", () => {
174
- expect(pruneByOwnership(node({ path: "metadata.annotations.noise", pattern: "metadata.annotations.noise" }), sets)).toBe(true);
175
- });
176
-
177
- test("keeps a foreign-owned, contested (declared) path", () => {
178
- expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas" }), sets)).toBe(false);
141
+ describe("K8S_SYSTEM_METADATA_PRUNE_PATTERNSwhat the controllers stamp, not what a human labels (#1191)", () => {
142
+ test("covers client-side apply bookkeeping, the Deployment controller's rollout counters and the controllers' selector labels", () => {
143
+ for (const p of [
144
+ "metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
145
+ "metadata.annotations.deployment.kubernetes.io/revision",
146
+ "metadata.labels.pod-template-hash",
147
+ "metadata.labels.controller-revision-hash",
148
+ ]) {
149
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has(p)).toBe(true);
150
+ }
179
151
  });
180
152
 
181
- test("leaves a path with no ownership information alone (never pruned by this rule)", () => {
182
- expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
153
+ test("an out-of-band user label is not on the list it has to reach the diff as undeclared", () => {
154
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.labels.team")).toBe(false);
155
+ expect(K8S_SYSTEM_METADATA_PRUNE_PATTERNS.has("metadata.annotations.kubernetes.io/change-cause")).toBe(false);
183
156
  });
184
157
  });
185
158
 
@@ -142,17 +142,18 @@ export interface OwnershipSets {
142
142
  chantOwned: ReadonlySet<string>;
143
143
  /** Paths owned by a manager that is not chant. */
144
144
  foreignOwned: ReadonlySet<string>;
145
- /** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
145
+ /** The subset of `foreignOwned` where the declared manifest also sets the path — a contested field. */
146
146
  foreignContested: ReadonlySet<string>;
147
147
  /**
148
148
  * Path → the name of the manager that owns it (#1189).
149
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.
150
+ * The three sets above answer *which category* owns a path. A reader needs
151
+ * the other question — "owned by `kubectl-client-side-apply`" and "owned by
152
+ * `hpa-controller`" are the same category and mean very different things to
153
+ * an operator. Since chant #1191 no category is pruned from the diff, so
154
+ * this map is what tells the two apart in the report. Last writer wins
155
+ * where several managers touch one path, matching what the API server
156
+ * itself reports.
156
157
  */
157
158
  owners: ReadonlyMap<string, string>;
158
159
  }
@@ -323,25 +324,41 @@ export function buildOwnershipSets(
323
324
  }
324
325
 
325
326
  /**
326
- * The three-question managed-fields prune rule, as a predicate over a
327
- * {@link DeepNode} plus one object's precomputed {@link OwnershipSets}
328
- * shared by every lexicon layering a per-resource managed-fields prune on
329
- * top of its own static rules (k8s's `perResourceHooks`, gcp's equivalent):
327
+ * Well-known metadata the control plane writes on an object regardless of
328
+ * who applied it matched on the exact index-erased pattern, like
329
+ * {@link K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS}, but gated by the caller on
330
+ * `side === "live" && counterpart === "absent"` so a manifest that *does*
331
+ * declare one of these (a hand-pinned `change-cause`, say) is still compared.
330
332
  *
331
- * 1. Chant owns the path (any chant manager) never pruned by this rule.
332
- * 2. A different manager owns it, chant does not, and it is not declared →
333
- * controller-managed noise, pruned.
334
- * 3. It is declared, regardless of who owns it live → never pruned by this
335
- * rule, because chant's source is a statement of intent independent of
336
- * which write currently holds the field.
333
+ * This list is the noise valve that replaced the managed-fields prune
334
+ * (chant #1191). Until then a foreign-owned, undeclared path was dropped
335
+ * before the diff ever saw it, which silenced `kubectl label deploy web
336
+ * team=platform` the exact console-edit class the `undeclared` drift kind
337
+ * exists for. Now a foreign-owned path nobody declared is reported as
338
+ * `undeclared` (with its owner named, #1189) unless the accepted baseline
339
+ * already carries it — and what remains to subtract statically is only the
340
+ * handful of annotations and labels Kubernetes' own controllers stamp on
341
+ * every object of a kind. Widening this set is additive; each entry must be
342
+ * something *no* human writes out of band.
337
343
  *
338
- * Only applies to the live side — the declared tree carries no managedFields
339
- * to prune by.
344
+ * - `kubectl.kubernetes.io/last-applied-configuration` client-side apply's
345
+ * bookkeeping, a JSON copy of whatever was last applied.
346
+ * - `deployment.kubernetes.io/revision` (+ `revision-history`,
347
+ * `desired-replicas`, `max-replicas`) — the Deployment controller's
348
+ * rollout counters, written to Deployments and their ReplicaSets.
349
+ * - `pod-template-hash` / `controller-revision-hash` /
350
+ * `statefulset.kubernetes.io/pod-name` — the selector labels the
351
+ * ReplicaSet, DaemonSet and StatefulSet controllers add to what they own.
340
352
  */
341
- export function pruneByOwnership(node: DeepNode, sets: OwnershipSets): boolean {
342
- if (node.side !== "live") return false;
343
- if (sets.chantOwned.has(node.path)) return false;
344
- if (!sets.foreignOwned.has(node.path)) return false;
345
- if (sets.foreignContested.has(node.path)) return false;
346
- return true;
347
- }
353
+ export const K8S_SYSTEM_METADATA_PRUNE_PATTERNS: ReadonlySet<string> = new Set([
354
+ "metadata.annotations.kubectl.kubernetes.io/last-applied-configuration",
355
+ "metadata.annotations.deployment.kubernetes.io/revision",
356
+ "metadata.annotations.deployment.kubernetes.io/revision-history",
357
+ "metadata.annotations.deployment.kubernetes.io/desired-replicas",
358
+ "metadata.annotations.deployment.kubernetes.io/max-replicas",
359
+ "metadata.labels.pod-template-hash",
360
+ "metadata.labels.controller-revision-hash",
361
+ "metadata.labels.statefulset.kubernetes.io/pod-name",
362
+ "spec.template.metadata.labels.pod-template-hash",
363
+ "spec.template.metadata.labels.controller-revision-hash",
364
+ ]);
@@ -29,7 +29,7 @@ const meta = (over: Partial<ResourceMetadata> = {}): ResourceMetadata => ({
29
29
 
30
30
  describe("normalizeObservation", () => {
31
31
  test("a bare map means 'I looked at everything'", () => {
32
- expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {} });
32
+ expect(normalizeObservation({ a: meta() })).toEqual({ resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] });
33
33
  });
34
34
 
35
35
  test("the envelope carries both halves", () => {
@@ -38,11 +38,19 @@ describe("normalizeObservation", () => {
38
38
  resources: { a: meta() },
39
39
  unobserved: { b: { reason: "read-failed" } },
40
40
  queried: {},
41
+ notes: [],
41
42
  });
42
43
  });
43
44
 
45
+ test("run-level notes ride the envelope, distinct (#1265)", () => {
46
+ const value = observation({ a: meta() }, undefined, undefined, ["ownership unknown", "ownership unknown"]);
47
+ expect(value.notes).toEqual(["ownership unknown", "ownership unknown"]);
48
+ expect(normalizeObservation(value).notes).toEqual(["ownership unknown"]);
49
+ expect(observation({}).notes).toBeUndefined();
50
+ });
51
+
44
52
  test("undefined normalizes to empty maps", () => {
45
- expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {} });
53
+ expect(normalizeObservation(undefined)).toEqual({ resources: {}, unobserved: {}, queried: {}, notes: [] });
46
54
  });
47
55
 
48
56
  test("the envelope carries the queried addresses through normalization (#1620)", () => {
@@ -84,25 +92,33 @@ describe("unobservedAll", () => {
84
92
  describe("mergeObservations (multi-stack)", () => {
85
93
  test("present beats not-observed beats absent", () => {
86
94
  const merged = mergeObservations([
87
- { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {} },
88
- { resources: { a: meta() }, unobserved: {}, queried: {} },
95
+ { resources: {}, unobserved: { a: { reason: "read-failed" }, b: { reason: "no-binding" } }, queried: {}, notes: [] },
96
+ { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
89
97
  ]);
90
98
  expect(Object.keys(merged.resources)).toEqual(["a"]);
91
99
  expect(Object.keys(merged.unobserved)).toEqual(["b"]);
92
100
  });
93
101
 
102
+ test("the same note from four stacks is one note (#1265)", () => {
103
+ const note = "ownership filter unavailable";
104
+ const merged = mergeObservations(
105
+ ["a", "b", "c", "d"].map((k) => ({ resources: { [k]: meta() }, unobserved: {}, queried: {}, notes: [note] })),
106
+ );
107
+ expect(merged.notes).toEqual([note]);
108
+ });
109
+
94
110
  test("an entity nobody looked for in any stack stays absent", () => {
95
111
  const merged = mergeObservations([
96
- { resources: { a: meta() }, unobserved: {}, queried: {} },
97
- { resources: { b: meta() }, unobserved: {}, queried: {} },
112
+ { resources: { a: meta() }, unobserved: {}, queried: {}, notes: [] },
113
+ { resources: { b: meta() }, unobserved: {}, queried: {}, notes: [] },
98
114
  ]);
99
115
  expect(merged.unobserved).toEqual({});
100
116
  });
101
117
 
102
118
  test("queried addresses union across stacks (#1620)", () => {
103
119
  const merged = mergeObservations([
104
- { resources: {}, unobserved: {}, queried: { a: "stack-1/a" } },
105
- { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" } },
120
+ { resources: {}, unobserved: {}, queried: { a: "stack-1/a" }, notes: [] },
121
+ { resources: { b: meta() }, unobserved: {}, queried: { b: "stack-2/b" }, notes: [] },
106
122
  ]);
107
123
  expect(merged.queried).toEqual({ a: "stack-1/a", b: "stack-2/b" });
108
124
  });