@intentius/chant 0.27.0 → 0.29.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/commands/build.d.ts.map +1 -1
  2. package/dist/cli/commands/lint.d.ts.map +1 -1
  3. package/dist/cli/handlers/components.d.ts.map +1 -1
  4. package/dist/cli/handlers/graph.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  6. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  7. package/dist/config.d.ts +46 -4
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/discovery/fold-import.d.ts +153 -17
  10. package/dist/discovery/fold-import.d.ts.map +1 -1
  11. package/dist/discovery/sandbox/config-run.d.ts.map +1 -1
  12. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  13. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/fork.d.ts +25 -0
  15. package/dist/discovery/sandbox/fork.d.ts.map +1 -1
  16. package/dist/discovery/sandbox/policy-run.d.ts.map +1 -1
  17. package/dist/discovery/sandbox/run.d.ts.map +1 -1
  18. package/dist/env.d.ts +5 -2
  19. package/dist/env.d.ts.map +1 -1
  20. package/dist/fold/fold.d.ts +12 -0
  21. package/dist/fold/fold.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +29 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +1 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +100 -0
  27. package/dist/kubectl-context.d.ts.map +1 -0
  28. package/dist/lexicon.d.ts +31 -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/live-diff.d.ts +25 -1
  33. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  34. package/dist/lifecycle/observe.d.ts +4 -2
  35. package/dist/lifecycle/observe.d.ts.map +1 -1
  36. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  37. package/dist/lifecycle/status.d.ts +26 -1
  38. package/dist/lifecycle/status.d.ts.map +1 -1
  39. package/dist/lifecycle/types.d.ts +8 -0
  40. package/dist/lifecycle/types.d.ts.map +1 -1
  41. package/dist/lint/config.d.ts +80 -0
  42. package/dist/lint/config.d.ts.map +1 -1
  43. package/dist/lint/policy.d.ts +8 -2
  44. package/dist/lint/policy.d.ts.map +1 -1
  45. package/dist/lint/post-synth.d.ts +18 -1
  46. package/dist/lint/post-synth.d.ts.map +1 -1
  47. package/dist/live-endpoint.d.ts +92 -0
  48. package/dist/live-endpoint.d.ts.map +1 -0
  49. package/dist/observation.d.ts +123 -0
  50. package/dist/observation.d.ts.map +1 -0
  51. package/dist/stack-output.d.ts +9 -4
  52. package/dist/stack-output.d.ts.map +1 -1
  53. package/package.json +1 -1
  54. package/src/cli/commands/build.test.ts +190 -0
  55. package/src/cli/commands/build.ts +34 -2
  56. package/src/cli/commands/lint.ts +17 -25
  57. package/src/cli/handlers/components.test.ts +63 -4
  58. package/src/cli/handlers/components.ts +78 -35
  59. package/src/cli/handlers/graph.test.ts +69 -6
  60. package/src/cli/handlers/graph.ts +61 -27
  61. package/src/cli/handlers/lifecycle.test.ts +285 -6
  62. package/src/cli/handlers/lifecycle.ts +297 -185
  63. package/src/config.test.ts +75 -0
  64. package/src/config.ts +61 -3
  65. package/src/discovery/fold-composite.test.ts +594 -0
  66. package/src/discovery/fold-import.ts +987 -43
  67. package/src/discovery/sandbox/config-boundary.test.ts +55 -1
  68. package/src/discovery/sandbox/config-run.ts +3 -0
  69. package/src/discovery/sandbox/config-wire.ts +3 -2
  70. package/src/discovery/sandbox/fork.ts +75 -1
  71. package/src/discovery/sandbox/policy-boundary.test.ts +56 -1
  72. package/src/discovery/sandbox/policy-run.ts +15 -1
  73. package/src/discovery/sandbox/run.test.ts +85 -1
  74. package/src/discovery/sandbox/run.ts +3 -0
  75. package/src/env.test.ts +12 -0
  76. package/src/env.ts +12 -4
  77. package/src/fold/fold.ts +12 -2
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +68 -12
  80. package/src/index.ts +1 -0
  81. package/src/kubectl-context.test.ts +94 -0
  82. package/src/kubectl-context.ts +207 -0
  83. package/src/lexicon.ts +41 -6
  84. package/src/lifecycle/change-set.test.ts +93 -1
  85. package/src/lifecycle/change-set.ts +65 -13
  86. package/src/lifecycle/live-diff.test.ts +39 -0
  87. package/src/lifecycle/live-diff.ts +51 -5
  88. package/src/lifecycle/observe.test.ts +74 -3
  89. package/src/lifecycle/observe.ts +82 -22
  90. package/src/lifecycle/snapshot.test.ts +39 -1
  91. package/src/lifecycle/snapshot.ts +34 -9
  92. package/src/lifecycle/status.test.ts +89 -8
  93. package/src/lifecycle/status.ts +53 -3
  94. package/src/lifecycle/types.ts +8 -0
  95. package/src/lint/config.test.ts +93 -1
  96. package/src/lint/config.ts +108 -0
  97. package/src/lint/policy.test.ts +90 -0
  98. package/src/lint/policy.ts +17 -5
  99. package/src/lint/post-synth.test.ts +4 -0
  100. package/src/lint/post-synth.ts +30 -1
  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 +76 -3
  106. package/src/stack-output.ts +59 -26
@@ -4,12 +4,21 @@ import { takeSnapshot } from "../../lifecycle/snapshot";
4
4
  import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
5
5
  import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
6
6
  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";
7
+ import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
8
+ import {
9
+ formatUnobserved,
10
+ mergeObservations,
11
+ normalizeObservation,
12
+ unobservedAll,
13
+ type NormalizedObservation,
14
+ type UnobservedEntity,
15
+ } from "../../observation";
8
16
  import { discoverComponents } from "../../components/discover";
9
17
  import { cfnDeployStacks } from "./components";
10
18
  import { affectedStacks } from "../../lifecycle/affected";
11
19
  import { rollbackToRevision } from "../../lifecycle/rollback";
12
- import { loadChantConfig } from "../../config";
20
+ import { loadChantConfig, environmentNames } from "../../config";
21
+ import { applyLiveEndpoint } from "../../live-endpoint";
13
22
  import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
14
23
  import type { CommandContext } from "../registry";
15
24
  import type { LifecycleSnapshot } from "../../lifecycle/types";
@@ -75,10 +84,11 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
75
84
  // Validate environment against config
76
85
  const projectPath = resolve(".");
77
86
  const { config } = await loadChantConfig(projectPath);
78
- if (config.environments && !config.environments.includes(environment)) {
87
+ const declaredEnvNames = environmentNames(config.environments);
88
+ if (declaredEnvNames && !declaredEnvNames.includes(environment)) {
79
89
  console.error(formatError({
80
90
  message: `Unknown environment "${environment}"`,
81
- hint: `Defined environments: ${config.environments.join(", ")}`,
91
+ hint: `Defined environments: ${declaredEnvNames.join(", ")}`,
82
92
  }));
83
93
  return 1;
84
94
  }
@@ -105,48 +115,58 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
105
115
  let anySnapshotSaved = false;
106
116
  let anyHardError = false;
107
117
 
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
- }
118
+ // #1166 same self-sufficiency as `chant graph --live`: a snapshot is
119
+ // always a live read, so an environment's declared endpoint applies here
120
+ // too, unless the ambient shell already set it.
121
+ const endpointResult = applyLiveEndpoint(config.environments, environment, observingPlugins.map((p) => p.name));
122
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
116
123
 
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;
124
+ try {
125
+ for (const target of targets) {
126
+ const label = target.stack ? `stack "${target.stack}"` : "project";
127
+ const buildResult = await build(target.root, targetSerializers);
128
+ if (buildResult.errors.length > 0) {
129
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before taking a snapshot` }));
130
+ anyHardError = true;
131
+ continue;
127
132
  }
128
- throw err;
129
- }
130
133
 
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
- }
134
+ let result;
135
+ try {
136
+ result = await takeSnapshot(environment, observingPlugins, buildResult, { stack: target.stack });
137
+ } catch (err) {
138
+ if (err instanceof StaleLifecycleBranchError) {
139
+ console.error(formatError({
140
+ message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
141
+ hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
142
+ }));
143
+ return 1;
144
+ }
145
+ throw err;
146
+ }
137
147
 
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})`));
148
+ for (const w of result.warnings) {
149
+ console.error(formatWarning({ message: w }));
150
+ }
151
+ for (const e of result.errors) {
152
+ console.error(formatError({ message: e }));
153
+ }
154
+
155
+ if (result.snapshots.length > 0) {
156
+ anySnapshotSaved = true;
157
+ const prefix = target.stack ? `${target.stack}: ` : "";
158
+ const counts = result.snapshots
159
+ .map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
160
+ .join(" ");
161
+ console.error(formatSuccess(`${prefix}Snapshot saved to chant/lifecycle (${counts})`));
162
+ }
163
+ if (result.errors.length > 0) anyHardError = true;
145
164
  }
146
- if (result.errors.length > 0) anyHardError = true;
147
- }
148
165
 
149
- return anyHardError && !anySnapshotSaved ? 1 : 0;
166
+ return anyHardError && !anySnapshotSaved ? 1 : 0;
167
+ } finally {
168
+ endpointResult.restore();
169
+ }
150
170
  }
151
171
 
152
172
  /**
@@ -263,71 +283,93 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
263
283
  const perStackJson: Record<string, unknown> = {};
264
284
  let combinedLexiconsJson: Record<string, unknown> | undefined;
265
285
  let totalDrift = 0;
286
+ let totalUnobserved = 0;
266
287
  let totalChecked = 0;
267
288
  let anyBuildError = false;
268
289
 
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
- }
290
+ // #1166 — an environment can declare its own endpoint (a local emulator like
291
+ // Floci), so `--live` is self-sufficient even when the ambient shell never
292
+ // exported e.g. AWS_ENDPOINT_URL. Ambient always wins when it's already set.
293
+ // Scoped to just the live reads below — restored in `finally`.
294
+ const liveLexicons = args.live ? plugins.filter((p) => p.describeResources || p.listArtifacts).map((p) => p.name) : [];
295
+ const endpointResult = applyLiveEndpoint(config.environments, environment, liveLexicons);
296
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
277
297
 
278
- const lexicons = lexiconFilter
279
- ? [lexiconFilter]
280
- : Array.from(buildResult.manifest.lexicons);
298
+ try {
299
+ for (const target of targets) {
300
+ const buildResult = await build(target.root, targetSerializers);
301
+ if (buildResult.errors.length > 0) {
302
+ const label = target.stack ? `stack "${target.stack}"` : "project";
303
+ console.error(formatError({ message: `Build failed for ${label} — fix errors before diffing` }));
304
+ anyBuildError = true;
305
+ continue;
306
+ }
281
307
 
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
308
+ const lexicons = lexiconFilter
309
+ ? [lexiconFilter]
310
+ : Array.from(buildResult.manifest.lexicons);
311
+
312
+ if (args.live) {
313
+ // Multi-stack component projects: observe each component's own cfn stack
314
+ // and union (same fix runGraphLive / plan use), so a deployed resource
315
+ // isn't reported "missing". Only when this target has no explicit stack.
316
+ let componentStacks: string[] = [];
317
+ if (!target.stack) {
318
+ try {
319
+ const disc = await discoverComponents(target.root, { sandbox: args.sandbox });
320
+ const set = new Set<string>();
321
+ for (const { component } of disc.components.values()) for (const s of cfnDeployStacks(component.deploy)) set.add(s);
322
+ componentStacks = [...set];
323
+ } catch {
324
+ // no components / discovery failed → single-stack observe path
325
+ }
326
+ }
327
+ const r = await runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json, stack: target.stack, componentStacks });
328
+ totalDrift += r.totalDrift;
329
+ totalUnobserved += r.totalUnobserved;
330
+ totalChecked += r.totalLexiconsChecked;
331
+ if (json) {
332
+ if (target.stack) perStackJson[target.stack] = r.byLexicon;
333
+ else combinedLexiconsJson = r.byLexicon;
295
334
  }
335
+ } else {
336
+ await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
296
337
  }
297
- const r = await runLifecycleDiffLive({ environment, lexicons, plugins, buildResult, json, stack: target.stack, componentStacks });
298
- totalDrift += r.totalDrift;
299
- totalChecked += r.totalLexiconsChecked;
338
+ }
339
+
340
+ if (args.live) {
300
341
  if (json) {
301
- if (target.stack) perStackJson[target.stack] = r.byLexicon;
302
- else combinedLexiconsJson = r.byLexicon;
342
+ // Single-stack keeps the original `{ environment, lexicons }` shape
343
+ // (behold's inspect diff, #852); multi-stack nests under `stacks`.
344
+ console.log(
345
+ JSON.stringify(
346
+ combinedLexiconsJson !== undefined
347
+ ? { environment, lexicons: combinedLexiconsJson }
348
+ : { environment, stacks: perStackJson },
349
+ ),
350
+ );
351
+ } else if (totalChecked === 0) {
352
+ console.error(formatWarning({
353
+ message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
354
+ }));
355
+ return 1;
356
+ } else if (totalDrift === 0) {
357
+ // Qualify the all-clear when part of the estate was never read (#1089):
358
+ // "no drift" over an incomplete observation is not the same claim.
359
+ console.error(
360
+ totalUnobserved > 0
361
+ ? formatWarning({
362
+ 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`,
363
+ })
364
+ : formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`),
365
+ );
303
366
  }
304
- } else {
305
- await runLifecycleDiffDigest({ environment, lexicons, buildResult, stack: target.stack });
306
367
  }
307
- }
308
368
 
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) {
321
- console.error(formatWarning({
322
- message: "No lexicons implement describeResources or listArtifacts — nothing to diff in --live mode",
323
- }));
324
- return 1;
325
- } else if (totalDrift === 0) {
326
- console.error(formatSuccess(`No drift detected across ${totalChecked} lexicon(s)`));
327
- }
369
+ return anyBuildError ? 1 : 0;
370
+ } finally {
371
+ endpointResult.restore();
328
372
  }
329
-
330
- return anyBuildError ? 1 : 0;
331
373
  }
332
374
 
333
375
  interface BetweenDiffArgs {
@@ -457,17 +499,81 @@ interface LiveDiffArgs {
457
499
  interface LiveDiffOutcome {
458
500
  byLexicon: Record<
459
501
  string,
460
- { resources?: LiveDiffResult; observed?: Record<string, ResourceMetadata>; artifacts?: LiveArtifactDiffResult }
502
+ {
503
+ resources?: LiveDiffResult;
504
+ observed?: Record<string, ResourceMetadata>;
505
+ /** Declared entities the lexicon could not read (#1089), keyed by name. */
506
+ unobserved?: Record<string, UnobservedEntity>;
507
+ artifacts?: LiveArtifactDiffResult;
508
+ }
461
509
  >;
462
510
  totalDrift: number;
511
+ /** Declared entities nobody could read. Not drift — a hole in the report. */
512
+ totalUnobserved: number;
463
513
  totalLexiconsChecked: number;
464
514
  }
465
515
 
516
+ /**
517
+ * Read one lexicon's live resources, resolving the observation tri-state
518
+ * (#1089) for every declared entity: present, confirmed-absent, or not
519
+ * observed with a reason.
520
+ *
521
+ * Two behaviours the old inline call sites did not have. A throw no longer
522
+ * skips the lexicon — every entity it was asked about comes back
523
+ * NOT-OBSERVED (`read-failed`), so a failed read shows up in the plan instead
524
+ * of vanishing from it. And a multi-stack read merges with
525
+ * present > not-observed > absent, so one unreadable stack cannot un-observe a
526
+ * resource another stack returned.
527
+ */
528
+ async function observeLexicon(
529
+ plugin: ObservationLexicon,
530
+ opts: {
531
+ environment: string;
532
+ buildOutput: string;
533
+ declared: Set<string>;
534
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
535
+ stack?: string;
536
+ componentStacks?: string[];
537
+ owned?: boolean;
538
+ },
539
+ ): Promise<NormalizedObservation> {
540
+ const entityNames = Array.from(opts.declared);
541
+ const base = {
542
+ environment: opts.environment,
543
+ buildOutput: opts.buildOutput,
544
+ entityNames,
545
+ entities: opts.entities,
546
+ ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
547
+ };
548
+ try {
549
+ if (opts.componentStacks && opts.componentStacks.length > 0) {
550
+ const parts: NormalizedObservation[] = [];
551
+ for (const stack of opts.componentStacks) {
552
+ parts.push(normalizeObservation(await plugin.describeResources!({ ...base, stack })));
553
+ }
554
+ return mergeObservations(parts);
555
+ }
556
+ return normalizeObservation(
557
+ await plugin.describeResources!({ ...base, ...(opts.stack ? { stack: opts.stack } : {}) }),
558
+ );
559
+ } catch (err) {
560
+ const message = err instanceof Error ? err.message : String(err);
561
+ console.error(formatError({
562
+ message: `${plugin.name}: describeResources failed — ${message} (reporting ${entityNames.length} declared entity(ies) as not observed, not as absent)`,
563
+ }));
564
+ return {
565
+ resources: {},
566
+ unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
567
+ };
568
+ }
569
+ }
570
+
466
571
  /** Diff current build vs live cloud for one stack. Renders the human report
467
572
  * inline; returns per-lexicon results so the caller emits the aggregate `--json`
468
573
  * once (single-stack keeps the original shape; multi-stack nests under `stacks`). */
469
574
  async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome> {
470
575
  let totalDrift = 0;
576
+ let totalUnobserved = 0;
471
577
  let totalLexiconsChecked = 0;
472
578
  const byLexicon: LiveDiffOutcome["byLexicon"] = {};
473
579
  if (!args.json && args.stack) console.log(`\n${formatBold(`■ stack ${args.stack}`)}`);
@@ -515,38 +621,27 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
515
621
 
516
622
  // ── Resources path (entity-keyed) ──────────────────────────────────────
517
623
  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
- }
624
+ const observed = await observeLexicon(plugin, {
625
+ environment: args.environment,
626
+ buildOutput,
627
+ declared,
628
+ entities,
629
+ stack: args.stack,
630
+ componentStacks: args.componentStacks,
631
+ });
632
+ const observedNow = observed.resources;
543
633
  const observedThen = prevSnapshot?.resources;
544
- const diff = diffLive({ declared, observedNow, observedThen });
634
+ const diff = diffLive({ declared, observedNow, observedThen, unobserved: observed.unobserved });
635
+ // Unobserved entities are deliberately NOT drift: a hole in the read is
636
+ // not a change in the cloud. They are reported separately (#1089) so a
637
+ // "no drift detected" line can never be built on top of a failed read.
545
638
  totalDrift += diff.driftedSinceSnapshot.length + diff.missing.length + diff.orphan.length + diff.disappeared.length;
639
+ totalUnobserved += diff.unobserved.length;
546
640
  if (args.json) {
547
641
  const entry = (byLexicon[lexiconName] ??= {});
548
642
  entry.resources = diff;
549
643
  entry.observed = observedNow; // live state (#862) — status/attributes per resource
644
+ if (Object.keys(observed.unobserved).length > 0) entry.unobserved = observed.unobserved;
550
645
  } else renderLiveDiff(lexiconName, args.environment, diff);
551
646
  lexiconChecked = true;
552
647
  }
@@ -573,21 +668,28 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
573
668
  if (lexiconChecked) totalLexiconsChecked++;
574
669
  }
575
670
 
576
- return { byLexicon, totalDrift, totalLexiconsChecked };
671
+ return { byLexicon, totalDrift, totalUnobserved, totalLexiconsChecked };
577
672
  }
578
673
 
579
674
  function renderLiveDiff(lexiconName: string, environment: string, diff: LiveDiffResult): void {
580
675
  const counts =
581
676
  `${diff.missing.length} missing, ${diff.orphan.length} orphan, ` +
582
677
  `${diff.disappeared.length} disappeared, ${diff.newlyObserved.length} newly observed, ` +
583
- `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged`;
678
+ `${diff.driftedSinceSnapshot.length} drifted, ${diff.unchanged.length} unchanged` +
679
+ (diff.unobserved.length > 0 ? `, ${diff.unobserved.length} unobserved` : "");
584
680
 
585
681
  console.log(`\n${formatBold(lexiconName)} — environment: ${environment}`);
586
682
  console.log(counts);
587
683
  console.log("-".repeat(80));
588
684
 
685
+ if (diff.unobserved.length > 0) {
686
+ console.log(formatBold("\nUNOBSERVED (declared; chant could not read live state — status unknown):"));
687
+ for (const u of diff.unobserved) {
688
+ console.log(` ? ${formatUnobserved(u.name, u)}`);
689
+ }
690
+ }
589
691
  if (diff.missing.length > 0) {
590
- console.log(formatBold("\nMISSING (declared, not in cloud):"));
692
+ console.log(formatBold("\nMISSING (declared, provider reports not in cloud):"));
591
693
  for (const name of diff.missing) console.log(` - ${name}`);
592
694
  }
593
695
  if (diff.orphan.length > 0) {
@@ -662,9 +764,11 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
662
764
  * chant lifecycle plan <environment> [lexicon]
663
765
  *
664
766
  * 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`.
767
+ * create / update / delete / adopt / noop / unobserved. Strictly read-only —
768
+ * never mutates, never deploys. Deletes are never proposed without ownership
769
+ * data (added in #121); an undeclared live resource is `adopt`; a declared
770
+ * entity chant could not read is `unobserved` and gets no proposal at all
771
+ * (#1089) — a create is only ever proposed against a confirmed absence.
668
772
  */
669
773
  export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
670
774
  const { args, plugins, serializers } = ctx;
@@ -709,71 +813,70 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
709
813
  const merged: ChangeSet = { env: environment, entries: [] };
710
814
  let checked = 0;
711
815
 
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
- }
816
+ // #1166 same self-sufficiency as `chant graph --live`: an environment can
817
+ // declare its own endpoint, applied here unless the ambient shell already
818
+ // set it. `chant lifecycle plan` is always a live read (no `--live` flag of
819
+ // its own), so this applies unconditionally.
820
+ const endpointResult = applyLiveEndpoint(config.environments, environment, lexicons);
821
+ if (endpointResult.notice) console.error(formatWarning({ message: endpointResult.notice }));
724
822
 
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
- });
823
+ try {
824
+ for (const lexiconName of lexicons) {
825
+ const plugin = plugins.find((p) => p.name === lexiconName);
826
+ if (!plugin) continue;
827
+ if (!plugin.describeResources) {
828
+ // Plan is entity-keyed; artifact-only lexicons have no declared axis.
829
+ if (!args.json) {
830
+ console.error(formatWarning({
831
+ message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
832
+ }));
833
+ }
834
+ continue;
734
835
  }
735
- }
736
-
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
836
 
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
- );
837
+ const declared = new Set<string>();
838
+ const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
839
+ for (const [name, entity] of buildResult.entities) {
840
+ if (entity.lexicon === lexiconName) {
841
+ declared.add(name);
842
+ entities.set(name, {
843
+ entityType: entity.entityType,
844
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
845
+ });
754
846
  }
755
- } else {
756
- observedNow = await plugin.describeResources({
757
- environment,
758
- buildOutput,
759
- entityNames: Array.from(declared),
760
- entities,
761
- owned: args.owned,
762
- });
763
847
  }
764
- } catch (err) {
765
- console.error(formatError({
766
- message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
767
- }));
768
- continue;
769
- }
770
848
 
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++;
849
+ const rawOutput = buildResult.outputs.get(lexiconName);
850
+ const buildOutput =
851
+ rawOutput === undefined
852
+ ? ""
853
+ : typeof rawOutput === "string"
854
+ ? rawOutput
855
+ : (rawOutput as SerializerResult).primary;
856
+
857
+ const observed = await observeLexicon(plugin, {
858
+ environment,
859
+ buildOutput,
860
+ declared,
861
+ entities,
862
+ componentStacks,
863
+ owned: args.owned,
864
+ });
865
+
866
+ const content = await readSnapshot(environment, lexiconName);
867
+ const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
868
+
869
+ const cs = buildChangeSet(environment, {
870
+ declared,
871
+ observedNow: observed.resources,
872
+ observedThen,
873
+ unobserved: observed.unobserved,
874
+ });
875
+ merged.entries.push(...cs.entries);
876
+ checked++;
877
+ }
878
+ } finally {
879
+ endpointResult.restore();
777
880
  }
778
881
 
779
882
  if (checked === 0) {
@@ -785,6 +888,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
785
888
 
786
889
  merged.entries.sort((a, b) => a.name.localeCompare(b.name));
787
890
 
891
+ // Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
892
+ // shapes have no room for it) still learn the plan has a hole (#1089).
893
+ const unobservedCount = summarize(merged).unobserved;
894
+ if (unobservedCount > 0) {
895
+ console.error(formatWarning({
896
+ message: `${unobservedCount} declared entity(ies) could not be observed — no create/update/delete is proposed for them. This plan is incomplete, not clean.`,
897
+ }));
898
+ }
899
+
788
900
  // `--report gitlab-mr` emits the GitLab MR plan-widget artifact instead of the
789
901
  // human render. Write it to a file (`tfplan.json`) in CI and declare it as
790
902
  // `artifacts:reports:terraform` to light up the merge-request widget.