@intentius/chant 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/cli/handlers/components.d.ts.map +1 -1
  2. package/dist/cli/handlers/graph.d.ts.map +1 -1
  3. package/dist/cli/handlers/lifecycle.d.ts +5 -3
  4. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  5. package/dist/cli/main.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +14 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/config.d.ts +46 -4
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/deep-observation.d.ts +257 -0
  11. package/dist/deep-observation.d.ts.map +1 -0
  12. package/dist/discovery/fold-import.d.ts +153 -17
  13. package/dist/discovery/fold-import.d.ts.map +1 -1
  14. package/dist/discovery/sandbox/config-wire.d.ts +3 -2
  15. package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
  16. package/dist/env.d.ts +5 -2
  17. package/dist/env.d.ts.map +1 -1
  18. package/dist/fold/fold.d.ts +35 -3
  19. package/dist/fold/fold.d.ts.map +1 -1
  20. package/dist/fold/subset.d.ts +9 -0
  21. package/dist/fold/subset.d.ts.map +1 -1
  22. package/dist/graph-ir.d.ts +73 -4
  23. package/dist/graph-ir.d.ts.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/kubectl-context.d.ts +27 -0
  27. package/dist/kubectl-context.d.ts.map +1 -1
  28. package/dist/lexicon.d.ts +78 -6
  29. package/dist/lexicon.d.ts.map +1 -1
  30. package/dist/lifecycle/change-set.d.ts +26 -5
  31. package/dist/lifecycle/change-set.d.ts.map +1 -1
  32. package/dist/lifecycle/deep-diff.d.ts +103 -0
  33. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  34. package/dist/lifecycle/deep-observe.d.ts +62 -0
  35. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  36. package/dist/lifecycle/index.d.ts +3 -0
  37. package/dist/lifecycle/index.d.ts.map +1 -1
  38. package/dist/lifecycle/live-diff.d.ts +25 -1
  39. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  40. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  41. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  42. package/dist/lifecycle/observe.d.ts +4 -2
  43. package/dist/lifecycle/observe.d.ts.map +1 -1
  44. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  45. package/dist/lifecycle/status.d.ts +26 -1
  46. package/dist/lifecycle/status.d.ts.map +1 -1
  47. package/dist/lifecycle/types.d.ts +8 -0
  48. package/dist/lifecycle/types.d.ts.map +1 -1
  49. package/dist/live-endpoint.d.ts +92 -0
  50. package/dist/live-endpoint.d.ts.map +1 -0
  51. package/dist/observation.d.ts +123 -0
  52. package/dist/observation.d.ts.map +1 -0
  53. package/dist/stack-output.d.ts.map +1 -1
  54. package/package.json +1 -1
  55. package/src/cli/handlers/components.test.ts +63 -4
  56. package/src/cli/handlers/components.ts +78 -35
  57. package/src/cli/handlers/graph.test.ts +155 -6
  58. package/src/cli/handlers/graph.ts +124 -29
  59. package/src/cli/handlers/lifecycle.test.ts +410 -6
  60. package/src/cli/handlers/lifecycle.ts +475 -182
  61. package/src/cli/main.test.ts +6 -0
  62. package/src/cli/main.ts +12 -0
  63. package/src/cli/registry.ts +14 -0
  64. package/src/config.test.ts +75 -0
  65. package/src/config.ts +61 -3
  66. package/src/deep-observation.test.ts +234 -0
  67. package/src/deep-observation.ts +489 -0
  68. package/src/discovery/fold-composite.test.ts +594 -0
  69. package/src/discovery/fold-import.test.ts +372 -1
  70. package/src/discovery/fold-import.ts +1216 -116
  71. package/src/discovery/sandbox/config-wire.ts +3 -2
  72. package/src/env.test.ts +12 -0
  73. package/src/env.ts +12 -4
  74. package/src/fold/fold.test.ts +105 -0
  75. package/src/fold/fold.ts +100 -20
  76. package/src/fold/subset.test.ts +38 -7
  77. package/src/fold/subset.ts +9 -0
  78. package/src/graph-ir-live.test.ts +28 -1
  79. package/src/graph-ir.ts +115 -12
  80. package/src/index.ts +2 -0
  81. package/src/kubectl-context.ts +81 -0
  82. package/src/lexicon.ts +100 -6
  83. package/src/lifecycle/change-set.test.ts +93 -1
  84. package/src/lifecycle/change-set.ts +65 -13
  85. package/src/lifecycle/deep-diff.test.ts +157 -0
  86. package/src/lifecycle/deep-diff.ts +213 -0
  87. package/src/lifecycle/deep-observe.test.ts +174 -0
  88. package/src/lifecycle/deep-observe.ts +173 -0
  89. package/src/lifecycle/index.ts +3 -0
  90. package/src/lifecycle/live-diff.test.ts +39 -0
  91. package/src/lifecycle/live-diff.ts +51 -5
  92. package/src/lifecycle/observation-baseline.test.ts +99 -0
  93. package/src/lifecycle/observation-baseline.ts +217 -0
  94. package/src/lifecycle/observe.test.ts +74 -3
  95. package/src/lifecycle/observe.ts +82 -22
  96. package/src/lifecycle/snapshot.test.ts +39 -1
  97. package/src/lifecycle/snapshot.ts +40 -20
  98. package/src/lifecycle/status.test.ts +89 -8
  99. package/src/lifecycle/status.ts +53 -3
  100. package/src/lifecycle/types.ts +8 -0
  101. package/src/live-endpoint.test.ts +115 -0
  102. package/src/live-endpoint.ts +148 -0
  103. package/src/observation.test.ts +96 -0
  104. package/src/observation.ts +213 -0
  105. package/src/stack-output.test.ts +55 -0
  106. package/src/stack-output.ts +41 -20
@@ -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
 
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ acceptDeviations,
4
+ acceptedDeviation,
5
+ baselineForLexicon,
6
+ countAccepted,
7
+ emptyBaseline,
8
+ isObservationBaseline,
9
+ parseBaseline,
10
+ serializeBaseline,
11
+ } from "./observation-baseline";
12
+
13
+ describe("the baseline document", () => {
14
+ test("round-trips through serialize/parse", () => {
15
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [
16
+ { entity: "Assets", type: "AWS::S3::Bucket", path: "Tags[0].Value", value: "platform", note: "org policy" },
17
+ ], { now: "2026-07-27T00:00:00.000Z" });
18
+ const parsed = parseBaseline(serializeBaseline(b));
19
+ expect(parsed).toEqual(b);
20
+ });
21
+
22
+ test("serializes with sorted keys and a trailing newline, so the commit diff reads cleanly", () => {
23
+ const json = serializeBaseline(emptyBaseline("prod"));
24
+ expect(json.endsWith("\n")).toBe(true);
25
+ expect(JSON.parse(json)).toEqual({ baseline: "v1", environment: "prod", lexicons: {} });
26
+ });
27
+
28
+ test("refuses to read anything that is not a versioned baseline", () => {
29
+ expect(parseBaseline(null)).toBeNull();
30
+ expect(parseBaseline("")).toBeNull();
31
+ expect(parseBaseline("{ not json")).toBeNull();
32
+ expect(parseBaseline('{"baseline":"v2","lexicons":{}}')).toBeNull();
33
+ expect(parseBaseline('{"lexicons":{}}')).toBeNull();
34
+ expect(isObservationBaseline({ baseline: "v1", lexicons: {} })).toBe(true);
35
+ });
36
+ });
37
+
38
+ describe("acceptDeviations", () => {
39
+ const now = "2026-07-27T00:00:00.000Z";
40
+
41
+ test("records a deviation bound to the value that was accepted", () => {
42
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [
43
+ { entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
44
+ ], { now });
45
+ expect(baselineForLexicon(b, "aws")).toEqual({
46
+ Role: {
47
+ type: "AWS::IAM::Role",
48
+ accepted: [{ path: "MaxSessionDuration", value: 7200, recordedAt: now }],
49
+ },
50
+ });
51
+ expect(b.updated).toBe(now);
52
+ });
53
+
54
+ test("does not mutate the input", () => {
55
+ const before = emptyBaseline("prod");
56
+ acceptDeviations(before, "aws", [{ entity: "R", path: "A", value: 1 }], { now });
57
+ expect(before.lexicons).toEqual({});
58
+ });
59
+
60
+ test("re-accepting the same path replaces the entry rather than appending a second", () => {
61
+ let b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R", path: "A", value: 1 }], { now });
62
+ b = acceptDeviations(b, "aws", [{ entity: "R", path: "A", value: 2 }], { now });
63
+ expect(baselineForLexicon(b, "aws").R.accepted).toEqual([{ path: "A", value: 2, recordedAt: now }]);
64
+ });
65
+
66
+ test("keeps deviations from other entities and other lexicons", () => {
67
+ let b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R1", path: "A", value: 1 }], { now });
68
+ b = acceptDeviations(b, "aws", [{ entity: "R2", path: "B", value: 2 }], { now });
69
+ b = acceptDeviations(b, "k8s", [{ entity: "D", path: "spec.replicas", value: 3 }], { now });
70
+ expect(Object.keys(baselineForLexicon(b, "aws")).sort()).toEqual(["R1", "R2"]);
71
+ expect(countAccepted(b)).toBe(3);
72
+ });
73
+
74
+ test("accepted paths sort, so the committed file is stable across runs", () => {
75
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [
76
+ { entity: "R", path: "Z", value: 1 },
77
+ { entity: "R", path: "A", value: 2 },
78
+ ], { now });
79
+ expect(baselineForLexicon(b, "aws").R.accepted.map((a) => a.path)).toEqual(["A", "Z"]);
80
+ });
81
+
82
+ test("an empty accept list is a no-op", () => {
83
+ const b = emptyBaseline("prod");
84
+ expect(acceptDeviations(b, "aws", [])).toBe(b);
85
+ });
86
+
87
+ test("lookup is by entity and path", () => {
88
+ const b = acceptDeviations(emptyBaseline("prod"), "aws", [{ entity: "R", path: "A", value: 1 }], { now });
89
+ const lex = baselineForLexicon(b, "aws");
90
+ expect(acceptedDeviation(lex, "R", "A")?.value).toBe(1);
91
+ expect(acceptedDeviation(lex, "R", "B")).toBeUndefined();
92
+ expect(acceptedDeviation(lex, "Other", "A")).toBeUndefined();
93
+ });
94
+
95
+ test("a missing baseline reads as nothing accepted", () => {
96
+ expect(baselineForLexicon(null, "aws")).toEqual({});
97
+ expect(countAccepted(null)).toBe(0);
98
+ });
99
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The accepted-observation baseline (#1014) — a committed record of deviations
3
+ * somebody looked at and accepted, so they stop re-alerting.
4
+ *
5
+ * Deep observation reports every property that differs between source and
6
+ * cloud, including properties nobody ever declared. Some of those are real
7
+ * findings. Many are permanent facts of the account — a platform team's
8
+ * mandatory tag, a bucket setting an org policy flips on, a role an operator
9
+ * attached by hand and everyone agreed to keep. Without somewhere to record
10
+ * "yes, we know, leave it", a deep diff is a report nobody reads twice.
11
+ *
12
+ * The model is cdk-real-drift's `.cdkrd`: a snapshot of accepted *undeclared*
13
+ * values that the diff subtracts. Accepting is an explicit act with a git
14
+ * commit behind it, and the acceptance is value-bound — accept
15
+ * `VersioningConfiguration.Status = Enabled` and a later change to `Suspended`
16
+ * is drift again, because what was accepted was that value, not that path.
17
+ *
18
+ * ## What this is not
19
+ *
20
+ * Not state. The baseline never tells a deploy what to do and is never read on
21
+ * the write path; deleting it costs you noise suppression and nothing else.
22
+ * Which is also why it is safe for it to be incomplete or stale.
23
+ *
24
+ * ## Where it lives
25
+ *
26
+ * `<environment>/observation-baseline.json` on the `chant/lifecycle` orphan
27
+ * branch — the epic's named candidate home, and the same storage the snapshots
28
+ * (`<env>/<lexicon>.json`), the release ledger (`<env>/releases.jsonl`) and the
29
+ * build archive (`_builds/<digest>.json`) already use, through the same
30
+ * `writeBlobToPath`/`readBlobFromPath` plumbing. One env-keyed namespace for
31
+ * everything chant records *about* an environment rather than *for* it.
32
+ *
33
+ * The parse/serialize/update half below is pure and storage-free, so the
34
+ * decision is one function call deep if a repo-committed file (`.chant/`) turns
35
+ * out to be the better review surface.
36
+ */
37
+
38
+ import { readBlobFromPath, writeBlobToPath } from "./git";
39
+ import { sortedJsonReplacer } from "../utils";
40
+
41
+ /** The file name under `<environment>/` on the orphan branch. */
42
+ export const OBSERVATION_BASELINE_FILE = "observation-baseline.json";
43
+
44
+ /** One deviation somebody accepted, bound to the value they accepted. */
45
+ export interface AcceptedDeviation {
46
+ /** Property path within the entity's normalized tree (`Tags[0].Value`, `Policy.Statement[1].Effect`). */
47
+ path: string;
48
+ /** The live value at the moment of acceptance. A different live value later is drift again. */
49
+ value: unknown;
50
+ /** Free-text justification, written by whoever accepted it. */
51
+ note?: string;
52
+ /** ISO timestamp of acceptance. */
53
+ recordedAt?: string;
54
+ }
55
+
56
+ /** Every accepted deviation for one declared entity. */
57
+ export interface BaselineEntity {
58
+ /** Entity type at acceptance time, for readability in the committed file. */
59
+ type?: string;
60
+ accepted: AcceptedDeviation[];
61
+ }
62
+
63
+ /** Accepted deviations for one lexicon, keyed by chant entity name. */
64
+ export type BaselineLexicon = Record<string, BaselineEntity>;
65
+
66
+ /** The committed baseline document for one environment. */
67
+ export interface ObservationBaseline {
68
+ /** Discriminant + wire version. */
69
+ readonly baseline: "v1";
70
+ environment: string;
71
+ /** ISO timestamp of the last `--update-baseline`. */
72
+ updated?: string;
73
+ /** lexicon → entity → accepted deviations. */
74
+ lexicons: Record<string, BaselineLexicon>;
75
+ }
76
+
77
+ /** An environment with nothing accepted yet. */
78
+ export function emptyBaseline(environment: string): ObservationBaseline {
79
+ return { baseline: "v1", environment, lexicons: {} };
80
+ }
81
+
82
+ /** True when `value` is a well-formed {@link ObservationBaseline}. */
83
+ export function isObservationBaseline(value: unknown): value is ObservationBaseline {
84
+ return (
85
+ typeof value === "object" &&
86
+ value !== null &&
87
+ (value as { baseline?: unknown }).baseline === "v1" &&
88
+ typeof (value as { lexicons?: unknown }).lexicons === "object" &&
89
+ (value as { lexicons?: unknown }).lexicons !== null
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Parse a baseline document. Returns `null` for unparseable or unrecognized
95
+ * content — a corrupt baseline degrades to "nothing is accepted", which is
96
+ * noisy but never wrong. Silently treating garbage as a baseline would
97
+ * suppress real drift.
98
+ */
99
+ export function parseBaseline(content: string | null | undefined): ObservationBaseline | null {
100
+ if (!content) return null;
101
+ let parsed: unknown;
102
+ try {
103
+ parsed = JSON.parse(content);
104
+ } catch {
105
+ return null;
106
+ }
107
+ if (!isObservationBaseline(parsed)) return null;
108
+ return parsed;
109
+ }
110
+
111
+ /** Deterministic on-disk form: sorted keys, trailing newline, reviewable diff. */
112
+ export function serializeBaseline(baseline: ObservationBaseline): string {
113
+ return `${JSON.stringify(baseline, sortedJsonReplacer, 2)}\n`;
114
+ }
115
+
116
+ /** The accepted deviations for one lexicon, or an empty map. */
117
+ export function baselineForLexicon(
118
+ baseline: ObservationBaseline | null | undefined,
119
+ lexicon: string,
120
+ ): BaselineLexicon {
121
+ return baseline?.lexicons?.[lexicon] ?? {};
122
+ }
123
+
124
+ /** Look up one accepted deviation by entity + path. */
125
+ export function acceptedDeviation(
126
+ lexiconBaseline: BaselineLexicon,
127
+ entity: string,
128
+ path: string,
129
+ ): AcceptedDeviation | undefined {
130
+ return lexiconBaseline[entity]?.accepted.find((a) => a.path === path);
131
+ }
132
+
133
+ /** One deviation to record as accepted. */
134
+ export interface DeviationToAccept {
135
+ entity: string;
136
+ type?: string;
137
+ path: string;
138
+ /** The live value being accepted. */
139
+ value: unknown;
140
+ note?: string;
141
+ }
142
+
143
+ /**
144
+ * Record deviations as accepted, returning a new baseline (the input is not
145
+ * mutated). An existing acceptance for the same entity+path is replaced — that
146
+ * is how re-accepting after a deliberate change works, and it keeps the file
147
+ * from growing a second entry for every value a path has ever held.
148
+ */
149
+ export function acceptDeviations(
150
+ baseline: ObservationBaseline,
151
+ lexicon: string,
152
+ deviations: readonly DeviationToAccept[],
153
+ opts?: { now?: string },
154
+ ): ObservationBaseline {
155
+ if (deviations.length === 0) return baseline;
156
+ const now = opts?.now ?? new Date().toISOString();
157
+ const lexicons: Record<string, BaselineLexicon> = { ...baseline.lexicons };
158
+ const entities: BaselineLexicon = { ...(lexicons[lexicon] ?? {}) };
159
+
160
+ for (const dev of deviations) {
161
+ const existing = entities[dev.entity];
162
+ const accepted = (existing?.accepted ?? []).filter((a) => a.path !== dev.path);
163
+ accepted.push({
164
+ path: dev.path,
165
+ value: dev.value,
166
+ ...(dev.note ? { note: dev.note } : {}),
167
+ recordedAt: now,
168
+ });
169
+ accepted.sort((a, b) => a.path.localeCompare(b.path));
170
+ entities[dev.entity] = {
171
+ ...(dev.type ?? existing?.type ? { type: dev.type ?? existing?.type } : {}),
172
+ accepted,
173
+ };
174
+ }
175
+
176
+ lexicons[lexicon] = entities;
177
+ return { baseline: "v1", environment: baseline.environment, updated: now, lexicons };
178
+ }
179
+
180
+ /** Total accepted deviations across every lexicon — for the "N accepted" line. */
181
+ export function countAccepted(baseline: ObservationBaseline | null | undefined): number {
182
+ if (!baseline) return 0;
183
+ let n = 0;
184
+ for (const entities of Object.values(baseline.lexicons)) {
185
+ for (const entity of Object.values(entities)) n += entity.accepted.length;
186
+ }
187
+ return n;
188
+ }
189
+
190
+ // ── Storage (chant/lifecycle orphan branch) ─────────────────────────────────
191
+
192
+ /**
193
+ * Read the accepted baseline for an environment. Returns `null` when the branch,
194
+ * the environment, or the file does not exist — every one of which means
195
+ * "nothing accepted yet", the normal state before anyone runs
196
+ * `--update-baseline`.
197
+ */
198
+ export async function readObservationBaseline(
199
+ environment: string,
200
+ opts?: { cwd?: string },
201
+ ): Promise<ObservationBaseline | null> {
202
+ return parseBaseline(await readBlobFromPath(environment, OBSERVATION_BASELINE_FILE, opts));
203
+ }
204
+
205
+ /** Write the accepted baseline to the orphan branch. Returns the new commit SHA. */
206
+ export async function writeObservationBaseline(
207
+ baseline: ObservationBaseline,
208
+ opts?: { cwd?: string },
209
+ ): Promise<string> {
210
+ return writeBlobToPath(
211
+ baseline.environment,
212
+ OBSERVATION_BASELINE_FILE,
213
+ serializeBaseline(baseline),
214
+ "Accepted observation baseline",
215
+ opts,
216
+ );
217
+ }
@@ -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
+ }