@intentius/chant 0.29.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 (54) hide show
  1. package/dist/cli/handlers/graph.d.ts.map +1 -1
  2. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +14 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/deep-observation.d.ts +257 -0
  7. package/dist/deep-observation.d.ts.map +1 -0
  8. package/dist/discovery/fold-import.d.ts.map +1 -1
  9. package/dist/fold/fold.d.ts +23 -3
  10. package/dist/fold/fold.d.ts.map +1 -1
  11. package/dist/fold/subset.d.ts +9 -0
  12. package/dist/fold/subset.d.ts.map +1 -1
  13. package/dist/graph-ir.d.ts +44 -0
  14. package/dist/graph-ir.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +47 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/deep-diff.d.ts +103 -0
  20. package/dist/lifecycle/deep-diff.d.ts.map +1 -0
  21. package/dist/lifecycle/deep-observe.d.ts +62 -0
  22. package/dist/lifecycle/deep-observe.d.ts.map +1 -0
  23. package/dist/lifecycle/index.d.ts +3 -0
  24. package/dist/lifecycle/index.d.ts.map +1 -1
  25. package/dist/lifecycle/observation-baseline.d.ts +118 -0
  26. package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
  27. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  28. package/package.json +1 -1
  29. package/src/cli/handlers/graph.test.ts +86 -0
  30. package/src/cli/handlers/graph.ts +64 -3
  31. package/src/cli/handlers/lifecycle.test.ts +126 -1
  32. package/src/cli/handlers/lifecycle.ts +184 -3
  33. package/src/cli/main.test.ts +6 -0
  34. package/src/cli/main.ts +12 -0
  35. package/src/cli/registry.ts +14 -0
  36. package/src/deep-observation.test.ts +234 -0
  37. package/src/deep-observation.ts +489 -0
  38. package/src/discovery/fold-import.test.ts +372 -1
  39. package/src/discovery/fold-import.ts +235 -79
  40. package/src/fold/fold.test.ts +105 -0
  41. package/src/fold/fold.ts +88 -18
  42. package/src/fold/subset.test.ts +38 -7
  43. package/src/fold/subset.ts +9 -0
  44. package/src/graph-ir.ts +47 -0
  45. package/src/index.ts +1 -0
  46. package/src/lexicon.ts +59 -0
  47. package/src/lifecycle/deep-diff.test.ts +157 -0
  48. package/src/lifecycle/deep-diff.ts +213 -0
  49. package/src/lifecycle/deep-observe.test.ts +174 -0
  50. package/src/lifecycle/deep-observe.ts +173 -0
  51. package/src/lifecycle/index.ts +3 -0
  52. package/src/lifecycle/observation-baseline.test.ts +99 -0
  53. package/src/lifecycle/observation-baseline.ts +217 -0
  54. package/src/lifecycle/snapshot.ts +6 -11
@@ -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";
@@ -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
+ }