@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
@@ -13,11 +13,12 @@
13
13
  * load-bearing.
14
14
  */
15
15
  import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff";
16
+ import { unobservedReasonText, type UnobservedReason } from "../observation";
16
17
 
17
18
  /**
18
19
  * What the projection proposes for a single resource.
19
20
  *
20
- * - `create` — declared in source, absent from live.
21
+ * - `create` — declared in source, and the provider **confirmed** it absent.
21
22
  * - `update` — declared and live, but live config drifted.
22
23
  * - `delete` — a chant-owned resource that is live but no longer declared.
23
24
  * Only emitted once ownership is known (#121); never inferred from the
@@ -25,8 +26,11 @@ import { diffLive, type AttributeChange, type DiffLiveInput } from "./live-diff"
25
26
  * - `adopt` — live but undeclared, ownership not established → a candidate to
26
27
  * pull back into source, never an auto-delete.
27
28
  * - `noop` — declared and live with no drift, or already reconciled.
29
+ * - `unobserved` — declared, and the lexicon could not look (#1089). Not a
30
+ * proposal at all: it is the plan admitting a hole. Never a create, never a
31
+ * delete. Read `unobservedReason` for which hole.
28
32
  */
29
- export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop";
33
+ export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
30
34
 
31
35
  /**
32
36
  * Who answers "is this resource chant's?". `unknown` until a live ownership
@@ -47,13 +51,24 @@ export interface ChangeSetEntry {
47
51
  declared: boolean;
48
52
  /** Present in the last snapshot. */
49
53
  inSnapshot: boolean;
50
- /** Observed in the live system right now. */
54
+ /** Observed present in the live system right now. */
51
55
  live: boolean;
56
+ /**
57
+ * The lexicon actually looked at this entity (#1089). `false` with
58
+ * `live: false` means "unknown", not "absent" — the distinction the whole
59
+ * change set now rests on. Absent-and-looked-at is `observed: true,
60
+ * live: false`.
61
+ */
62
+ observed: boolean;
52
63
  };
53
64
  /** Attribute-level changes, for `update`. */
54
65
  deltas?: AttributeChange[];
55
66
  /** Live-marker ownership. Defaults to `unknown`. */
56
67
  ownership: Ownership;
68
+ /** Why the entity could not be observed, for `action: "unobserved"` (#1089). */
69
+ unobservedReason?: UnobservedReason;
70
+ /** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
71
+ unobservedDetail?: string;
57
72
  }
58
73
 
59
74
  export interface ChangeSet {
@@ -67,11 +82,16 @@ export interface ChangeSet {
67
82
  * `create`/`update` are precise from declared-vs-live. `delete` is never
68
83
  * emitted here — an undeclared live resource classifies as `adopt` until
69
84
  * ownership is known.
85
+ *
86
+ * A declared entity the lexicon could not observe (`input.unobserved`, #1089)
87
+ * classifies as `unobserved` and nothing else: no `create` is ever synthesized
88
+ * from a read that did not happen.
70
89
  */
71
90
  export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
72
91
  const diff = diffLive(input);
73
92
  const { declared, observedNow } = input;
74
93
  const observedThen = input.observedThen ?? {};
94
+ const unobservedInput = input.unobserved ?? {};
75
95
 
76
96
  const driftByName = new Map(
77
97
  diff.driftedSinceSnapshot.map((d) => [d.name, d.changes] as const),
@@ -81,6 +101,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
81
101
  ...declared,
82
102
  ...Object.keys(observedNow),
83
103
  ...Object.keys(observedThen),
104
+ ...Object.keys(unobservedInput),
84
105
  ]);
85
106
 
86
107
  const entries: ChangeSetEntry[] = [];
@@ -88,8 +109,11 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
88
109
  const isDeclared = declared.has(name);
89
110
  const live = Object.prototype.hasOwnProperty.call(observedNow, name);
90
111
  const inSnapshot = Object.prototype.hasOwnProperty.call(observedThen, name);
91
- const type = observedNow[name]?.type ?? observedThen[name]?.type;
92
- const evidence = { declared: isDeclared, inSnapshot, live };
112
+ // A returned resource was observed by definition; `unobserved` only counts
113
+ // for entities the lexicon did not return.
114
+ const unobservedEntry = live ? undefined : unobservedInput[name];
115
+ const type = observedNow[name]?.type ?? observedThen[name]?.type ?? unobservedEntry?.type;
116
+ const evidence = { declared: isDeclared, inSnapshot, live, observed: !unobservedEntry };
93
117
 
94
118
  // Ownership comes from the LIVE marker only (carried on observedNow), never
95
119
  // from the snapshot. This is the invariant that keeps the snapshot from
@@ -100,8 +124,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
100
124
  let action: ChangeAction;
101
125
  let deltas: AttributeChange[] | undefined;
102
126
 
103
- if (isDeclared && !live) {
104
- // Declared in source, not in the cloud → create.
127
+ if (unobservedEntry) {
128
+ // The lexicon never looked. Absence is not established, so neither a
129
+ // create (declared) nor a delete/adopt (undeclared) can be proposed —
130
+ // the entry exists to say the plan has a hole here.
131
+ action = "unobserved";
132
+ } else if (isDeclared && !live) {
133
+ // Declared in source, and the provider confirmed it absent → create.
105
134
  action = "create";
106
135
  } else if (isDeclared && live) {
107
136
  const drift = driftByName.get(name);
@@ -120,14 +149,27 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
120
149
  action = "noop";
121
150
  }
122
151
 
123
- entries.push({ name, type, action, evidence, deltas, ownership });
152
+ entries.push({
153
+ name,
154
+ type,
155
+ action,
156
+ evidence,
157
+ deltas,
158
+ ownership,
159
+ ...(unobservedEntry
160
+ ? {
161
+ unobservedReason: unobservedEntry.reason,
162
+ ...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
163
+ }
164
+ : {}),
165
+ });
124
166
  }
125
167
 
126
168
  entries.sort((a, b) => a.name.localeCompare(b.name));
127
169
  return { env, entries };
128
170
  }
129
171
 
130
- const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop"];
172
+ const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
131
173
 
132
174
  /** Count entries per action. */
133
175
  export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
@@ -137,6 +179,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
137
179
  delete: 0,
138
180
  adopt: 0,
139
181
  noop: 0,
182
+ unobserved: 0,
140
183
  };
141
184
  for (const e of cs.entries) counts[e.action]++;
142
185
  return counts;
@@ -148,8 +191,10 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
148
191
  * GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
149
192
  * UI as "N to add, M to change, K to delete". The format is generic — any tool
150
193
  * that emits this JSON gets the widget — and the chant plan maps onto it
151
- * directly. Only the mutating actions count: `adopt` and `noop` are excluded,
152
- * since the widget has no column for "live but undeclared" or "no change".
194
+ * directly. Only the mutating actions count: `adopt`, `noop` and `unobserved`
195
+ * are excluded, since the widget has no column for "live but undeclared", "no
196
+ * change", or "could not look" (#1089). The widget is therefore a floor, not a
197
+ * complete plan: read the full change set when entities are unobserved.
153
198
  *
154
199
  * The widget label reads "Terraform" regardless of producer; that is GitLab's
155
200
  * fixed string, not a claim chant makes.
@@ -175,10 +220,17 @@ export function renderChangeSet(cs: ChangeSet): string {
175
220
  for (const action of ACTION_ORDER) {
176
221
  const group = cs.entries.filter((e) => e.action === action);
177
222
  if (group.length === 0) continue;
178
- lines.push(`\n${action.toUpperCase()}:`);
223
+ lines.push(
224
+ action === "unobserved"
225
+ ? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
226
+ : `\n${action.toUpperCase()}:`,
227
+ );
179
228
  for (const e of group) {
180
229
  const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
181
- lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}`);
230
+ const why = e.unobservedReason
231
+ ? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
232
+ : "";
233
+ lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}`);
182
234
  for (const d of e.deltas ?? []) {
183
235
  lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
184
236
  }
@@ -23,6 +23,7 @@ describe("diffLive", () => {
23
23
  newlyObserved: [],
24
24
  driftedSinceSnapshot: [],
25
25
  unchanged: [],
26
+ unobserved: [],
26
27
  });
27
28
  });
28
29
 
@@ -112,6 +113,44 @@ describe("diffLive", () => {
112
113
  expect(result.driftedSinceSnapshot.map((d) => d.name)).toEqual(["c"]);
113
114
  expect(result.unchanged).toEqual(["b"]);
114
115
  });
116
+
117
+ // ── The observation tri-state (#1089) ─────────────────────────────────────
118
+
119
+ test("declared and not observed → unobserved, not missing", () => {
120
+ const result = diffLive({
121
+ declared: new Set(["crd", "gone"]),
122
+ observedNow: {},
123
+ observedThen: undefined,
124
+ unobserved: { crd: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" } },
125
+ });
126
+ expect(result.missing).toEqual(["gone"]);
127
+ expect(result.unobserved).toEqual([
128
+ { name: "crd", type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" },
129
+ ]);
130
+ });
131
+
132
+ test("an unobserved entity in the last snapshot has not disappeared", () => {
133
+ const result = diffLive({
134
+ declared: new Set(["crd"]),
135
+ observedNow: {},
136
+ observedThen: { crd: meta() },
137
+ unobserved: { crd: { reason: "no-credentials" } },
138
+ });
139
+ expect(result.disappeared).toEqual([]);
140
+ expect(result.missing).toEqual([]);
141
+ expect(result.unobserved.map((u) => u.name)).toEqual(["crd"]);
142
+ });
143
+
144
+ test("a resource that was returned is never also unobserved", () => {
145
+ const result = diffLive({
146
+ declared: new Set(["a"]),
147
+ observedNow: { a: meta() },
148
+ observedThen: undefined,
149
+ unobserved: { a: { reason: "read-failed" } },
150
+ });
151
+ expect(result.unobserved).toEqual([]);
152
+ expect(result.newlyObserved).toEqual(["a"]);
153
+ });
115
154
  });
116
155
 
117
156
  describe("diffLiveArtifacts", () => {
@@ -13,6 +13,7 @@
13
13
  * outside chant's entity model)
14
14
  */
15
15
  import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
16
+ import type { UnobservedEntity, UnobservedReason } from "../observation";
16
17
 
17
18
  export interface AttributeChange {
18
19
  /** Attribute path (e.g. "status", "physicalId", "attributes.tags.env"). */
@@ -27,8 +28,20 @@ export interface ResourceDrift {
27
28
  changes: AttributeChange[];
28
29
  }
29
30
 
31
+ /** A declared entity the lexicon could not observe, as reported by the diff (#1089). */
32
+ export interface UnobservedResource {
33
+ name: string;
34
+ type?: string;
35
+ reason: UnobservedReason;
36
+ detail?: string;
37
+ }
38
+
30
39
  export interface LiveDiffResult {
31
- /** Declared in current build, but not observed in cloud right now. */
40
+ /**
41
+ * Declared in current build, and the provider reported it absent. Entities
42
+ * the lexicon could not observe are NOT here — they are in `unobserved`
43
+ * (#1089), so "missing" keeps meaning "confirmed not there".
44
+ */
32
45
  missing: string[];
33
46
  /** Observed in cloud right now, but not declared. */
34
47
  orphan: string[];
@@ -40,6 +53,12 @@ export interface LiveDiffResult {
40
53
  driftedSinceSnapshot: ResourceDrift[];
41
54
  /** Observed both then and now; metadata identical. */
42
55
  unchanged: string[];
56
+ /**
57
+ * Declared, and the lexicon could not look (#1089) — no reader for the kind,
58
+ * the read failed, no credentials, no binding. Not drift, not absence: a hole
59
+ * in the observation. Sorted by name.
60
+ */
61
+ unobserved: UnobservedResource[];
43
62
  }
44
63
 
45
64
  export interface DiffLiveInput {
@@ -49,6 +68,12 @@ export interface DiffLiveInput {
49
68
  observedNow: Record<string, ResourceMetadata>;
50
69
  /** Resources captured by the previous snapshot, if any. */
51
70
  observedThen: Record<string, ResourceMetadata> | undefined;
71
+ /**
72
+ * Declared entities `describeResources()` reported as NOT-OBSERVED (#1089),
73
+ * keyed by entity name. Absent/empty means every declared entity was looked
74
+ * at, so absence from `observedNow` is a confirmed absence.
75
+ */
76
+ unobserved?: Record<string, UnobservedEntity>;
52
77
  }
53
78
 
54
79
  const TRACKED_FIELDS: Array<keyof ResourceMetadata> = [
@@ -135,6 +160,12 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
135
160
  const observedThenMap = observedThen ?? {};
136
161
  const observedNowNames = new Set(Object.keys(observedNow));
137
162
  const observedThenNames = new Set(Object.keys(observedThenMap));
163
+ // A resource the lexicon returned is observed, whatever it also said about
164
+ // it — present beats not-observed.
165
+ const unobservedMap = input.unobserved ?? {};
166
+ const unobservedNames = new Set(
167
+ Object.keys(unobservedMap).filter((n) => !observedNowNames.has(n)),
168
+ );
138
169
 
139
170
  const missing: string[] = [];
140
171
  const orphan: string[] = [];
@@ -142,10 +173,23 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
142
173
  const newlyObserved: string[] = [];
143
174
  const driftedSinceSnapshot: ResourceDrift[] = [];
144
175
  const unchanged: string[] = [];
176
+ const unobserved: UnobservedResource[] = [];
177
+
178
+ for (const name of unobservedNames) {
179
+ const entry = unobservedMap[name];
180
+ unobserved.push({
181
+ name,
182
+ ...(entry.type ? { type: entry.type } : {}),
183
+ reason: entry.reason,
184
+ ...(entry.detail ? { detail: entry.detail } : {}),
185
+ });
186
+ }
145
187
 
146
- // Declared but not observed in cloud right now → missing
188
+ // Declared, looked at, and the provider said it isn't there → missing.
189
+ // Declared but never looked at is `unobserved`, not missing — the whole point
190
+ // of #1089: "we didn't check" must not read as "it isn't there".
147
191
  for (const name of declared) {
148
- if (!observedNowNames.has(name)) {
192
+ if (!observedNowNames.has(name) && !unobservedNames.has(name)) {
149
193
  missing.push(name);
150
194
  }
151
195
  }
@@ -157,9 +201,10 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
157
201
  }
158
202
  }
159
203
 
160
- // In previous snapshot but not observed now → disappeared
204
+ // In previous snapshot but not observed now → disappeared. An entity nobody
205
+ // could look at has not disappeared; it is unobserved.
161
206
  for (const name of observedThenNames) {
162
- if (!observedNowNames.has(name)) {
207
+ if (!observedNowNames.has(name) && !unobservedNames.has(name)) {
163
208
  disappeared.push(name);
164
209
  }
165
210
  }
@@ -194,6 +239,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
194
239
  newlyObserved: newlyObserved.sort(),
195
240
  driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
196
241
  unchanged: unchanged.sort(),
242
+ unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
197
243
  };
198
244
  }
199
245
 
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { observeResources } from "./observe";
3
+ import { observation } from "../observation";
3
4
  import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
4
5
  import type { BuildResult } from "../build";
5
6
 
@@ -47,13 +48,20 @@ describe("observeResources", () => {
47
48
  expect(names).toEqual(["web-vpc"]);
48
49
  });
49
50
 
50
- it("collects a throwing plugin into errors instead of failing the whole graph", async () => {
51
+ it("collects a throwing plugin into errors instead of failing the whole graph, and reports its entities unobserved (#1089)", async () => {
51
52
  const plugins = [
52
53
  awsPlugin(() => { throw new Error("access denied"); }),
53
54
  ];
54
- const { observations, errors } = await observeResources("prod", plugins, mockBuild());
55
- expect(observations).toEqual([]);
55
+ const { observations, errors, warnings } = await observeResources("prod", plugins, mockBuild());
56
56
  expect(errors).toEqual(["aws: access denied"]);
57
+ // The failed read is a hole, not an empty environment: every declared
58
+ // entity comes back NOT-OBSERVED so nothing downstream reads it as absent.
59
+ expect(observations).toHaveLength(1);
60
+ expect(observations[0].resources).toEqual({});
61
+ expect(observations[0].unobserved).toEqual({
62
+ "web-vpc": { reason: "read-failed", type: "AWS::EC2::VPC", detail: "access denied" },
63
+ });
64
+ expect(warnings.join("\n")).toContain("web-vpc");
57
65
  });
58
66
 
59
67
  it("skips plugins with no describeResources and drops empty results", async () => {
@@ -63,6 +71,69 @@ describe("observeResources", () => {
63
71
  expect(observations).toEqual([]);
64
72
  });
65
73
 
74
+ // #1166 — this is exactly the "wrong endpoint" shape: AWS's stackDoesNotExist
75
+ // branch returns an empty map (bare `{}`, no #1089 envelope) for a declared
76
+ // entity nobody could actually observe. Before the fix this vanished with
77
+ // neither an observation nor a warning; now it must say so.
78
+ it("warns when a lexicon with declared entities observes zero resources and nothing is unobserved either (#1166)", async () => {
79
+ const empty = awsPlugin(() => ({}));
80
+ const { observations, warnings } = await observeResources("prod", [empty], mockBuild());
81
+ expect(observations).toEqual([]); // still no observation pushed — nothing to graph
82
+ expect(warnings).toEqual([
83
+ 'aws: 0 live resources for env "prod" (1 declared) — check the endpoint/credentials',
84
+ ]);
85
+ });
86
+
87
+ it("does not warn about zero resources when the lexicon declares no entities at all", async () => {
88
+ const empty = awsPlugin(() => ({}));
89
+ const noEntities: BuildResult = { outputs: new Map(), entities: new Map(), errors: [] } as unknown as BuildResult;
90
+ const { warnings } = await observeResources("prod", [empty], noEntities);
91
+ expect(warnings).toEqual([]);
92
+ });
93
+
94
+ it("does not double-warn when the emptiness is already explained by #1089 unobserved", async () => {
95
+ const plugin = {
96
+ name: "aws",
97
+ serializer: {} as ObservationLexicon["serializer"],
98
+ describeResources: async () =>
99
+ observation({}, { "web-vpc": { type: "AWS::EC2::VPC", reason: "no-binding" } }),
100
+ } as unknown as ObservationLexicon;
101
+ const { warnings } = await observeResources("prod", [plugin], mockBuild());
102
+ // Only the #1089 per-entity warning — no separate "0 live resources" line.
103
+ expect(warnings).toHaveLength(1);
104
+ expect(warnings[0]).toContain("no binding for this environment");
105
+ });
106
+
107
+ it("carries a plugin's own unobserved entities through (#1089)", async () => {
108
+ const plugin = {
109
+ name: "aws",
110
+ serializer: {} as ObservationLexicon["serializer"],
111
+ describeResources: async () =>
112
+ observation({}, { "web-vpc": { type: "AWS::EC2::VPC", reason: "unsupported-kind" } }),
113
+ } as unknown as ObservationLexicon;
114
+ const { observations, warnings, errors } = await observeResources("prod", [plugin], mockBuild());
115
+ expect(errors).toEqual([]);
116
+ expect(observations[0].unobserved).toEqual({
117
+ "web-vpc": { type: "AWS::EC2::VPC", reason: "unsupported-kind" },
118
+ });
119
+ expect(warnings[0]).toContain("no reader for this resource kind");
120
+ });
121
+
122
+ it("merges multi-stack reads with present > not-observed > absent (#1089)", async () => {
123
+ const stacks = ["a", "b"];
124
+ const plugin = {
125
+ name: "aws",
126
+ serializer: {} as ObservationLexicon["serializer"],
127
+ describeResources: async (opts: { stack?: string }) =>
128
+ opts.stack === "a"
129
+ ? observation({}, { "web-vpc": { reason: "read-failed", detail: "stack a unreadable" } })
130
+ : observation({ "web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE" } }),
131
+ } as unknown as ObservationLexicon;
132
+ const { observations } = await observeResources("prod", [plugin], mockBuild(), { stacks });
133
+ expect(Object.keys(observations[0].resources)).toEqual(["web-vpc"]);
134
+ expect(observations[0].unobserved).toBeUndefined();
135
+ });
136
+
66
137
  it("with no stacks: calls describeResources exactly once with no `stack` key (unchanged single-stack path)", async () => {
67
138
  const calls: Array<{ stack?: string }> = [];
68
139
  const plugins = [
@@ -9,10 +9,18 @@
9
9
  * side effects. Snapshotting keeps its own copy for now; a future refactor can
10
10
  * fold both onto this primitive.
11
11
  */
12
- import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
12
+ import type { ObservationLexicon } from "../lexicon";
13
13
  import type { BuildResult } from "../build";
14
14
  import type { SerializerResult } from "../serializer";
15
15
  import type { LiveObservation } from "../graph-ir";
16
+ import {
17
+ mergeObservations,
18
+ normalizeObservation,
19
+ unobservedAll,
20
+ formatUnobserved,
21
+ type NormalizedObservation,
22
+ } from "../observation";
23
+ import { zeroResourcesWarning } from "../live-endpoint";
16
24
 
17
25
  export interface ObserveResult {
18
26
  observations: LiveObservation[];
@@ -25,8 +33,10 @@ export interface ObserveResult {
25
33
  * `environment`. `owned` (default true for the managed-only diagram, epic #776)
26
34
  * restricts to resources carrying chant's ownership marker; a lexicon with no
27
35
  * marker channel logs and returns everything (its own contract). Plugins that
28
- * throw are collected into `errors` and skipped — one failing lexicon never
29
- * sinks the whole graph.
36
+ * throw are collected into `errors` — one failing lexicon never sinks the whole
37
+ * graph and every entity they were asked about is recorded as NOT-OBSERVED
38
+ * (`read-failed`, #1089) rather than dropped, so a failed read is visibly a
39
+ * hole instead of a silent absence.
30
40
  *
31
41
  * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster)
32
42
  * where there is no single stack named after the environment — AWS's
@@ -77,36 +87,86 @@ export async function observeResources(
77
87
  }
78
88
 
79
89
  try {
80
- let resources: Record<string, ResourceMetadata>;
90
+ let observed: NormalizedObservation;
81
91
  if (stacks.length > 0) {
82
- resources = {};
92
+ const parts: NormalizedObservation[] = [];
83
93
  for (const stack of stacks) {
84
- const perStack = await plugin.describeResources({
94
+ parts.push(
95
+ normalizeObservation(
96
+ await plugin.describeResources({
97
+ environment,
98
+ buildOutput,
99
+ entityNames,
100
+ entities,
101
+ owned,
102
+ stack,
103
+ }),
104
+ ),
105
+ );
106
+ }
107
+ observed = mergeObservations(parts);
108
+ } else {
109
+ observed = normalizeObservation(
110
+ await plugin.describeResources({
85
111
  environment,
86
112
  buildOutput,
87
113
  entityNames,
88
114
  entities,
89
115
  owned,
90
- stack,
91
- });
92
- Object.assign(resources, perStack);
93
- }
94
- } else {
95
- resources = await plugin.describeResources({
96
- environment,
97
- buildOutput,
98
- entityNames,
99
- entities,
100
- owned,
101
- });
102
- }
103
- if (Object.keys(resources).length > 0) {
104
- observations.push({ lexicon: plugin.name, resources });
116
+ }),
117
+ );
105
118
  }
119
+ pushObservation(observations, warnings, plugin.name, observed, environment, entityNames.length);
106
120
  } catch (err) {
107
- errors.push(`${plugin.name}: ${err instanceof Error ? err.message : String(err)}`);
121
+ // A thrown read is the whole-lexicon failure: every declared entity is
122
+ // NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a
123
+ // failed read look like "none of these exist" to every consumer.
124
+ const message = err instanceof Error ? err.message : String(err);
125
+ errors.push(`${plugin.name}: ${message}`);
126
+ pushObservation(
127
+ observations,
128
+ warnings,
129
+ plugin.name,
130
+ {
131
+ resources: {},
132
+ unobserved: unobservedAll(entityNames, "read-failed", message, entities),
133
+ },
134
+ environment,
135
+ entityNames.length,
136
+ );
108
137
  }
109
138
  }
110
139
 
111
140
  return { observations, warnings, errors };
112
141
  }
142
+
143
+ /** Record one lexicon's observation, warning once per unobserved entity. */
144
+ function pushObservation(
145
+ observations: LiveObservation[],
146
+ warnings: string[],
147
+ lexicon: string,
148
+ observed: NormalizedObservation,
149
+ environment: string,
150
+ declaredCount: number,
151
+ ): void {
152
+ const hasResources = Object.keys(observed.resources).length > 0;
153
+ const unobservedNames = Object.keys(observed.unobserved);
154
+ for (const name of unobservedNames) {
155
+ warnings.push(`${lexicon}: not observed — ${formatUnobserved(name, observed.unobserved[name])}`);
156
+ }
157
+ if (!hasResources && unobservedNames.length === 0) {
158
+ // #1166 — this is exactly the "wrong endpoint" shape (AWS's
159
+ // stackDoesNotExist branch returns an empty map with no #1089 hole): a
160
+ // declared entity list with nothing observed and nothing explained.
161
+ // Previously this fell straight through with neither an observation nor a
162
+ // warning — silently indistinguishable from "nothing is deployed yet".
163
+ const notice = zeroResourcesWarning(lexicon, environment, declaredCount, observed);
164
+ if (notice) warnings.push(notice);
165
+ return;
166
+ }
167
+ observations.push({
168
+ lexicon,
169
+ resources: observed.resources,
170
+ ...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
171
+ });
172
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
- import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
2
+ import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
3
3
  import type { BuildResult } from "../build";
4
4
 
5
5
  const writeSnapshotMock = vi.fn();
@@ -114,6 +114,44 @@ describe("takeSnapshot", () => {
114
114
  expect(result.errors.some((e) => e.includes("aws") && e.includes("no valid"))).toBe(true);
115
115
  });
116
116
 
117
+ // #1089 — a snapshot is evidence of what was seen. An entity nobody could
118
+ // read must be recorded as a hole, not omitted (which the next diff would
119
+ // read back as "was not there").
120
+ test("records unobserved entities alongside the resources", async () => {
121
+ const plugin = createMockPlugin({
122
+ name: "k8s",
123
+ describeResources: staticObservation(
124
+ { web: { type: "K8s::Apps::Deployment", status: "READY" } },
125
+ { widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" } },
126
+ ),
127
+ });
128
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web", "widget"] }));
129
+ expect(result.snapshots[0].unobserved).toEqual({
130
+ widget: { type: "K8s::X::Widget", reason: "unsupported-kind", detail: "no reader" },
131
+ });
132
+ expect(result.warnings.some((w) => w.includes("widget") && w.includes("not observed"))).toBe(true);
133
+ });
134
+
135
+ test("an entirely unreadable environment is not snapshotted as empty", async () => {
136
+ const plugin = createMockPlugin({
137
+ name: "k8s",
138
+ describeResources: staticObservation({}, { web: { reason: "no-credentials" } }),
139
+ });
140
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
141
+ expect(result.snapshots).toEqual([]);
142
+ expect(result.errors.some((e) => e.includes("could not be read"))).toBe(true);
143
+ });
144
+
145
+ test("a throwing plugin reports each declared entity as unobserved in the warnings", async () => {
146
+ const plugin = createMockPlugin({
147
+ name: "k8s",
148
+ describeResources: async () => { throw new Error("kubeconfig missing"); },
149
+ });
150
+ const result = await takeSnapshot("prod", [plugin], makeBuildResult({ k8s: ["web"] }));
151
+ expect(result.snapshots).toEqual([]);
152
+ expect(result.warnings.some((w) => w.includes("web") && w.includes("kubeconfig missing"))).toBe(true);
153
+ });
154
+
117
155
  test("resources missing required type/status are dropped with warning", async () => {
118
156
  const plugin = createMockPlugin({
119
157
  name: "aws",