@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
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Property-level live drift (#1014) — declared vs live vs accepted baseline.
3
+ *
4
+ * The thin diff (./live-diff.ts) compares whole entities on status, physical id
5
+ * and a few outputs. This compares their property trees, path by path, which is
6
+ * where a console edit actually shows up. Pure function; the reading happens in
7
+ * ./deep-observe.ts and the CLI.
8
+ *
9
+ * Three axes, and all three matter:
10
+ *
11
+ * - **declared** — the property tree chant synthesized, normalized with the
12
+ * lexicon's own hooks so it is in the same shape as the live tree.
13
+ * - **live** — what the provider returned, normalized with the same hooks.
14
+ * - **baseline** — the value somebody accepted (./observation-baseline.ts). A
15
+ * deviation whose live value matches the accepted value is not drift; one
16
+ * that has moved away from the accepted value is drift again, and the
17
+ * report shows all three so the reader can see what changed and from what.
18
+ *
19
+ * A path is skipped entirely when the declared value is
20
+ * {@link UNRESOLVED} — an unevaluated intrinsic (`Fn::Sub`, `Ref`) has no
21
+ * source-side value to compare, and reporting one as drift would light up every
22
+ * interpolated property forever.
23
+ */
24
+
25
+ import {
26
+ UNRESOLVED,
27
+ deepValueEqual,
28
+ flattenDeepProperties,
29
+ type DeepNormalizationHooks,
30
+ type NormalizedDeepObservation,
31
+ } from "../deep-observation";
32
+ import type { UnobservedResource } from "./live-diff";
33
+ import { acceptedDeviation, type BaselineLexicon } from "./observation-baseline";
34
+
35
+ /**
36
+ * How a property differs.
37
+ *
38
+ * - `changed` — declared and live both have the path, with different values.
39
+ * - `undeclared` — live has it, source never did. cdk-real-drift's whole reason
40
+ * to exist: the console-added property CloudFormation itself will not report.
41
+ * - `absent` — source declares it and the live tree does not carry it. Weaker
42
+ * than the other two: a provider that omits a property it considers unset is
43
+ * common, which is what the lexicon's pruning hook is for.
44
+ */
45
+ export type PropertyDriftKind = "changed" | "undeclared" | "absent";
46
+
47
+ /** One property-level difference. */
48
+ export interface PropertyDrift {
49
+ /** Path within the normalized property tree (`Tags[0].Value`). */
50
+ path: string;
51
+ kind: PropertyDriftKind;
52
+ /** Value in source. Absent for `undeclared`. */
53
+ declared?: unknown;
54
+ /** Value in the cloud. Absent for `absent`. */
55
+ live?: unknown;
56
+ /**
57
+ * The accepted value from the baseline, when this path has one. Present on a
58
+ * reported drift too — that is the "accepted X, now Y" case, and hiding the
59
+ * accepted value there would lose the most useful column in the report.
60
+ */
61
+ baseline?: unknown;
62
+ }
63
+
64
+ /** Property-level drift for one declared entity. */
65
+ export interface DeepEntityDrift {
66
+ name: string;
67
+ type: string;
68
+ changes: PropertyDrift[];
69
+ }
70
+
71
+ export interface DeepDiffResult {
72
+ /** Entities with at least one reportable property difference. Sorted by name. */
73
+ drifted: DeepEntityDrift[];
74
+ /**
75
+ * Differences suppressed by the baseline — reported separately rather than
76
+ * dropped, so `--json` consumers and `--update-baseline` can see what is
77
+ * being held back and the count never silently changes meaning.
78
+ */
79
+ accepted: DeepEntityDrift[];
80
+ /** Entities whose property trees matched. Sorted. */
81
+ unchanged: string[];
82
+ /** Declared entities whose *properties* could not be read (#1089). Sorted. */
83
+ unobserved: UnobservedResource[];
84
+ /** Entities the deep reader returned that were never declared. Sorted. */
85
+ undeclaredEntities: string[];
86
+ }
87
+
88
+ /** A declared entity's property tree, already normalized with the lexicon's hooks. */
89
+ export interface DeclaredDeepEntity {
90
+ type: string;
91
+ properties: Record<string, unknown>;
92
+ }
93
+
94
+ export interface DiffDeepInput {
95
+ /** Normalized declared property trees, keyed by chant entity name. */
96
+ declared: Record<string, DeclaredDeepEntity>;
97
+ /** Normalized live observation, as returned by `observeResourcesDeep()`. */
98
+ live: NormalizedDeepObservation;
99
+ /** Accepted deviations for this lexicon. Omit for "nothing accepted". */
100
+ baseline?: BaselineLexicon;
101
+ /**
102
+ * The lexicon's hooks, so set-like arrays are addressed by key rather than by
103
+ * position (see `flattenDeepProperties`). Omit and paths are positional,
104
+ * which still diffs correctly but shifts every path after an inserted
105
+ * element.
106
+ */
107
+ hooks?: DeepNormalizationHooks;
108
+ }
109
+
110
+ /**
111
+ * Compare declared and live property trees path by path, subtracting accepted
112
+ * deviations. Deterministic: every list is sorted.
113
+ */
114
+ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
115
+ const baseline = input.baseline ?? {};
116
+ const drifted: DeepEntityDrift[] = [];
117
+ const accepted: DeepEntityDrift[] = [];
118
+ const unchanged: string[] = [];
119
+ const unobserved: UnobservedResource[] = [];
120
+ const undeclaredEntities: string[] = [];
121
+
122
+ const liveNames = new Set(Object.keys(input.live.resources));
123
+
124
+ for (const [name, entry] of Object.entries(input.live.unobserved)) {
125
+ // Present beats not-observed, exactly as the thin contract resolves it.
126
+ if (liveNames.has(name)) continue;
127
+ unobserved.push({
128
+ name,
129
+ ...(entry.type ? { type: entry.type } : {}),
130
+ reason: entry.reason,
131
+ ...(entry.detail ? { detail: entry.detail } : {}),
132
+ });
133
+ }
134
+
135
+ for (const name of liveNames) {
136
+ if (!(name in input.declared)) undeclaredEntities.push(name);
137
+ }
138
+
139
+ for (const name of Object.keys(input.declared).sort()) {
140
+ const liveEntity = input.live.resources[name];
141
+ // Not observed deeply → already recorded above; no properties to compare.
142
+ // Observed absent by the deep reader is the thin diff's `missing` case and
143
+ // is not restated here: a resource that does not exist has no property
144
+ // drift, and reporting every one of its declared properties as `absent`
145
+ // would bury the one line that matters.
146
+ if (!liveEntity) continue;
147
+
148
+ const declaredEntity = input.declared[name];
149
+ const type = liveEntity.type || declaredEntity.type;
150
+ const declaredFlat = flattenDeepProperties(declaredEntity.properties, {
151
+ entityType: type,
152
+ side: "declared",
153
+ hooks: input.hooks,
154
+ });
155
+ const liveFlat = flattenDeepProperties(liveEntity.properties, {
156
+ entityType: type,
157
+ side: "live",
158
+ hooks: input.hooks,
159
+ });
160
+
161
+ const paths = [...new Set([...declaredFlat.keys(), ...liveFlat.keys()])].sort();
162
+ const reported: PropertyDrift[] = [];
163
+ const suppressed: PropertyDrift[] = [];
164
+
165
+ for (const path of paths) {
166
+ const hasDeclared = declaredFlat.has(path);
167
+ const hasLive = liveFlat.has(path);
168
+ const declaredValue = declaredFlat.get(path);
169
+ const liveValue = liveFlat.get(path);
170
+
171
+ // An unevaluated intrinsic has no source-side value to compare against.
172
+ if (hasDeclared && declaredValue === UNRESOLVED) continue;
173
+ if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
174
+
175
+ const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
176
+ const drift: PropertyDrift = {
177
+ path,
178
+ kind,
179
+ ...(hasDeclared ? { declared: declaredValue } : {}),
180
+ ...(hasLive ? { live: liveValue } : {}),
181
+ };
182
+
183
+ const acceptedEntry = acceptedDeviation(baseline, name, path);
184
+ if (acceptedEntry) {
185
+ drift.baseline = acceptedEntry.value;
186
+ // Value-bound acceptance: the accepted value is not drift, a different
187
+ // one is drift again.
188
+ if (hasLive && deepValueEqual(liveValue, acceptedEntry.value)) {
189
+ suppressed.push(drift);
190
+ continue;
191
+ }
192
+ }
193
+ reported.push(drift);
194
+ }
195
+
196
+ if (suppressed.length > 0) accepted.push({ name, type, changes: suppressed });
197
+ if (reported.length > 0) drifted.push({ name, type, changes: reported });
198
+ else if (suppressed.length === 0) unchanged.push(name);
199
+ }
200
+
201
+ return {
202
+ drifted: drifted.sort((a, b) => a.name.localeCompare(b.name)),
203
+ accepted: accepted.sort((a, b) => a.name.localeCompare(b.name)),
204
+ unchanged: unchanged.sort(),
205
+ unobserved: unobserved.sort((a, b) => a.name.localeCompare(b.name)),
206
+ undeclaredEntities: undeclaredEntities.sort(),
207
+ };
208
+ }
209
+
210
+ /** Total reported property differences across every entity. */
211
+ export function countPropertyDrift(result: DeepDiffResult): number {
212
+ return result.drifted.reduce((n, e) => n + e.changes.length, 0);
213
+ }
@@ -0,0 +1,174 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { deepDiffForLexicon, diffDeepObservation, mergeDeepObservations, observeDeep } from "./deep-observe";
3
+ import { deepObservation, type DeepNormalizationHooks } from "../deep-observation";
4
+ import type { ObservationLexicon } from "../lexicon";
5
+
6
+ const entities = (
7
+ record: Record<string, { entityType: string; props: Record<string, unknown> }>,
8
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
9
+
10
+ /** Minimal plugin shell — only the observation surface matters here. */
11
+ const pluginWith = (over: Partial<ObservationLexicon>): ObservationLexicon =>
12
+ ({ name: "test", serializer: {} , ...over } as unknown as ObservationLexicon);
13
+
14
+ describe("observeDeep", () => {
15
+ test("a lexicon with no deep reader observes nothing and claims nothing", async () => {
16
+ const result = await observeDeep(pluginWith({}), {
17
+ environment: "prod",
18
+ buildOutput: "",
19
+ entities: entities({ a: { entityType: "T", props: {} } }),
20
+ });
21
+ expect(result).toEqual({ resources: {}, unobserved: {} });
22
+ });
23
+
24
+ test("a thrown reader becomes read-failed for every declared entity, never an empty tree", async () => {
25
+ const result = await observeDeep(
26
+ pluginWith({
27
+ observeResourcesDeep: () => Promise.reject(new Error("kubeconfig has no current context")),
28
+ }),
29
+ {
30
+ environment: "prod",
31
+ buildOutput: "",
32
+ entities: entities({ a: { entityType: "T", props: {} }, b: { entityType: "U", props: {} } }),
33
+ },
34
+ );
35
+ expect(result.resources).toEqual({});
36
+ expect(result.unobserved.a).toEqual({
37
+ type: "T",
38
+ reason: "read-failed",
39
+ detail: "kubeconfig has no current context",
40
+ });
41
+ expect(Object.keys(result.unobserved)).toEqual(["a", "b"]);
42
+ });
43
+
44
+ test("multi-stack reads merge with present beating not-observed", async () => {
45
+ const reader = vi.fn(async (opts: { stack?: string }) =>
46
+ opts.stack === "one"
47
+ ? deepObservation({}, { a: { reason: "read-failed", detail: "not in this stack" } })
48
+ : deepObservation({ a: { type: "T", properties: { A: 1 } } }),
49
+ );
50
+ const result = await observeDeep(pluginWith({ observeResourcesDeep: reader as never }), {
51
+ environment: "prod",
52
+ buildOutput: "",
53
+ entities: entities({ a: { entityType: "T", props: {} } }),
54
+ componentStacks: ["one", "two"],
55
+ });
56
+ expect(result.unobserved).toEqual({});
57
+ expect(result.resources.a.properties).toEqual({ A: 1 });
58
+ });
59
+
60
+ test("passes the declared entity names through to the reader", async () => {
61
+ const reader = vi.fn(async () => deepObservation({}));
62
+ await observeDeep(pluginWith({ observeResourcesDeep: reader as never }), {
63
+ environment: "prod",
64
+ buildOutput: "out",
65
+ entities: entities({ a: { entityType: "T", props: {} } }),
66
+ owned: true,
67
+ });
68
+ expect(reader).toHaveBeenCalledWith(
69
+ expect.objectContaining({ environment: "prod", buildOutput: "out", entityNames: ["a"], owned: true }),
70
+ );
71
+ });
72
+ });
73
+
74
+ describe("mergeDeepObservations", () => {
75
+ test("a resource found in any part is present everywhere", () => {
76
+ const merged = mergeDeepObservations([
77
+ { resources: {}, unobserved: { a: { reason: "read-failed" } } },
78
+ { resources: { a: { type: "T", properties: {} } }, unobserved: {} },
79
+ ]);
80
+ expect(merged.unobserved).toEqual({});
81
+ expect(Object.keys(merged.resources)).toEqual(["a"]);
82
+ });
83
+ });
84
+
85
+ describe("diffDeepObservation", () => {
86
+ const hooks: DeepNormalizationHooks = {
87
+ prune(node) {
88
+ // Server-populated everywhere.
89
+ if (node.pattern === "Arn") return true;
90
+ // A provider default, subtracted only where source is silent.
91
+ return node.side === "live" && node.counterpart === "absent" && node.pattern === "Path" && node.value === "/";
92
+ },
93
+ };
94
+
95
+ test("applies the lexicon's hooks to both sides", () => {
96
+ const result = diffDeepObservation(
97
+ entities({ r: { entityType: "AWS::IAM::Role", props: { Arn: "declared-arn", RoleName: "r" } } }),
98
+ {
99
+ resources: {
100
+ r: { type: "AWS::IAM::Role", properties: { Arn: "arn:aws:iam::1:role/r", RoleName: "r", Path: "/" } },
101
+ },
102
+ unobserved: {},
103
+ },
104
+ hooks,
105
+ );
106
+ // Arn pruned on both sides; Path subtracted as an undeclared default.
107
+ expect(result.drifted).toEqual([]);
108
+ expect(result.unchanged).toEqual(["r"]);
109
+ });
110
+
111
+ test("a declared property at its default is still compared", () => {
112
+ const result = diffDeepObservation(
113
+ entities({ r: { entityType: "AWS::IAM::Role", props: { Path: "/" } } }),
114
+ { resources: { r: { type: "AWS::IAM::Role", properties: { Path: "/team/" } } }, unobserved: {} },
115
+ hooks,
116
+ );
117
+ expect(result.drifted[0].changes).toEqual([
118
+ { path: "Path", kind: "changed", declared: "/", live: "/team/" },
119
+ ]);
120
+ });
121
+
122
+ test("unevaluated declared props never read as drift", () => {
123
+ class Sub {
124
+ constructor(readonly t: string) {}
125
+ }
126
+ const result = diffDeepObservation(
127
+ entities({ b: { entityType: "AWS::S3::Bucket", props: { BucketName: new Sub("${AWS::StackName}") } } }),
128
+ { resources: { b: { type: "AWS::S3::Bucket", properties: { BucketName: "prod-data" } } }, unobserved: {} },
129
+ hooks,
130
+ );
131
+ expect(result.drifted).toEqual([]);
132
+ });
133
+ });
134
+
135
+ describe("deepDiffForLexicon", () => {
136
+ test("an unreadable deep read surfaces as a hole with a reason, not as clean", async () => {
137
+ const result = await deepDiffForLexicon(
138
+ pluginWith({
139
+ observeResourcesDeep: async () =>
140
+ deepObservation({}, { a: { type: "T", reason: "no-credentials", detail: "token expired" } }),
141
+ }),
142
+ {
143
+ environment: "prod",
144
+ buildOutput: "",
145
+ entities: entities({ a: { entityType: "T", props: { A: 1 } } }),
146
+ },
147
+ );
148
+ expect(result.drifted).toEqual([]);
149
+ expect(result.unobserved).toEqual([
150
+ { name: "a", type: "T", reason: "no-credentials", detail: "token expired" },
151
+ ]);
152
+ });
153
+
154
+ test("subtracts the accepted baseline it is handed", async () => {
155
+ const plugin = pluginWith({
156
+ observeResourcesDeep: async () =>
157
+ deepObservation({ a: { type: "T", properties: { Extra: "accepted-value" } } }),
158
+ });
159
+ const opts = {
160
+ environment: "prod",
161
+ buildOutput: "",
162
+ entities: entities({ a: { entityType: "T", props: {} } }),
163
+ };
164
+ const withoutBaseline = await deepDiffForLexicon(plugin, opts);
165
+ expect(withoutBaseline.drifted[0].changes[0].path).toBe("Extra");
166
+
167
+ const withBaseline = await deepDiffForLexicon(plugin, {
168
+ ...opts,
169
+ baseline: { a: { accepted: [{ path: "Extra", value: "accepted-value" }] } },
170
+ });
171
+ expect(withBaseline.drifted).toEqual([]);
172
+ expect(withBaseline.accepted[0].changes[0].path).toBe("Extra");
173
+ });
174
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Deep observation orchestration (#1014) — call one lexicon's
3
+ * `observeResourcesDeep()`, put the declared trees in the same shape, and diff.
4
+ *
5
+ * The sibling of ./observe.ts on the thin path, and it inherits that path's
6
+ * rules: a reader that throws does not vanish, it reports every declared entity
7
+ * NOT-OBSERVED with `read-failed` (#1089); a multi-stack read merges with
8
+ * present > not-observed > absent. A deep read that fails is a hole with a
9
+ * reason, never a thin-but-clean answer.
10
+ *
11
+ * The normalization is applied here, on both sides, with the lexicon's own
12
+ * hooks. The reader already normalized what it returned — that is the contract
13
+ * — but only core can normalize the *declared* tree, and only core knows which
14
+ * paths exist on the other side, which is what
15
+ * {@link import("../deep-observation").DeepNode.counterpart} needs for default
16
+ * subtraction. Re-running the pass over an already-normalized live tree is
17
+ * idempotent for every hook that does not consult `counterpart`.
18
+ */
19
+
20
+ import type { ObservationLexicon } from "../lexicon";
21
+ import {
22
+ deepPathSet,
23
+ normalizeDeepObservation,
24
+ normalizeDeepProperties,
25
+ type DeepNormalizationHooks,
26
+ type DeepResourceObservation,
27
+ type NormalizedDeepObservation,
28
+ } from "../deep-observation";
29
+ import { unobservedAll, type UnobservedEntity } from "../observation";
30
+ import { diffDeep, type DeclaredDeepEntity, type DeepDiffResult } from "./deep-diff";
31
+ import type { BaselineLexicon } from "./observation-baseline";
32
+
33
+ /** Declared entities for one lexicon, in the shape the observe paths pass around. */
34
+ export type DeclaredEntities = Map<string, { entityType: string; props: Record<string, unknown> }>;
35
+
36
+ export interface DeepObserveOptions {
37
+ environment: string;
38
+ buildOutput: string;
39
+ entities: DeclaredEntities;
40
+ /** Deployed stack for a multi-stack project (#932). */
41
+ stack?: string;
42
+ /** Component projects deploy one stack per component; read them all and merge. */
43
+ componentStacks?: string[];
44
+ owned?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Merge several deep observations of the same lexicon (the multi-stack read).
49
+ * Precedence matches the thin contract: present > not-observed > absent.
50
+ */
51
+ export function mergeDeepObservations(
52
+ parts: Iterable<NormalizedDeepObservation>,
53
+ ): NormalizedDeepObservation {
54
+ const resources: Record<string, DeepResourceObservation> = {};
55
+ const unobserved: Record<string, UnobservedEntity> = {};
56
+ for (const part of parts) {
57
+ Object.assign(resources, part.resources);
58
+ Object.assign(unobserved, part.unobserved);
59
+ }
60
+ for (const name of Object.keys(resources)) delete unobserved[name];
61
+ return { resources, unobserved };
62
+ }
63
+
64
+ /**
65
+ * Read one lexicon's live property trees. Never throws: a thrown reader becomes
66
+ * a NOT-OBSERVED verdict for every declared entity, with the error as the
67
+ * detail, so the caller sees a hole rather than an empty tree that reads as
68
+ * "no properties drifted".
69
+ */
70
+ export async function observeDeep(
71
+ plugin: ObservationLexicon,
72
+ opts: DeepObserveOptions,
73
+ ): Promise<NormalizedDeepObservation> {
74
+ const entityNames = Array.from(opts.entities.keys());
75
+ if (!plugin.observeResourcesDeep) {
76
+ return { resources: {}, unobserved: {} };
77
+ }
78
+ const base = {
79
+ environment: opts.environment,
80
+ buildOutput: opts.buildOutput,
81
+ entityNames,
82
+ entities: opts.entities,
83
+ ...(opts.owned !== undefined ? { owned: opts.owned } : {}),
84
+ };
85
+ try {
86
+ if (opts.componentStacks && opts.componentStacks.length > 0) {
87
+ const parts: NormalizedDeepObservation[] = [];
88
+ for (const stack of opts.componentStacks) {
89
+ parts.push(normalizeDeepObservation(await plugin.observeResourcesDeep({ ...base, stack })));
90
+ }
91
+ return mergeDeepObservations(parts);
92
+ }
93
+ return normalizeDeepObservation(
94
+ await plugin.observeResourcesDeep({ ...base, ...(opts.stack ? { stack: opts.stack } : {}) }),
95
+ );
96
+ } catch (err) {
97
+ const message = err instanceof Error ? err.message : String(err);
98
+ return {
99
+ resources: {},
100
+ unobserved: unobservedAll(entityNames, "read-failed", message, opts.entities),
101
+ };
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Normalize both sides into the same shape, then diff.
107
+ *
108
+ * Split out from {@link deepDiffForLexicon} so the pure half is testable
109
+ * without a plugin: given declared entities, a live observation and a baseline,
110
+ * this is a deterministic function.
111
+ */
112
+ export function diffDeepObservation(
113
+ entities: DeclaredEntities,
114
+ live: NormalizedDeepObservation,
115
+ hooks?: DeepNormalizationHooks,
116
+ baseline?: BaselineLexicon,
117
+ ): DeepDiffResult {
118
+ const declared: Record<string, DeclaredDeepEntity> = {};
119
+ const normalizedLive: Record<string, DeepResourceObservation> = {};
120
+
121
+ for (const [name, entity] of entities) {
122
+ const liveEntity = live.resources[name];
123
+ const declaredRaw = entity.props ?? {};
124
+ const liveRaw = liveEntity?.properties ?? {};
125
+ const declaredPaths = deepPathSet(declaredRaw);
126
+ const livePaths = deepPathSet(liveRaw);
127
+
128
+ declared[name] = {
129
+ type: entity.entityType,
130
+ properties: normalizeDeepProperties(declaredRaw, {
131
+ entityType: entity.entityType,
132
+ side: "declared",
133
+ hooks,
134
+ counterpartPaths: livePaths,
135
+ }),
136
+ };
137
+ if (liveEntity) {
138
+ normalizedLive[name] = {
139
+ type: liveEntity.type || entity.entityType,
140
+ ...(liveEntity.physicalId ? { physicalId: liveEntity.physicalId } : {}),
141
+ properties: normalizeDeepProperties(liveRaw, {
142
+ entityType: liveEntity.type || entity.entityType,
143
+ side: "live",
144
+ hooks,
145
+ counterpartPaths: declaredPaths,
146
+ }),
147
+ };
148
+ }
149
+ }
150
+
151
+ // Live entities nobody declared keep their reader-normalized trees — there is
152
+ // no declared side to normalize them against, and `diffDeep` reports them as
153
+ // undeclared entities rather than diffing their properties.
154
+ for (const [name, liveEntity] of Object.entries(live.resources)) {
155
+ if (!normalizedLive[name]) normalizedLive[name] = liveEntity;
156
+ }
157
+
158
+ return diffDeep({
159
+ declared,
160
+ live: { resources: normalizedLive, unobserved: live.unobserved },
161
+ baseline,
162
+ hooks,
163
+ });
164
+ }
165
+
166
+ /** Read one lexicon deeply and diff it against source and the accepted baseline. */
167
+ export async function deepDiffForLexicon(
168
+ plugin: ObservationLexicon,
169
+ opts: DeepObserveOptions & { baseline?: BaselineLexicon },
170
+ ): Promise<DeepDiffResult> {
171
+ const live = await observeDeep(plugin, opts);
172
+ return diffDeepObservation(opts.entities, live, plugin.deepNormalizationHooks, opts.baseline);
173
+ }
@@ -3,6 +3,9 @@ export * from "./git";
3
3
  export * from "./digest";
4
4
  export * from "./snapshot";
5
5
  export * from "./live-diff";
6
+ export * from "./deep-diff";
7
+ export * from "./deep-observe";
8
+ export * from "./observation-baseline";
6
9
  export * from "./change-set";
7
10
  export * from "./affected";
8
11
  export * from "./release-ledger";
@@ -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", () => {