@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
@@ -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",
@@ -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