@intentius/chant 0.28.0 → 0.30.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/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -1,15 +1,36 @@
1
1
  import { resolve } from "node:path";
2
2
  import { build } from "../../build";
3
3
  import { takeSnapshot } from "../../lifecycle/snapshot";
4
- import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
4
+ import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
5
+ import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
6
+ import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
7
+ import {
8
+ acceptDeviations,
9
+ baselineForLexicon,
10
+ emptyBaseline,
11
+ readObservationBaseline,
12
+ writeObservationBaseline,
13
+ OBSERVATION_BASELINE_FILE,
14
+ type DeviationToAccept,
15
+ type ObservationBaseline,
16
+ } from "../../lifecycle/observation-baseline";
5
17
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
6
18
  import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
7
- import { buildChangeSet, renderChangeSet, gitlabMrReport, type ChangeSet } from "../../lifecycle/change-set";
19
+ import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
20
+ import {
21
+ formatUnobserved,
22
+ mergeObservations,
23
+ normalizeObservation,
24
+ unobservedAll,
25
+ type NormalizedObservation,
26
+ type UnobservedEntity,
27
+ } from "../../observation";
8
28
  import { discoverComponents } from "../../components/discover";
9
29
  import { cfnDeployStacks } from "./components";
10
30
  import { affectedStacks } from "../../lifecycle/affected";
11
31
  import { rollbackToRevision } from "../../lifecycle/rollback";
12
- import { loadChantConfig } from "../../config";
32
+ import { loadChantConfig, environmentNames } from "../../config";
33
+ import { applyLiveEndpoint } from "../../live-endpoint";
13
34
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
14
35
  import type { CommandContext } from "../registry";
15
36
  import type { LifecycleSnapshot } from "../../lifecycle/types";
@@ -75,10 +96,11 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
75
96
  // Validate environment against config
76
97
  const projectPath = resolve(".");
77
98
  const { config } = await loadChantConfig(projectPath);
78
- if (config.environments && !config.environments.includes(environment)) {
99
+ const declaredEnvNames = environmentNames(config.environments);
100
+ if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
79
101
  console.error(formatError({
80
102
  message: `Unknown environment "${environment}"`,
81
- hint: `Defined environments: ${config.environments.join(", ")}`,
103
+ hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
82
104
  }));
83
105
  return 1;
84
106
  }
@@ -105,48 +127,58 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
105
127
  let anySnapshotSaved = false;
106
128
  let anyHardError = false;
107
129
 
108
- for (const target of targets) {
109
- const label = target.stack ? `stack "${target.stack}"` : "project";
110
- const buildResult = await build(target.root, targetSerializers);
111
- if (buildResult.errors.length > 0) {
112
- console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
113
- anyHardError = true;
114
- continue;
115
- }
130
+ // #1166 same self-sufficiency as `chant graph --live`: a snapshot is
131
+ // always a live read, so an environment's declared endpoint applies here
132
+ // too, unless the ambient shell already set it.
133
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
134
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
116
135
 
117
- let result;
118
- try {
119
- result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
120
- } catch (err) {
121
- if (err instanceof StaleLifecycleBranchError) {
122
- console.error(formatError({
123
- message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
124
- hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
125
- }));
126
- return 1;
136
+ try {
137
+ for (const target of targets) {
138
+ const label = target.stack ? `stack "${target.stack}"` : "project";
139
+ const buildResult = await build(target.root, targetSerializers);
140
+ if (buildResult.errors.length > 0) {
141
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
142
+ anyHardError = true;
143
+ continue;
127
144
  }
128
- throw err;
129
- }
130
145
 
131
- for (const w of result.warnings) {
132
- console.error(formatWarning({ message: w }));
133
- }
134
- for (const e of result.errors) {
135
- console.error(formatError({ message: e }));
136
- }
146
+ let result;
147
+ try {
148
+ result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
149
+ } catch (err) {
150
+ if (err instanceof StaleLifecycleBranchError) {
151
+ console.error(formatError({
152
+ message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
153
+ hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
154
+ }));
155
+ return 1;
156
+ }
157
+ throw err;
158
+ }
159
+
160
+ for (const w of result.warnings) {
161
+ console.error(formatWarning({ message: w }));
162
+ }
163
+ for (const e of result.errors) {
164
+ console.error(formatError({ message: e }));
165
+ }
137
166
 
138
- if (result.snapshots.length > 0) {
139
- anySnapshotSaved = true;
140
- const prefix = target.stack ? `${target.stack}: ` : "";
141
- const counts = result.snapshots
142
- .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
143
- .join(" ");
144
- console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
167
+ if (result.snapshots.length > 0) {
168
+ anySnapshotSaved = true;
169
+ const prefix = target.stack ? `${target.stack}: ` : "";
170
+ const counts = result.snapshots
171
+ .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
172
+ .join(" ");
173
+ console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
174
+ }
175
+ if (result.errors.length > 0) anyHardError = true;
145
176
  }
146
- if (result.errors.length > 0) anyHardError = true;
147
- }
148
177
 
149
- return anyHardError && !anySnapshotSaved ? 1 : 0;
178
+ return anyHardError && !anySnapshotSaved ? 1 : 0;
179
+ } finally {
180
+ endpointResult.restore();
181
+ }
150
182
  }
151
183
 
152
184
  /**
@@ -263,71 +295,163 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
263
295
  const perStackJson: Record<string, unknown> = {};
264
296
  let combinedLexiconsJson: Record<string, unknown> | undefined;
265
297
  let totalDrift = 0;
298
+ let totalUnobserved = 0;
266
299
  let totalChecked = 0;
267
300
  let anyBuildError = false;
268
301
 
269
- for (const target of targets) {
270
- const buildResult = await build(target.root, targetSerializers);
271
- if (buildResult.errors.length > 0) {
272
- const label = target.stack ? `stack "${target.stack}"` : "project";
273
- console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
274
- anyBuildError = true;
275
- continue;
276
- }
302
+ // Accepted-deviation baseline (#1014). Read once for the whole run — it is
303
+ // env-keyed, not stack-keyed, and every deep pass subtracts from the same
304
+ // committed set. Absent is the normal state (nothing accepted yet).
305
+ const baseline = args.live ? await readObservationBaseline(environment) : null;
306
+ const accepted: Record<string, DeviationToAccept[]> = {};
277
307
 
278
- const lexicons = lexiconFilter
279
- ? [lexiconFilter]
280
- : Array.from(buildResult.manifest.lexicons);
308
+ // #1166 an environment can declare its own endpoint (a local emulator like
309
+ // Floci), so `--live` is self-sufficient even when the ambient shell never
310
+ // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
311
+ // Scoped to just the live reads below — restored in `finally`.
312
+ const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts).map((p) => p.name) : [];
313
+ const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
314
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
281
315
 
282
- if (args.live) {
283
- // Multi-stack component projects: observe each component's own cfn stack
284
- // and union (same fix runGraphLive / plan use), so a deployed resource
285
- // isn't reported "missing". Only when this target has no explicit stack.
286
- let componentStacks: string[] = [];
287
- if (!target.stack) {
288
- try {
289
- const disc = await discoverComponents(target.root, { sandbox: args.sandbox });
290
- const set = new Set<string>();
291
- for (const { component } of disc.components.values()) for (const s of cfnDeployStacks(component.deploy)) set.add(s);
292
- componentStacks = [...set];
293
- } catch {
294
- // no components / discovery failed → single-stack observe path
316
+ try {
317
+ for (const target of targets) {
318
+ const buildResult = await build(target.root, targetSerializers);
319
+ if (buildResult.errors.length > 0) {
320
+ const label = target.stack ? `stack "${target.stack}"` : "project";
321
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
322
+ anyBuildError = true;
323
+ continue;
324
+ }
325
+
326
+ const lexicons = lexiconFilter
327
+ ? [lexiconFilter]
328
+ : Array.from(buildResult.manifest.lexicons);
329
+
330
+ if (args.live) {
331
+ // Multi-stack component projects: observe each component's own cfn stack
332
+ // and union (same fix runGraphLive / plan use), so a deployed resource
333
+ // isn't reported "missing". Only when this target has no explicit stack.
334
+ let componentStacks: string[] = [];
335
+ if (!target.stack) {
336
+ try {
337
+ const disc = await discoverComponents(target.root, { sandbox: args.sandbox });
338
+ const set = new Set<string>();
339
+ for (const { component } of disc.components.values()) for (const s of cfnDeployStacks(component.deploy)) set.add(s);
340
+ componentStacks = [...set];
341
+ } catch {
342
+ // no components / discovery failed → single-stack observe path
343
+ }
344
+ }
345
+ const r = await runLifecycleDiffLive({
346
+ environment,
347
+ lexicons,
348
+ plugins,
349
+ buildResult,
350
+ json,
351
+ stack: target.stack,
352
+ componentStacks,
353
+ baseline,
354
+ updateBaseline: args.updateBaseline,
355
+ });
356
+ totalDrift += r.totalDrift;
357
+ totalUnobserved += r.totalUnobserved;
358
+ totalChecked += r.totalLexiconsChecked;
359
+ for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
360
+ (accepted[lexicon] ??= []).push(...deviations);
295
361
  }
362
+ if (json) {
363
+ if (target.stack) perStackJson[target.stack] = r.byLexicon;
364
+ else combinedLexiconsJson = r.byLexicon;
365
+ }
366
+ } else {
367
+ await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
296
368
  }
297
- const r = await runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json, stack: target.stack, componentStacks });
298
- totalDrift += r.totalDrift;
299
- totalChecked += r.totalLexiconsChecked;
369
+ }
370
+
371
+ // `--update-baseline` (#1014): record what the deep pass just reported as
372
+ // accepted, so it stops re-alerting. Runs before the summary lines so the
373
+ // "no drift" verdict below still describes the run that produced it.
374
+ if (args.live && args.updateBaseline) {
375
+ await recordAcceptedBaseline(environment, baseline, accepted, json);
376
+ }
377
+
378
+ if (args.live) {
300
379
  if (json) {
301
- if (target.stack) perStackJson[target.stack] = r.byLexicon;
302
- else combinedLexiconsJson = r.byLexicon;
380
+ // Single-stack keeps the original `{ environment, lexicons }` shape
381
+ // (behold's inspect diff, #852); multi-stack nests under `stacks`.
382
+ console.log(
383
+ JSON.stringify(
384
+ combinedLexiconsJson !== undefined
385
+ ? { environment, lexicons: combinedLexiconsJson }
386
+ : { environment, stacks: perStackJson },
387
+ ),
388
+ );
389
+ } else if (totalChecked === 0) {
390
+ console.error(formatWarning({
391
+ message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
392
+ }));
393
+ return 1;
394
+ } else if (totalDrift === 0) {
395
+ // Qualify the all-clear when part of the estate was never read (#1089):
396
+ // "no drift" over an incomplete observation is not the same claim.
397
+ console.error(
398
+ totalUnobserved > 0
399
+ ? formatWarning({
400
+ message: `No drift detected across ${totalChecked} lexicon(s), but ${totalUnobserved} declared entity(ies) could not be observed — that part of the estate is unknown, not clean`,
401
+ })
402
+ : formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`),
403
+ );
303
404
  }
304
- } else {
305
- await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
306
405
  }
406
+
407
+ return anyBuildError ? 1 : 0;
408
+ } finally {
409
+ endpointResult.restore();
307
410
  }
411
+ }
308
412
 
309
- if (args.live) {
310
- if (json) {
311
- // Single-stack keeps the original `{ environment, lexicons }` shape
312
- // (behold's inspect diff, #852); multi-stack nests under `stacks`.
313
- console.log(
314
- JSON.stringify(
315
- combinedLexiconsJson !== undefined
316
- ? { environment, lexicons: combinedLexiconsJson }
317
- : { environment, stacks: perStackJson },
318
- ),
319
- );
320
- } else if (totalChecked === 0) {
413
+ /**
414
+ * Write the accepted-deviation baseline (#1014) for everything the deep pass
415
+ * reported this run, and push it on the same orphan branch the snapshots use.
416
+ *
417
+ * Acceptance is a deliberate, committed act — that is the whole difference
418
+ * between this and a suppression flag — so the write is loud: it names the
419
+ * count and the storage path, and a failed push says so rather than leaving
420
+ * the operator believing the team's baseline moved.
421
+ */
422
+ async function recordAcceptedBaseline(
423
+ environment: string,
424
+ existing: ObservationBaseline | null,
425
+ accepted: Record<string, DeviationToAccept[]>,
426
+ json: boolean,
427
+ ): Promise<void> {
428
+ const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
429
+ if (total === 0) {
430
+ if (!json) {
321
431
  console.error(formatWarning({
322
- message: "No lexicons implement describeResources or listArtifacts nothing to diff in --live mode",
432
+ message: "--update-baseline: nothing to acceptno property-level deviations were reported",
323
433
  }));
324
- return 1;
325
- } else if (totalDrift === 0) {
326
- console.error(formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`));
327
434
  }
435
+ return;
436
+ }
437
+ let next = existing ?? emptyBaseline(environment);
438
+ for (const [lexicon, deviations] of Object.entries(accepted)) {
439
+ next = acceptDeviations(next, lexicon, deviations);
440
+ }
441
+ try {
442
+ await writeObservationBaseline(next);
443
+ const pushed = await pushLifecycle();
444
+ if (!json) {
445
+ console.error(formatSuccess(
446
+ `--update-baseline: accepted ${total} deviation(s) into ${environment}/${OBSERVATION_BASELINE_FILE} on chant/lifecycle` +
447
+ (pushed ? " (pushed)" : " (local only — no remote configured or push refused)"),
448
+ ));
449
+ }
450
+ } catch (err) {
451
+ console.error(formatError({
452
+ message: `--update-baseline: could not write the baseline — ${err instanceof Error ? err.message : String(err)}`,
453
+ }));
328
454
  }
329
-
330
- return anyBuildError ? 1 : 0;
331
455
  }
332
456
 
333
457
  interface BetweenDiffArgs {
@@ -452,15 +576,86 @@ interface LiveDiffArgs {
452
576
  * union (the same fix graph/plan use), else every deployed resource reads as
453
577
  * "missing". Empty → the single-stack observe path. */
454
578
  componentStacks?: string[];
579
+ /** Accepted-deviation baseline for this environment (#1014), or null when none is recorded. */
580
+ baseline: ObservationBaseline | null;
581
+ /** `--update-baseline`: accept everything the deep pass reports this run. */
582
+ updateBaseline?: boolean;
455
583
  }
456
584
 
457
585
  interface LiveDiffOutcome {
458
586
  byLexicon: Record<
459
587
  string,
460
- { resources?: LiveDiffResult; observed?: Record<string, ResourceMetadata>; artifacts?: LiveArtifactDiffResult }
588
+ {
589
+ resources?: LiveDiffResult;
590
+ observed?: Record<string, ResourceMetadata>;
591
+ /** Declared entities the lexicon could not read (#1089), keyed by name. */
592
+ unobserved?: Record<string, UnobservedEntity>;
593
+ /** Property-level drift (#1014), present only for lexicons with a deep reader. */
594
+ deep?: DeepDiffResult;
595
+ artifacts?: LiveArtifactDiffResult;
596
+ }
461
597
  >;
462
598
  totalDrift: number;
599
+ /** Declared entities nobody could read. Not drift — a hole in the report. */
600
+ totalUnobserved: number;
463
601
  totalLexiconsChecked: number;
602
+ /** Deviations `--update-baseline` should record, per lexicon. */
603
+ toAccept: Record<string, DeviationToAccept[]>;
604
+ }
605
+
606
+ /**
607
+ * Read one lexicon's live resources, resolving the observation tri-state
608
+ * (#1089) for every declared entity: present, confirmed-absent, or not
609
+ * observed with a reason.
610
+ *
611
+ * Two behaviours the old inline call sites did not have. A throw no longer
612
+ * skips the lexicon — every entity it was asked about comes back
613
+ * NOT-OBSERVED (`read-failed`), so a failed read shows up in the plan instead
614
+ * of vanishing from it. And a multi-stack read merges with
615
+ * present > not-observed > absent, so one unreadable stack cannot un-observe a
616
+ * resource another stack returned.
617
+ */
618
+ async function observeLexicon(
619
+ plugin: ObservationLexicon,
620
+ opts: {
621
+ environment: string;
622
+ buildOutput: string;
623
+ declared: Set<string>;
624
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
625
+ stack?: string;
626
+ componentStacks?: string[];
627
+ owned?: boolean;
628
+ },
629
+ ): Promise<NormalizedObservation> {
630
+ const entityNames = Array.from(opts.declared);
631
+ const base = {
632
+ environment: opts.environment,
633
+ buildOutput: opts.buildOutput,
634
+ entityNames,
635
+ entities: opts.entities,
636
+ ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
637
+ };
638
+ try {
639
+ if (opts.componentStacks && opts.componentStacks.length > 0) {
640
+ const parts: NormalizedObservation[] = [];
641
+ for (const stack of opts.componentStacks) {
642
+ parts.push(normalizeObservation(await plugin.describeResources!({ ...base, stack })));
643
+ }
644
+ return mergeObservations(parts);
645
+ }
646
+ return normalizeObservation(
647
+ await plugin.describeResources!({ ...base, ...(opts.stack ? { stack: opts.stack } : {}) }),
648
+ );
649
+ } catch (err) {
650
+ const message = err instanceof Error ? err.message : String(err);
651
+ console.error(formatError({
652
+ message: `${plugin.name}: describeResources failed — ${message} (reporting ${entityNames.length} declared entity(ies) as not observed, not as absent)`,
653
+ }));
654
+ return {
655
+ resources: {},
656
+ unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
657
+ };
658
+ }
464
659
  }
465
660
 
466
661
  /** Diff current build vs live cloud for one stack. Renders the human report
@@ -468,8 +663,10 @@ interface LiveDiffOutcome {
468
663
  * once (single-stack keeps the original shape; multi-stack nests under `stacks`). */
469
664
  async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome> {
470
665
  let totalDrift = 0;
666
+ let totalUnobserved = 0;
471
667
  let totalLexiconsChecked = 0;
472
668
  const byLexicon: LiveDiffOutcome["byLexicon"] = {};
669
+ const toAccept: Record<string, DeviationToAccept[]> = {};
473
670
  if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
474
671
 
475
672
  for (const lexiconName of args.lexicons) {
@@ -515,40 +712,50 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
515
712
 
516
713
  // ── Resources path (entity-keyed) ──────────────────────────────────────
517
714
  if (plugin.describeResources) {
518
- let observedNow: Record<string, ResourceMetadata> = {};
519
- try {
520
- if (args.componentStacks && args.componentStacks.length > 0) {
521
- // Observe each component's own stack and union — the multi-stack fix.
522
- for (const stack of args.componentStacks) {
523
- Object.assign(
524
- observedNow,
525
- await plugin.describeResources({ environment: args.environment, buildOutput, entityNames: Array.from(declared), entities, stack }),
526
- );
527
- }
528
- } else {
529
- observedNow = await plugin.describeResources({
530
- environment: args.environment,
531
- buildOutput,
532
- entityNames: Array.from(declared),
533
- entities,
534
- stack: args.stack,
535
- });
536
- }
537
- } catch (err) {
538
- console.error(formatError({
539
- message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
540
- }));
541
- continue;
542
- }
715
+ const observed = await observeLexicon(plugin, {
716
+ environment: args.environment,
717
+ buildOutput,
718
+ declared,
719
+ entities,
720
+ stack: args.stack,
721
+ componentStacks: args.componentStacks,
722
+ });
723
+ const observedNow = observed.resources;
543
724
  const observedThen = prevSnapshot?.resources;
544
- const diff = diffLive({ declared, observedNow, observedThen });
725
+ const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
726
+ // Unobserved entities are deliberately NOT drift: a hole in the read is
727
+ // not a change in the cloud. They are reported separately (#1089) so a
728
+ // "no drift detected" line can never be built on top of a failed read.
545
729
  totalDrift += diff.driftedSinceSnapshot.length + diff.missing.length + diff.orphan.length + diff.disappeared.length;
730
+ totalUnobserved += diff.unobserved.length;
546
731
  if (args.json) {
547
732
  const entry = (byLexicon[lexiconName] ??= {});
548
733
  entry.resources = diff;
549
734
  entry.observed = observedNow; // live state (#862) — status/attributes per resource
735
+ if (Object.keys(observed.unobserved).length > 0) entry.unobserved = observed.unobserved;
550
736
  } else renderLiveDiff(lexiconName, args.environment, diff);
551
737
  lexiconChecked = true;
738
+
739
+ // ── Deep path (property-level, #1014) ───────────────────────────────
740
+ // Gated purely on the capability: a lexicon without a deep reader is
741
+ // completely unaffected, including its output.
742
+ if (plugin.observeResourcesDeep) {
743
+ const deep = await deepDiffForLexicon(plugin, {
744
+ environment: args.environment,
745
+ buildOutput,
746
+ entities,
747
+ stack: args.stack,
748
+ componentStacks: args.componentStacks,
749
+ baseline: baselineForLexicon(args.baseline, lexiconName),
750
+ });
751
+ totalDrift += countPropertyDrift(deep);
752
+ // Only count a deep hole for an entity the thin read *did* resolve —
753
+ // otherwise one unreadable entity is counted twice.
754
+ totalUnobserved += deep.unobserved.filter((u) => !observed.unobserved[u.name]).length;
755
+ if (args.updateBaseline) toAccept[lexiconName] = deviationsToAccept(deep);
756
+ if (args.json) (byLexicon[lexiconName] ??= {}).deep = deep;
757
+ else renderDeepDiff(lexiconName, deep);
758
+ }
552
759
  }
553
760
 
554
761
  // ── Artifacts path (context-keyed) ─────────────────────────────────────
@@ -573,21 +780,97 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
573
780
  if (lexiconChecked) totalLexiconsChecked++;
574
781
  }
575
782
 
576
- return { byLexicon, totalDrift, totalLexiconsChecked };
783
+ return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked, toAccept };
784
+ }
785
+
786
+ /**
787
+ * Everything a deep diff reported this run, as deviations to record accepted.
788
+ * `--update-baseline` accepts what was *reported*, never what was already
789
+ * suppressed — re-accepting an unchanged suppression would rewrite its
790
+ * `recordedAt` on every run and turn the baseline into a churn file.
791
+ */
792
+ function deviationsToAccept(deep: DeepDiffResult): DeviationToAccept[] {
793
+ const out: DeviationToAccept[] = [];
794
+ for (const entity of deep.drifted) {
795
+ for (const change of entity.changes) {
796
+ // Only a value that is actually live can be accepted: `absent` means the
797
+ // cloud does not carry the declared property, which is a finding to fix
798
+ // in source or in the cloud, not a value to bless.
799
+ if (!("live" in change)) continue;
800
+ out.push({ entity: entity.name, type: entity.type, path: change.path, value: change.live });
801
+ }
802
+ }
803
+ return out;
804
+ }
805
+
806
+ /** Property-level drift report (#1014). Silent when a lexicon's deep read found nothing to say. */
807
+ function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
808
+ const drift = countPropertyDrift(deep);
809
+ if (
810
+ drift === 0 &&
811
+ deep.accepted.length === 0 &&
812
+ deep.unobserved.length === 0 &&
813
+ deep.undeclaredEntities.length === 0
814
+ ) {
815
+ return;
816
+ }
817
+
818
+ const acceptedCount = deep.accepted.reduce((n, e) => n + e.changes.length, 0);
819
+ console.log(`\n${formatBold(`${lexiconName} (properties)`)}`);
820
+ console.log(
821
+ `${drift} property drift across ${deep.drifted.length} resource(s), ` +
822
+ `${acceptedCount} accepted, ${deep.unchanged.length} unchanged` +
823
+ (deep.unobserved.length > 0 ? `, ${deep.unobserved.length} unobserved` : ""),
824
+ );
825
+ console.log("-".repeat(80));
826
+
827
+ if (deep.unobserved.length > 0) {
828
+ console.log(formatBold("\nPROPERTIES UNOBSERVED (declared; the deep read could not look):"));
829
+ for (const u of deep.unobserved) console.log(` ? ${formatUnobserved(u.name, u)}`);
830
+ }
831
+ if (deep.drifted.length > 0) {
832
+ console.log(formatBold("\nPROPERTY DRIFT (declared vs live; baseline shown where one exists):"));
833
+ for (const entity of deep.drifted) {
834
+ console.log(` - ${entity.name} (${entity.type})`);
835
+ for (const change of entity.changes) {
836
+ const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
837
+ const live = "live" in change ? formatValue(change.live) : "<absent>";
838
+ const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
839
+ console.log(` ${change.path}: ${declared} → ${live}${baseline}`);
840
+ }
841
+ }
842
+ }
843
+ if (deep.undeclaredEntities.length > 0) {
844
+ console.log(formatBold("\nUNDECLARED (read deeply, never declared in source):"));
845
+ for (const name of deep.undeclaredEntities) console.log(` - ${name}`);
846
+ }
847
+ if (acceptedCount > 0) {
848
+ console.log(formatBold("\nACCEPTED (in the baseline; not drift):"));
849
+ for (const entity of deep.accepted) {
850
+ console.log(` - ${entity.name}: ${entity.changes.map((c) => c.path).join(", ")}`);
851
+ }
852
+ }
577
853
  }
578
854
 
579
855
  function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
580
856
  const counts =
581
857
  `${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
582
858
  `${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
583
- `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged`;
859
+ `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged` +
860
+ (diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "");
584
861
 
585
862
  console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
586
863
  console.log(counts);
587
864
  console.log("-".repeat(80));
588
865
 
866
+ if (diff.unobserved.length > 0) {
867
+ console.log(formatBold("\nUNOBSERVED (declared; chant could not read live state — status unknown):"));
868
+ for (const u of diff.unobserved) {
869
+ console.log(` ? ${formatUnobserved(u.name, u)}`);
870
+ }
871
+ }
589
872
  if (diff.missing.length > 0) {
590
- console.log(formatBold("\nMISSING (declared, not in cloud):"));
873
+ console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
591
874
  for (const name of diff.missing) console.log(` - ${name}`);
592
875
  }
593
876
  if (diff.orphan.length > 0) {
@@ -662,9 +945,11 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
662
945
  * chant lifecycle plan <environment> [lexicon]
663
946
  *
664
947
  * Promote the live diff to a typed, read-only change set: per-entity
665
- * create / update / delete / adopt / noop. Strictly read-only — never
666
- * mutates, never deploys. Deletes are never proposed without ownership
667
- * data (added in #121); an undeclared live resource is `adopt`.
948
+ * create / update / delete / adopt / noop / unobserved. Strictly read-only —
949
+ * never mutates, never deploys. Deletes are never proposed without ownership
950
+ * data (added in #121); an undeclared live resource is `adopt`; a declared
951
+ * entity chant could not read is `unobserved` and gets no proposal at all
952
+ * (#1089) — a create is only ever proposed against a confirmed absence.
668
953
  */
669
954
  export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
670
955
  const { args, plugins, serializers } = ctx;
@@ -709,71 +994,70 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
709
994
  const merged: ChangeSet = { env: environment, entries: [] };
710
995
  let checked = 0;
711
996
 
712
- for (const lexiconName of lexicons) {
713
- const plugin = plugins.find((p) => p.name === lexiconName);
714
- if (!plugin) continue;
715
- if (!plugin.describeResources) {
716
- // Plan is entity-keyed; artifact-only lexicons have no declared axis.
717
- if (!args.json) {
718
- console.error(formatWarning({
719
- message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
720
- }));
721
- }
722
- continue;
723
- }
997
+ // #1166 same self-sufficiency as `chant graph --live`: an environment can
998
+ // declare its own endpoint, applied here unless the ambient shell already
999
+ // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
1000
+ // its own), so this applies unconditionally.
1001
+ const endpointResult = applyLiveEndpoint(config.environments, environment, lexicons);
1002
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
724
1003
 
725
- const declared = new Set<string>();
726
- const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
727
- for (const [name, entity] of buildResult.entities) {
728
- if (entity.lexicon === lexiconName) {
729
- declared.add(name);
730
- entities.set(name, {
731
- entityType: entity.entityType,
732
- props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
733
- });
1004
+ try {
1005
+ for (const lexiconName of lexicons) {
1006
+ const plugin = plugins.find((p) => p.name === lexiconName);
1007
+ if (!plugin) continue;
1008
+ if (!plugin.describeResources) {
1009
+ // Plan is entity-keyed; artifact-only lexicons have no declared axis.
1010
+ if (!args.json) {
1011
+ console.error(formatWarning({
1012
+ message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
1013
+ }));
1014
+ }
1015
+ continue;
734
1016
  }
735
- }
736
1017
 
737
- const rawOutput = buildResult.outputs.get(lexiconName);
738
- const buildOutput =
739
- rawOutput === undefined
740
- ? ""
741
- : typeof rawOutput === "string"
742
- ? rawOutput
743
- : (rawOutput as SerializerResult).primary;
744
-
745
- let observedNow: Record<string, ResourceMetadata> = {};
746
- try {
747
- if (componentStacks.length > 0) {
748
- // Observe each component's own stack and union — the multi-stack fix.
749
- for (const stack of componentStacks) {
750
- Object.assign(
751
- observedNow,
752
- await plugin.describeResources({ environment, buildOutput, entityNames: Array.from(declared), entities, stack, owned: args.owned }),
753
- );
1018
+ const declared = new Set<string>();
1019
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
1020
+ for (const [name, entity] of buildResult.entities) {
1021
+ if (entity.lexicon === lexiconName) {
1022
+ declared.add(name);
1023
+ entities.set(name, {
1024
+ entityType: entity.entityType,
1025
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
1026
+ });
754
1027
  }
755
- } else {
756
- observedNow = await plugin.describeResources({
757
- environment,
758
- buildOutput,
759
- entityNames: Array.from(declared),
760
- entities,
761
- owned: args.owned,
762
- });
763
1028
  }
764
- } catch (err) {
765
- console.error(formatError({
766
- message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
767
- }));
768
- continue;
769
- }
770
1029
 
771
- const content = await readSnapshot(environment, lexiconName);
772
- const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
773
-
774
- const cs = buildChangeSet(environment, { declared, observedNow, observedThen });
775
- merged.entries.push(...cs.entries);
776
- checked++;
1030
+ const rawOutput = buildResult.outputs.get(lexiconName);
1031
+ const buildOutput =
1032
+ rawOutput === undefined
1033
+ ? ""
1034
+ : typeof rawOutput === "string"
1035
+ ? rawOutput
1036
+ : (rawOutput as SerializerResult).primary;
1037
+
1038
+ const observed = await observeLexicon(plugin, {
1039
+ environment,
1040
+ buildOutput,
1041
+ declared,
1042
+ entities,
1043
+ componentStacks,
1044
+ owned: args.owned,
1045
+ });
1046
+
1047
+ const content = await readSnapshot(environment, lexiconName);
1048
+ const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
1049
+
1050
+ const cs = buildChangeSet(environment, {
1051
+ declared,
1052
+ observedNow: observed.resources,
1053
+ observedThen,
1054
+ unobserved: observed.unobserved,
1055
+ });
1056
+ merged.entries.push(...cs.entries);
1057
+ checked++;
1058
+ }
1059
+ } finally {
1060
+ endpointResult.restore();
777
1061
  }
778
1062
 
779
1063
  if (checked === 0) {
@@ -785,6 +1069,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
785
1069
 
786
1070
  merged.entries.sort((a, b) => a.name.localeCompare(b.name));
787
1071
 
1072
+ // Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
1073
+ // shapes have no room for it) still learn the plan has a hole (#1089).
1074
+ const unobservedCount = summarize(merged).unobserved;
1075
+ if (unobservedCount > 0) {
1076
+ console.error(formatWarning({
1077
+ message: `${unobservedCount} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
1078
+ }));
1079
+ }
1080
+
788
1081
  // `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
789
1082
  // human render. Write it to a file (`tfplan.json`) in CI and declare it as
790
1083
  // `artifacts:reports:terraform` to light up the merge-request widget.