@intentius/chant 0.28.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 (75) 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/config.d.ts +46 -4
  6. package/dist/config.d.ts.map +1 -1
  7. package/dist/discovery/fold-import.d.ts +153 -17
  8. package/dist/discovery/fold-import.d.ts.map +1 -1
  9. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  10. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  11. package/dist/env.d.ts +5 -2
  12. package/dist/env.d.ts.map +1 -1
  13. package/dist/fold/fold.d.ts +12 -0
  14. package/dist/fold/fold.d.ts.map +1 -1
  15. package/dist/graph-ir.d.ts +29 -4
  16. package/dist/graph-ir.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/kubectl-context.d.ts +27 -0
  20. package/dist/kubectl-context.d.ts.map +1 -1
  21. package/dist/lexicon.d.ts +31 -6
  22. package/dist/lexicon.d.ts.map +1 -1
  23. package/dist/lifecycle/change-set.d.ts +26 -5
  24. package/dist/lifecycle/change-set.d.ts.map +1 -1
  25. package/dist/lifecycle/live-diff.d.ts +25 -1
  26. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  27. package/dist/lifecycle/observe.d.ts +4 -2
  28. package/dist/lifecycle/observe.d.ts.map +1 -1
  29. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  30. package/dist/lifecycle/status.d.ts +26 -1
  31. package/dist/lifecycle/status.d.ts.map +1 -1
  32. package/dist/lifecycle/types.d.ts +8 -0
  33. package/dist/lifecycle/types.d.ts.map +1 -1
  34. package/dist/live-endpoint.d.ts +92 -0
  35. package/dist/live-endpoint.d.ts.map +1 -0
  36. package/dist/observation.d.ts +123 -0
  37. package/dist/observation.d.ts.map +1 -0
  38. package/dist/stack-output.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/cli/handlers/components.test.ts +63 -4
  41. package/src/cli/handlers/components.ts +78 -35
  42. package/src/cli/handlers/graph.test.ts +69 -6
  43. package/src/cli/handlers/graph.ts +61 -27
  44. package/src/cli/handlers/lifecycle.test.ts +285 -6
  45. package/src/cli/handlers/lifecycle.ts +297 -185
  46. package/src/config.test.ts +75 -0
  47. package/src/config.ts +61 -3
  48. package/src/discovery/fold-composite.test.ts +594 -0
  49. package/src/discovery/fold-import.ts +987 -43
  50. package/src/discovery/sandbox/config-wire.ts +3 -2
  51. package/src/env.test.ts +12 -0
  52. package/src/env.ts +12 -4
  53. package/src/fold/fold.ts +12 -2
  54. package/src/graph-ir-live.test.ts +28 -1
  55. package/src/graph-ir.ts +68 -12
  56. package/src/index.ts +1 -0
  57. package/src/kubectl-context.ts +81 -0
  58. package/src/lexicon.ts +41 -6
  59. package/src/lifecycle/change-set.test.ts +93 -1
  60. package/src/lifecycle/change-set.ts +65 -13
  61. package/src/lifecycle/live-diff.test.ts +39 -0
  62. package/src/lifecycle/live-diff.ts +51 -5
  63. package/src/lifecycle/observe.test.ts +74 -3
  64. package/src/lifecycle/observe.ts +82 -22
  65. package/src/lifecycle/snapshot.test.ts +39 -1
  66. package/src/lifecycle/snapshot.ts +34 -9
  67. package/src/lifecycle/status.test.ts +89 -8
  68. package/src/lifecycle/status.ts +53 -3
  69. package/src/lifecycle/types.ts +8 -0
  70. package/src/live-endpoint.test.ts +115 -0
  71. package/src/live-endpoint.ts +148 -0
  72. package/src/observation.test.ts +96 -0
  73. package/src/observation.ts +213 -0
  74. package/src/stack-output.test.ts +55 -0
  75. package/src/stack-output.ts +41 -20
@@ -9,6 +9,7 @@ import type { LifecycleSnapshot } from "./types";
9
9
  import { computeBuildDigest } from "./digest";
10
10
  import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
11
11
  import { sortedJsonReplacer } from "../utils";
12
+ import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
12
13
 
13
14
  /** Patterns in attribute names that suggest sensitive data. */
14
15
  const SENSITIVE_PATTERNS = [
@@ -124,22 +125,32 @@ export async function takeSnapshot(
124
125
 
125
126
  let resources: Record<string, ResourceMetadata> = {};
126
127
  let artifacts: Record<string, ArtifactMetadata> = {};
128
+ let unobserved: Record<string, UnobservedEntity> = {};
127
129
 
128
130
  try {
129
131
  if (plugin.describeResources) {
130
- const raw = await plugin.describeResources({
131
- environment,
132
- buildOutput,
133
- entityNames,
134
- entities,
135
- stack,
136
- });
137
- const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
132
+ const observed = normalizeObservation(
133
+ await plugin.describeResources({
134
+ environment,
135
+ buildOutput,
136
+ entityNames,
137
+ entities,
138
+ stack,
139
+ }),
140
+ );
141
+ const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
138
142
  warnings.push(...validationWarnings);
139
143
  if (dropped.length > 0) {
140
144
  warnings.push(`${plugin.name}: dropped ${dropped.length} invalid resource(s)`);
141
145
  }
142
146
  resources = valid;
147
+ // Record the holes (#1089). A snapshot is evidence of what was seen; an
148
+ // entity nobody could read must not be recorded as "was not there",
149
+ // because the next diff would then read it back as absent.
150
+ unobserved = observed.unobserved;
151
+ for (const [name, entry] of Object.entries(unobserved)) {
152
+ warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
153
+ }
143
154
  }
144
155
 
145
156
  if (plugin.listArtifacts) {
@@ -153,7 +164,12 @@ export async function takeSnapshot(
153
164
  }
154
165
 
155
166
  if (Object.keys(resources).length === 0 && Object.keys(artifacts).length === 0) {
156
- errors.push(`${plugin.name}: no valid resources or artifacts returned`);
167
+ const unreadable = Object.keys(unobserved).length;
168
+ errors.push(
169
+ unreadable > 0
170
+ ? `${plugin.name}: nothing observed — ${unreadable} declared entity(ies) could not be read (see warnings); not snapshotting an unread environment as empty`
171
+ : `${plugin.name}: no valid resources or artifacts returned`,
172
+ );
157
173
  continue;
158
174
  }
159
175
 
@@ -164,15 +180,24 @@ export async function takeSnapshot(
164
180
  commit: headCommit,
165
181
  timestamp,
166
182
  resources,
183
+ ...(Object.keys(unobserved).length > 0 && { unobserved }),
167
184
  ...(Object.keys(artifacts).length > 0 && { artifacts }),
168
185
  digest,
169
186
  };
170
187
 
171
188
  snapshots.push(snapshot);
172
189
  } catch (err) {
190
+ // A thrown read is not an empty environment — record nothing and say so
191
+ // (#1089). Writing a snapshot here would persist "none of this exists".
173
192
  errors.push(
174
193
  `${plugin.name}: ${err instanceof Error ? err.message : String(err)}`,
175
194
  );
195
+ const message = err instanceof Error ? err.message : String(err);
196
+ for (const [name, entry] of Object.entries(
197
+ unobservedAll(entityNames, "read-failed", message, entities),
198
+ )) {
199
+ warnings.push(`${plugin.name}: not observed — ${formatUnobserved(name, entry)}`);
200
+ }
176
201
  }
177
202
  }
178
203
 
@@ -32,8 +32,8 @@ describe("status", () => {
32
32
  const cs: ChangeSet = {
33
33
  env: "prod",
34
34
  entries: [
35
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
36
- { name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true }, ownership: "foreign" },
35
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
36
+ { name: "orphan-thing", type: "T", action: "adopt", evidence: { declared: false, inSnapshot: false, live: true, observed: true }, ownership: "foreign" },
37
37
  ],
38
38
  };
39
39
  const evidence = liveEvidenceFromChangeSet(cs);
@@ -49,7 +49,7 @@ describe("status", () => {
49
49
  const cs: ChangeSet = {
50
50
  env: "prod",
51
51
  entries: [
52
- { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
52
+ { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
53
53
  ],
54
54
  };
55
55
  const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
@@ -63,7 +63,7 @@ describe("status", () => {
63
63
  const cs: ChangeSet = {
64
64
  env: "prod",
65
65
  entries: [
66
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
66
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
67
67
  ],
68
68
  };
69
69
  const mapping: LiveNameMapping = new Map([["some-other-component", ["renamed-thing"]]]);
@@ -75,8 +75,8 @@ describe("status", () => {
75
75
  const cs: ChangeSet = {
76
76
  env: "prod",
77
77
  entries: [
78
- { name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
79
- { name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
78
+ { name: "cluster-node-1", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
79
+ { name: "cluster-node-2", type: "T", action: "update", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
80
80
  ],
81
81
  };
82
82
  const mapping: LiveNameMapping = new Map([["neo4j-cluster", ["cluster-node-1", "cluster-node-2"]]]);
@@ -297,7 +297,7 @@ describe("status", () => {
297
297
  const cs: ChangeSet = {
298
298
  env: "prod",
299
299
  entries: [
300
- { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
300
+ { name: "search-service-v2", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
301
301
  ],
302
302
  };
303
303
  const mapping: LiveNameMapping = new Map([["search-svc", ["search-service-v2"]]]);
@@ -311,7 +311,7 @@ describe("status", () => {
311
311
  const cs: ChangeSet = {
312
312
  env: "prod",
313
313
  entries: [
314
- { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true }, ownership: "owned" },
314
+ { name: "search-service", type: "T", action: "noop", evidence: { declared: true, inSnapshot: true, live: true, observed: true }, ownership: "owned" },
315
315
  ],
316
316
  };
317
317
  const liveEvidence = liveEvidenceFromChangeSet(cs);
@@ -381,5 +381,86 @@ describe("status", () => {
381
381
  const merged = mergeLiveEvidence(undefined, supplement);
382
382
  expect(merged.get("c")).toEqual({ live: true, ownership: "owned", action: undefined });
383
383
  });
384
+
385
+ test("a direct stack observation clears an inherited hole; a failing one keeps it (#1089)", () => {
386
+ const base = new Map<string, LiveComponentEvidence>([
387
+ ["a", { live: false, unobserved: { reason: "read-failed" } }],
388
+ ["b", { live: false, unobserved: { reason: "read-failed" } }],
389
+ ]);
390
+ const supplement = new Map<string, LiveComponentEvidence>([
391
+ ["a", { live: true, ownership: "owned" }],
392
+ ["b", { live: false, unobserved: { reason: "read-failed", detail: "no determinate status" } }],
393
+ ]);
394
+ const merged = mergeLiveEvidence(base, supplement);
395
+ expect(merged.get("a")!.unobserved).toBeUndefined();
396
+ expect(merged.get("b")!.unobserved?.reason).toBe("read-failed");
397
+ });
398
+ });
399
+
400
+ // ── The observation tri-state reaches the status join (#1089) ─────────────
401
+
402
+ describe("not-observed never becomes 'stale' (#1089)", () => {
403
+ const record = {
404
+ version: 1,
405
+ component: "search-svc",
406
+ env: "prod",
407
+ digest: "sha256:abc",
408
+ gitSha: "g",
409
+ runId: "r",
410
+ timestamp: "2026-01-01T00:00:00Z",
411
+ actor: "ci",
412
+ } as const;
413
+
414
+ test("a recorded component whose live state could not be read reports unknown", () => {
415
+ const rows = reconcileStatus("prod", [record], {
416
+ liveEvidence: new Map<string, LiveComponentEvidence>([
417
+ ["search-svc", { live: false, unobserved: { reason: "no-binding", detail: "no kubectl context" } }],
418
+ ]),
419
+ });
420
+ expect(rows[0].reconciliation).toBe("unknown");
421
+ expect(rows[0].detail).toContain("could not be observed");
422
+ expect(rows[0].detail).toContain("no kubectl context");
423
+ // `live` is omitted entirely — `false` would read as "not deployed".
424
+ expect(rows[0].live).toBeUndefined();
425
+ expect(rows[0].unobserved).toEqual({ reason: "no-binding", detail: "no kubectl context" });
426
+ });
427
+
428
+ test("the same component, actually observed absent, still reports stale", () => {
429
+ const rows = reconcileStatus("prod", [record], {
430
+ liveEvidence: new Map<string, LiveComponentEvidence>([["search-svc", { live: false }]]),
431
+ });
432
+ expect(rows[0].reconciliation).toBe("stale");
433
+ expect(rows[0].live).toBe(false);
434
+ });
435
+
436
+ test("an unrecorded component that could not be read is unknown, not unrecorded", () => {
437
+ const rows = reconcileStatus("prod", [], {
438
+ allComponents: ["search-svc"],
439
+ liveEvidence: new Map<string, LiveComponentEvidence>([
440
+ ["search-svc", { live: false, unobserved: { reason: "read-failed" } }],
441
+ ]),
442
+ });
443
+ expect(rows[0].reconciliation).toBe("unknown");
444
+ });
445
+
446
+ test("liveEvidenceFromChangeSet carries the plan's unobserved verdict", () => {
447
+ const evidence = liveEvidenceFromChangeSet({
448
+ env: "prod",
449
+ entries: [
450
+ {
451
+ name: "search-svc",
452
+ action: "unobserved",
453
+ evidence: { declared: true, inSnapshot: false, live: false, observed: false },
454
+ ownership: "unknown",
455
+ unobservedReason: "unsupported-kind",
456
+ unobservedDetail: "no reader",
457
+ },
458
+ ],
459
+ });
460
+ expect(evidence.get("search-svc")!.unobserved).toEqual({
461
+ reason: "unsupported-kind",
462
+ detail: "no reader",
463
+ });
464
+ });
384
465
  });
385
466
  });
@@ -21,6 +21,9 @@
21
21
  * lower-confidence signal rather than silently treated as "reconciled".
22
22
  * - **reconciled** — a release record exists and live evidence (via
23
23
  * ownership) confirms the component is present and owned by chant.
24
+ * - **unknown** — live evidence was requested and could not be read (#1089),
25
+ * or was not requested at all. A component chant could not observe is never
26
+ * reported `stale`: "the read failed" and "it is gone" are different facts.
24
27
  *
25
28
  * This is deliberately a light-touch reconciliation: chant's lexicons report
26
29
  * resource-level status (`ResourceMetadata`), not "the digest currently
@@ -36,6 +39,7 @@
36
39
  import type { ChangeSet, ChangeAction } from "./change-set";
37
40
  import { latestPerComponent, type ReleaseRecord } from "./release-ledger";
38
41
  import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger";
42
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
39
43
 
40
44
  /** One row of `chant components status [env]` — the per-component join of recorded vs live. */
41
45
  export interface ComponentStatusRow {
@@ -63,9 +67,18 @@ export interface ComponentStatusRow {
63
67
  /**
64
68
  * Machine-readable "observed live", when live evidence was gathered (`--live`).
65
69
  * A consumer joining this row onto a graph node should read this rather than
66
- * string-matching `detail`. Absent when `--live` was not requested.
70
+ * string-matching `detail`. Absent when `--live` was not requested — and, since
71
+ * #1089, also absent when live state could not be read at all: `false` means
72
+ * "looked, not there", never "did not look". Read {@link unobserved} for that
73
+ * case; a consumer that treats absent as "unknown" already handles it.
67
74
  */
68
75
  live?: boolean;
76
+ /**
77
+ * Set when `--live` was requested and the observation could not read this
78
+ * component (#1089). `live` is absent alongside it and `reconciliation` is
79
+ * `unknown` — the row reports a hole rather than a verdict.
80
+ */
81
+ unobserved?: { reason: UnobservedReason; detail?: string };
69
82
  /**
70
83
  * The owning deploy unit's raw status, when a lexicon reported it (AWS: the
71
84
  * component's own CFN stack via `describeStackStatus`). Lets a renderer paint a
@@ -103,6 +116,12 @@ export interface ComponentStatusResult {
103
116
  export interface LiveComponentEvidence {
104
117
  /** True if this component name was observed live at all (declared+live, or orphan+live). */
105
118
  live: boolean;
119
+ /**
120
+ * Set when the observation could not read this component (#1089). `live` is
121
+ * `false` alongside it, but only because the boolean has nowhere else to go —
122
+ * every consumer must branch on this field before believing `live: false`.
123
+ */
124
+ unobserved?: { reason: UnobservedReason; detail?: string };
106
125
  /** The `ChangeSet` action chant's existing plan logic assigned, when the component maps to a tracked entity/resource name. */
107
126
  action?: ChangeAction;
108
127
  /** Ownership verdict, when known. */
@@ -132,6 +151,10 @@ export function mergeLiveEvidence(
132
151
  const b = merged.get(component);
133
152
  merged.set(component, {
134
153
  live: sup.live,
154
+ // A direct stack observation that succeeded answers the question the
155
+ // change-set axis could not — so it clears an inherited "not observed".
156
+ // A supplement that itself could not read keeps the hole.
157
+ ...(sup.unobserved ? { unobserved: sup.unobserved } : {}),
135
158
  ownership: sup.ownership ?? b?.ownership,
136
159
  action: b?.action,
137
160
  stack: sup.stack ?? b?.stack,
@@ -178,8 +201,12 @@ function mergeEvidence(entries: LiveComponentEvidence[]): LiveComponentEvidence
178
201
  const action = entries.some((e) => e.action === "update")
179
202
  ? "update"
180
203
  : entries.find((e) => e.action !== undefined)?.action;
204
+ // A component whose entities were partly readable is still partly unknown, so
205
+ // any unobserved entity keeps the hole — unless something under it was
206
+ // actually seen live, which already answers "is this deployed".
207
+ const unobserved = live ? undefined : entries.find((e) => e.unobserved)?.unobserved;
181
208
 
182
- return { live, ownership, action };
209
+ return { live, ownership, action, ...(unobserved ? { unobserved } : {}) };
183
210
  }
184
211
 
185
212
  /**
@@ -204,6 +231,16 @@ export function liveEvidenceFromChangeSet(
204
231
  live: entry.evidence.live,
205
232
  action: entry.action,
206
233
  ownership: entry.ownership,
234
+ // Carry the plan's "could not look" verdict through so the status join
235
+ // reports a hole instead of reading `live: false` as "gone" (#1089).
236
+ ...(entry.action === "unobserved" && entry.unobservedReason
237
+ ? {
238
+ unobserved: {
239
+ reason: entry.unobservedReason,
240
+ ...(entry.unobservedDetail ? { detail: entry.unobservedDetail } : {}),
241
+ },
242
+ }
243
+ : {}),
207
244
  });
208
245
  }
209
246
 
@@ -275,6 +312,15 @@ export function reconcileStatus(
275
312
  detail = recorded
276
313
  ? "recorded; live status not queried (pass --live to reconcile)"
277
314
  : "no release record found";
315
+ } else if (evidence?.unobserved) {
316
+ // Live evidence was requested and could not be read (#1089). Neither
317
+ // `stale` (which claims the component is gone) nor `reconciled` is
318
+ // supportable — the honest verdict is that nothing is known.
319
+ reconciliation = "unknown";
320
+ const why = `${unobservedReasonText(evidence.unobserved.reason)}${evidence.unobserved.detail ? `: ${evidence.unobserved.detail}` : ""}`;
321
+ detail = recorded
322
+ ? `recorded ${recorded.timestamp} (digest ${recorded.digest}), but live state could not be observed — ${why}`
323
+ : `no release record, and live state could not be observed — ${why}`;
278
324
  } else if (!recorded && evidence?.live) {
279
325
  reconciliation = "unrecorded";
280
326
  detail = `live${evidence.ownership === "owned" ? " and chant-owned" : ""}, but no release record exists — deployed outside the recorded path`;
@@ -300,7 +346,11 @@ export function reconcileStatus(
300
346
  componentBom,
301
347
  reconciliation,
302
348
  detail,
303
- ...(liveEvidence ? { live: !!evidence?.live } : {}),
349
+ // `live` is only emitted when it is a real answer: requested AND read.
350
+ // An unread component leaves it absent (= unknown to every consumer)
351
+ // rather than reporting `false`, which would read as "not deployed".
352
+ ...(liveEvidence && !evidence?.unobserved ? { live: !!evidence?.live } : {}),
353
+ ...(evidence?.unobserved ? { unobserved: evidence.unobserved } : {}),
304
354
  ...(evidence?.stack ? { stack: evidence.stack } : {}),
305
355
  });
306
356
  }
@@ -1,4 +1,5 @@
1
1
  import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
2
+ import type { UnobservedEntity } from "../observation";
2
3
 
3
4
  export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
4
5
 
@@ -18,6 +19,13 @@ export interface LifecycleSnapshot {
18
19
  timestamp: string;
19
20
  /** Resource metadata keyed by logical name */
20
21
  resources: Record<string, ResourceMetadata>;
22
+ /**
23
+ * Declared entities this observation could not read (#1089), keyed by logical
24
+ * name. Additive and optional: a snapshot without it observed everything it
25
+ * was asked about. Present so a later diff can tell "was not there when the
26
+ * snapshot was taken" from "was never looked at".
27
+ */
28
+ unobserved?: Record<string, UnobservedEntity>;
21
29
  /** Artifact metadata keyed by server-side identifier (lexicon-specific). */
22
30
  artifacts?: Record<string, ArtifactMetadata>;
23
31
  /** Build digest at snapshot time — what was declared when this snapshot was taken */
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { applyLiveEndpoint, zeroResourcesWarning, LEXICON_ENDPOINT_ENV_VAR } from "./live-endpoint";
3
+ import type { EnvironmentDeclaration } from "./config";
4
+
5
+ describe("applyLiveEndpoint (#1166)", () => {
6
+ test("no-op — and no notice — when the environment declares no endpoint at all", () => {
7
+ const env: NodeJS.ProcessEnv = {};
8
+ const result = applyLiveEndpoint(["floci", "prod"], "floci", ["aws"], env);
9
+ expect(result.notice).toBeUndefined();
10
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
11
+ result.restore(); // always safe, even as a no-op
12
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
13
+ });
14
+
15
+ test("applies the declared endpoint to the ambient var of every observing lexicon that has one", () => {
16
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
17
+ const env: NodeJS.ProcessEnv = {};
18
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
19
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
20
+ expect(result.notice).toMatch(/environment "floci" declares endpoint http:\/\/localhost:4566/);
21
+ expect(result.notice).toMatch(/AWS_ENDPOINT_URL/);
22
+ });
23
+
24
+ test("restore() removes exactly what it set, not a pre-existing value it didn't touch", () => {
25
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
26
+ const env: NodeJS.ProcessEnv = {};
27
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
28
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
29
+ result.restore();
30
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
31
+ });
32
+
33
+ test("ambient wins: an already-set var is left untouched, and the notice says so", () => {
34
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
35
+ const env: NodeJS.ProcessEnv = { AWS_ENDPOINT_URL: "http://real-endpoint.example" };
36
+ const result = applyLiveEndpoint(environments, "floci", ["aws"], env);
37
+ expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // unchanged
38
+ expect(result.notice).toMatch(/ambient AWS_ENDPOINT_URL already set/);
39
+ result.restore();
40
+ expect(env.AWS_ENDPOINT_URL).toBe("http://real-endpoint.example"); // restore never touches what it didn't set
41
+ });
42
+
43
+ test("a bare-string environment entry has no endpoint to apply", () => {
44
+ const env: NodeJS.ProcessEnv = {};
45
+ const result = applyLiveEndpoint(["floci"], "floci", ["aws"], env);
46
+ expect(result.notice).toBeUndefined();
47
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
48
+ });
49
+
50
+ test("only applies to lexicons actually observing, and only those with a known endpoint var", () => {
51
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
52
+ const env: NodeJS.ProcessEnv = {};
53
+ // k8s has no ambient-var knob (config-resolved instead) — nothing to set.
54
+ const result = applyLiveEndpoint(environments, "floci", ["k8s"], env);
55
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
56
+ expect(result.notice).toBeUndefined();
57
+ });
58
+
59
+ test("applies to fly's FLY_FLAPS_BASE_URL too, when fly is among the observing lexicons", () => {
60
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
61
+ const env: NodeJS.ProcessEnv = {};
62
+ const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
63
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566");
64
+ expect(env.FLY_FLAPS_BASE_URL).toBe("http://localhost:4566");
65
+ result.restore();
66
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
67
+ expect(env.FLY_FLAPS_BASE_URL).toBeUndefined();
68
+ });
69
+
70
+ test("mixed: one lexicon's var is applied, another's ambient value wins — both show up in the notice", () => {
71
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
72
+ const env: NodeJS.ProcessEnv = { FLY_FLAPS_BASE_URL: "http://real-fly.example" };
73
+ const result = applyLiveEndpoint(environments, "floci", ["aws", "fly"], env);
74
+ expect(env.AWS_ENDPOINT_URL).toBe("http://localhost:4566"); // applied
75
+ expect(env.FLY_FLAPS_BASE_URL).toBe("http://real-fly.example"); // ambient wins
76
+ expect(result.notice).toMatch(/applied to AWS_ENDPOINT_URL/);
77
+ expect(result.notice).toMatch(/ambient FLY_FLAPS_BASE_URL already set/);
78
+ });
79
+
80
+ test("audited endpoint-knob registry: only aws and fly (gcp/k8s/azure/temporal resolve via config, not an ambient var)", () => {
81
+ expect(LEXICON_ENDPOINT_ENV_VAR).toEqual({ aws: "AWS_ENDPOINT_URL", fly: "FLY_FLAPS_BASE_URL" });
82
+ });
83
+
84
+ test("a name that isn't declared at all has no endpoint to apply", () => {
85
+ const environments: EnvironmentDeclaration[] = [{ name: "floci", endpoint: "http://localhost:4566" }];
86
+ const env: NodeJS.ProcessEnv = {};
87
+ const result = applyLiveEndpoint(environments, "prod", ["aws"], env);
88
+ expect(result.notice).toBeUndefined();
89
+ expect(env.AWS_ENDPOINT_URL).toBeUndefined();
90
+ });
91
+ });
92
+
93
+ describe("zeroResourcesWarning (#1166)", () => {
94
+ test("undefined when nothing was declared to look for", () => {
95
+ expect(zeroResourcesWarning("aws", "floci", 0, { resources: {}, unobserved: {} })).toBeUndefined();
96
+ });
97
+
98
+ test("undefined when resources were actually observed", () => {
99
+ expect(
100
+ zeroResourcesWarning("aws", "floci", 2, { resources: { a: {} }, unobserved: {} }),
101
+ ).toBeUndefined();
102
+ });
103
+
104
+ test("undefined when the emptiness is already explained by #1089 unobserved", () => {
105
+ expect(
106
+ zeroResourcesWarning("aws", "floci", 2, { resources: {}, unobserved: { a: { reason: "no-binding" } } }),
107
+ ).toBeUndefined();
108
+ });
109
+
110
+ test("warns with declared count and the check-endpoint hint when truly empty and unexplained", () => {
111
+ expect(zeroResourcesWarning("aws", "floci", 3, { resources: {}, unobserved: {} })).toBe(
112
+ 'aws: 0 live resources for env "floci" (3 declared) — check the endpoint/credentials',
113
+ );
114
+ });
115
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * chant #1166 — let a declared `environment` carry its own endpoint so
3
+ * `--live --env <name>` is self-sufficient.
4
+ *
5
+ * The bug this closes: `chant graph --live --env floci` (and `lifecycle
6
+ * diff`/`plan`) observe a stack by shelling out through each lexicon's
7
+ * `describeResources()`. For AWS that shell-out honors the ambient
8
+ * `AWS_ENDPOINT_URL` env var — when a project's `floci` environment is a local
9
+ * emulator (`http://localhost:4566`) but the invoking shell never exported
10
+ * that var, the AWS CLI silently targets real AWS instead. The stack named
11
+ * after the environment doesn't exist there, so `describeResources` hits its
12
+ * `stackDoesNotExist` branch and returns an empty, unremarkable "nothing is
13
+ * deployed" — indistinguishable from the truthful answer. This cost real
14
+ * debugging time validating #1162's live overlay: the observation code was
15
+ * right, the manual repro just never set the var.
16
+ *
17
+ * The fix: `environments` in `chant.config.ts` can name an endpoint per
18
+ * environment (`config.ts`'s `EnvironmentDeclaration`), and {@link
19
+ * applyLiveEndpoint} injects it into the ambient env var each observing
20
+ * lexicon's CLI shell-out actually reads — but only for a var that isn't
21
+ * already set. Ambient always wins: a shell that already exports
22
+ * `AWS_ENDPOINT_URL` sees no change in behavior.
23
+ *
24
+ * Audited (#1166) which lexicons have an ambient-env-var endpoint knob at
25
+ * all, since that's the specific footgun — a lexicon whose environment
26
+ * binding is resolved from `chant.config` itself (not an ambient var) has
27
+ * nothing to inject here:
28
+ *
29
+ * - **aws** — `AWS_ENDPOINT_URL`, read directly by
30
+ * `lexicons/aws/src/components/cloud-executor.ts` / `plugin.ts` before
31
+ * every `aws …` shell-out (`applyAwsEndpoint`/`applyAwsEndpointArgv`).
32
+ * - **fly** — `FLY_FLAPS_BASE_URL`, read by `resolveEndpoint()` in
33
+ * `lexicons/fly/src/op/activities/fly-apply.ts`, the same seam
34
+ * `describeResources` (`../describe-resources.ts`) calls through.
35
+ * - **gcp**, **k8s** — resolve their live target from `chant.config` itself
36
+ * (`k8s.profiles.<env>.context` via `resolveClusterTarget`,
37
+ * `packages/core/src/kubectl-context.ts`), not an ambient var. Nothing to
38
+ * inject: the config *is* the binding already.
39
+ * - **azure** — resolves via the `az` CLI's own logged-in
40
+ * subscription/session context; no ambient endpoint var exists to miss.
41
+ * - **temporal** — resolves its connection from `temporal.profiles.<env>`
42
+ * (`resolveProfile`, `lexicons/temporal/src/describe-resources.ts`), the
43
+ * same "config is the binding" shape as k8s/gcp.
44
+ */
45
+
46
+ import { environmentEndpoint, type EnvironmentDeclaration } from "./config";
47
+
48
+ /**
49
+ * Per-lexicon ambient env var a `--live` read honors for its endpoint. Only
50
+ * lexicons with a genuine ambient-var footgun are listed — see the module doc
51
+ * for the full audit (gcp/k8s/azure/temporal resolve their target from
52
+ * `chant.config` instead, so they have nothing to inject).
53
+ */
54
+ export const LEXICON_ENDPOINT_ENV_VAR: Record<string, string> = {
55
+ aws: "AWS_ENDPOINT_URL",
56
+ fly: "FLY_FLAPS_BASE_URL",
57
+ };
58
+
59
+ /** Result of {@link applyLiveEndpoint} — always call `restore()`, even when nothing was applied (it is then a no-op). */
60
+ export interface AppliedEndpoint {
61
+ /**
62
+ * One line describing what happened, or `undefined` when the environment
63
+ * declares no endpoint at all (nothing to say). Present whether the
64
+ * declared endpoint was applied OR an ambient var already won — #1166's
65
+ * "no silent anything" stance: an operator should never have to guess which
66
+ * target a `--live` read actually used.
67
+ */
68
+ notice?: string;
69
+ /** Undo whatever ambient env vars this call set. Always safe to call. */
70
+ restore: () => void;
71
+ }
72
+
73
+ /**
74
+ * Resolve `environment`'s declared endpoint (if any) from `config.environments`
75
+ * and apply it to the ambient env var of every lexicon in `lexicons` that has
76
+ * one ({@link LEXICON_ENDPOINT_ENV_VAR}) — but only when that var isn't
77
+ * already set. Ambient always wins (#1166): behavior for a shell that already
78
+ * exports `AWS_ENDPOINT_URL` is unchanged.
79
+ *
80
+ * Call before a `--live` describe/enrich pass; `restore()` in a `finally` so
81
+ * the injected value never leaks into a later invocation in the same process
82
+ * (tests, or a long-lived host like the MCP server).
83
+ */
84
+ export function applyLiveEndpoint(
85
+ environments: EnvironmentDeclaration[] | undefined,
86
+ environment: string,
87
+ lexicons: readonly string[],
88
+ env: NodeJS.ProcessEnv = process.env,
89
+ ): AppliedEndpoint {
90
+ const endpoint = environmentEndpoint(environments, environment);
91
+ if (!endpoint) return { restore: () => {} };
92
+
93
+ const applied: string[] = [];
94
+ const overridden: string[] = [];
95
+ const seen = new Set<string>(); // a var shared by two lexicons is only reported once
96
+ for (const lexicon of lexicons) {
97
+ const varName = LEXICON_ENDPOINT_ENV_VAR[lexicon];
98
+ if (!varName || seen.has(varName)) continue;
99
+ seen.add(varName);
100
+ if (env[varName]) {
101
+ overridden.push(varName);
102
+ continue;
103
+ }
104
+ env[varName] = endpoint;
105
+ applied.push(varName);
106
+ }
107
+
108
+ const notices: string[] = [];
109
+ if (applied.length > 0) {
110
+ notices.push(
111
+ `environment "${environment}" declares endpoint ${endpoint} — applied to ${applied.join(", ")} for this read`,
112
+ );
113
+ }
114
+ if (overridden.length > 0) {
115
+ notices.push(
116
+ `ambient ${overridden.join(", ")} already set — keeping it over environment "${environment}"'s declared endpoint (${endpoint})`,
117
+ );
118
+ }
119
+
120
+ return {
121
+ notice: notices.length > 0 ? notices.join("; ") : undefined,
122
+ restore: () => {
123
+ for (const varName of applied) delete env[varName];
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * #1166 acceptance: when a `--live` describe comes back with zero resources
130
+ * for a lexicon that had declared entities to look for, and nothing was
131
+ * already reported NOT-OBSERVED (#1089) either, that is either "genuinely
132
+ * nothing is deployed yet" or a misconfigured endpoint/credentials — the two
133
+ * are visually identical, so a caller must say so rather than stay quiet.
134
+ * Returns `undefined` when there is nothing to declare (no declared entities
135
+ * to have asked about, or the emptiness is already explained by #1089's
136
+ * `unobserved`).
137
+ */
138
+ export function zeroResourcesWarning(
139
+ lexicon: string,
140
+ environment: string,
141
+ declaredCount: number,
142
+ observed: { resources: Record<string, unknown>; unobserved: Record<string, unknown> },
143
+ ): string | undefined {
144
+ if (declaredCount === 0) return undefined;
145
+ if (Object.keys(observed.resources).length > 0) return undefined;
146
+ if (Object.keys(observed.unobserved).length > 0) return undefined;
147
+ return `${lexicon}: 0 live resources for env "${environment}" (${declaredCount} declared) — check the endpoint/credentials`;
148
+ }