@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
@@ -95,6 +95,12 @@ describe("parseArgs", () => {
95
95
  expect(result.down).toBe(true);
96
96
  });
97
97
 
98
+ test("parses graph --components --format ir --projection <lexicon>", () => {
99
+ const result = parseArgs(["graph", "--components", "--format", "ir", "--projection", "gitlab"]);
100
+ expect(result.components).toBe(true);
101
+ expect(result.projection).toBe("gitlab");
102
+ });
103
+
98
104
  test("combines multiple options", () => {
99
105
  const result = parseArgs([
100
106
  "build",
package/src/cli/main.ts CHANGED
@@ -64,6 +64,7 @@ const BOOLEAN_FLAGS = new Set([
64
64
  "--json",
65
65
  "--progress-json",
66
66
  "--update-snapshot",
67
+ "--update-baseline",
67
68
  "--run-examples",
68
69
  "--check",
69
70
  "--bump",
@@ -269,6 +270,8 @@ export function parseArgs(args: string[]): ParsedArgs {
269
270
  result.progressJson = true;
270
271
  } else if (arg === "--update-snapshot") {
271
272
  result.updateSnapshot = true;
273
+ } else if (arg === "--update-baseline") {
274
+ result.updateBaseline = true;
272
275
  } else if (arg === "--run-examples") {
273
276
  result.runExamples = true;
274
277
  } else if (arg === "--pinned-digest") {
@@ -312,6 +315,8 @@ export function parseArgs(args: string[]): ParsedArgs {
312
315
  (result.param ??= []).push(args[++i]);
313
316
  } else if (arg === "--params-file") {
314
317
  result.paramsFile = args[++i];
318
+ } else if (arg === "--projection") {
319
+ result.projection = args[++i];
315
320
  } else if (arg.startsWith("--")) {
316
321
  // chant #1127 — every recognized flag is matched above; anything left
317
322
  // starting with `--` is unrecognized, whether it arrived bare
@@ -421,12 +426,19 @@ Ops:
421
426
  --layout-engine graphviz to use dot instead;
422
427
  --detail 0..3: stacks|composites|declarables|attributes;
423
428
  --lens lexicon:<n>|stack:<n>|blast:<node> (--up/--down))
429
+ --components --format ir --projection gitlab|github|forgejo:
430
+ add the CI/pipeline projection (stages/jobs/needs) to
431
+ the component-graph IR, from the same generator
432
+ 'build --components --generate' uses (#989)
424
433
 
425
434
  Lifecycle (alias: lc):
426
435
  lifecycle snapshot <env> Query API, save metadata to orphan branch
427
436
  lifecycle show <env> Show latest lifecycle snapshot
428
437
  lifecycle diff <env> Compare current build against last snapshot
429
438
  --live: query cloud now and detect drift
439
+ (lexicons with a deep reader also report
440
+ property-level drift; --update-baseline records
441
+ what it reports as accepted so it stops alerting)
430
442
  lifecycle plan <env> Typed change set (create/update/delete/adopt) vs live
431
443
  lifecycle affected Stacks a change affects (--base <ref> [--include-dependents])
432
444
  --json: emit the ChangeSet as JSON
@@ -118,6 +118,14 @@ export interface ParsedArgs {
118
118
  theme?: string;
119
119
  /** `chant dev surface-diff --update-snapshot` — write the fresh snapshot as the new baseline */
120
120
  updateSnapshot?: boolean;
121
+ /**
122
+ * `chant lifecycle diff <env> --live --update-baseline` (#1014) — record every
123
+ * property-level deviation this run reports as *accepted*, so it stops
124
+ * re-alerting. Value-bound: a later change to the accepted value is drift
125
+ * again. Writes `<env>/observation-baseline.json` on the chant/lifecycle
126
+ * orphan branch; never touches the cloud.
127
+ */
128
+ updateBaseline?: boolean;
121
129
  /** `chant dev surface-diff --run-examples` — also run the example build harness */
122
130
  runExamples?: boolean;
123
131
  /** `chant dev surface-diff --pinned-digest <file>` — path to SHA-256 digest file for supply-chain verification */
@@ -154,6 +162,12 @@ export interface ParsedArgs {
154
162
  param?: string[];
155
163
  /** `chant build --params-file <path>` (#1064) — a JSON file of `{ "name": value }` build-time parameter values. Second precedence, after `--param`. */
156
164
  paramsFile?: string;
165
+ /** `chant graph --components --format ir --projection <lexicon>` (#989) — add
166
+ * the CI/pipeline projection (stages/jobs/`needs`) to the component-graph IR,
167
+ * synthesized by `<lexicon>`'s `generateComponentPipeline` (gitlab, github,
168
+ * forgejo today) — the same generator `chant build --components --generate
169
+ * <lexicon>` uses, reused rather than re-derived. */
170
+ projection?: string;
157
171
  }
158
172
 
159
173
  /**
@@ -5,6 +5,9 @@ import {
5
5
  resolveAutoReleaseDisabled,
6
6
  resolveFoldEnabled,
7
7
  resolveSbomFormat,
8
+ environmentName,
9
+ environmentNames,
10
+ environmentEndpoint,
8
11
  } from "./config";
9
12
  import { writeFileSync, mkdirSync, rmSync } from "fs";
10
13
  import { join } from "path";
@@ -134,6 +137,78 @@ describe("loadChantConfig", () => {
134
137
  });
135
138
  });
136
139
 
140
+ // #1166 — `environments` accepts either a bare name (unchanged) or
141
+ // `{ name, endpoint }`, so a declared environment can be self-sufficient for
142
+ // `--live` reads without an ambient AWS_ENDPOINT_URL export.
143
+ describe("environments (#1166 — string or { name, endpoint })", () => {
144
+ test("bare string environments load exactly as before", async () => {
145
+ writeFileSync(
146
+ join(TEST_DIR, "chant.config.json"),
147
+ JSON.stringify({ environments: ["dev", "prod"] }),
148
+ );
149
+
150
+ const result = await loadChantConfig(TEST_DIR);
151
+ expect(result.config.environments).toEqual(["dev", "prod"]);
152
+ });
153
+
154
+ test("an object entry with a declared endpoint loads alongside bare strings", async () => {
155
+ writeFileSync(
156
+ join(TEST_DIR, "chant.config.json"),
157
+ JSON.stringify({
158
+ environments: ["prod", { name: "floci", endpoint: "http://localhost:4566" }],
159
+ }),
160
+ );
161
+
162
+ const result = await loadChantConfig(TEST_DIR);
163
+ expect(result.config.environments).toEqual([
164
+ "prod",
165
+ { name: "floci", endpoint: "http://localhost:4566" },
166
+ ]);
167
+ });
168
+
169
+ test("an object entry with no endpoint is legal (name-only, same as a bare string)", async () => {
170
+ writeFileSync(
171
+ join(TEST_DIR, "chant.config.json"),
172
+ JSON.stringify({ environments: [{ name: "staging" }] }),
173
+ );
174
+
175
+ const result = await loadChantConfig(TEST_DIR);
176
+ expect(result.config.environments).toEqual([{ name: "staging" }]);
177
+ });
178
+
179
+ test("rejects an environments entry that is neither a string nor { name }", async () => {
180
+ writeFileSync(
181
+ join(TEST_DIR, "chant.config.json"),
182
+ JSON.stringify({ environments: [{ endpoint: "http://localhost:4566" }] }),
183
+ );
184
+
185
+ await expect(loadChantConfig(TEST_DIR)).rejects.toThrow(/environments/);
186
+ });
187
+ });
188
+
189
+ describe("environmentName / environmentNames / environmentEndpoint (#1166)", () => {
190
+ test("environmentName reduces either form to its name", () => {
191
+ expect(environmentName("prod")).toBe("prod");
192
+ expect(environmentName({ name: "floci", endpoint: "http://localhost:4566" })).toBe("floci");
193
+ expect(environmentName({ name: "staging" })).toBe("staging");
194
+ });
195
+
196
+ test("environmentNames maps a mixed list, and passes undefined through", () => {
197
+ expect(environmentNames(["prod", { name: "floci", endpoint: "http://x" }])).toEqual(["prod", "floci"]);
198
+ expect(environmentNames(undefined)).toBeUndefined();
199
+ expect(environmentNames([])).toEqual([]);
200
+ });
201
+
202
+ test("environmentEndpoint resolves the declared endpoint, or undefined otherwise", () => {
203
+ const environments = ["prod", { name: "floci", endpoint: "http://localhost:4566" }, { name: "staging" }];
204
+ expect(environmentEndpoint(environments, "floci")).toBe("http://localhost:4566");
205
+ expect(environmentEndpoint(environments, "prod")).toBeUndefined(); // bare string, no endpoint
206
+ expect(environmentEndpoint(environments, "staging")).toBeUndefined(); // object, but no endpoint set
207
+ expect(environmentEndpoint(environments, "unknown")).toBeUndefined(); // not declared at all
208
+ expect(environmentEndpoint(undefined, "floci")).toBeUndefined();
209
+ });
210
+ });
211
+
137
212
  describe("resolveFoldEnabled (#1134 — fold is the default build path)", () => {
138
213
  test("default (no flag, no config) → fold ON", () => {
139
214
  expect(resolveFoldEnabled({})).toBe(true);
package/src/config.ts CHANGED
@@ -10,13 +10,61 @@ import type { BuildParamsConfig } from "./build-params";
10
10
  import { findProjectConfig } from "./project-root";
11
11
  import { evaluateProjectConfig } from "./config-sandbox";
12
12
 
13
+ /**
14
+ * One project-declared environment (chant #1166). Historically always a bare
15
+ * name (`"floci"`); an entry can now instead carry the endpoint that
16
+ * environment's `--live` reads should target (`{ name: "floci", endpoint:
17
+ * "http://localhost:4566" }`), so a project pointed at a local emulator is
18
+ * self-sufficient — no ambient `AWS_ENDPOINT_URL` export required to avoid
19
+ * silently querying real AWS. See {@link environmentName}, {@link
20
+ * environmentNames}, {@link environmentEndpoint} below, and
21
+ * `./live-endpoint.ts`'s `applyLiveEndpoint`, the CLI-side consumer.
22
+ */
23
+ export type EnvironmentDeclaration = string | { name: string; endpoint?: string };
24
+
25
+ /** The declared name of one `environments` entry, whichever form it takes. */
26
+ export function environmentName(entry: EnvironmentDeclaration): string {
27
+ return typeof entry === "string" ? entry : entry.name;
28
+ }
29
+
30
+ /**
31
+ * Every declared environment's name, in `environments` order. `undefined` in,
32
+ * `undefined` out — mirrors the field itself being optional, so a caller that
33
+ * already writes `config.environments?.something` can keep doing so:
34
+ * `environmentNames(config.environments)?.includes(name)`.
35
+ */
36
+ export function environmentNames(environments: EnvironmentDeclaration[] | undefined): string[] | undefined {
37
+ return environments?.map(environmentName);
38
+ }
39
+
40
+ /**
41
+ * The endpoint `name` declares (chant #1166) — `undefined` for a bare-string
42
+ * entry, an entry with no `endpoint` set, or a name this project doesn't
43
+ * declare at all. `./live-endpoint.ts`'s `applyLiveEndpoint` is the consumer:
44
+ * it injects this into the ambient env var each observing lexicon's CLI
45
+ * shell-out reads (e.g. `AWS_ENDPOINT_URL`), unless that var is already set —
46
+ * ambient always wins.
47
+ */
48
+ export function environmentEndpoint(environments: EnvironmentDeclaration[] | undefined, name: string): string | undefined {
49
+ const found = environments?.find((e) => environmentName(e) === name);
50
+ return found && typeof found !== "string" ? found.endpoint : undefined;
51
+ }
52
+
13
53
  /**
14
54
  * Zod schema for ChantConfig validation.
15
55
  */
56
+ const EnvironmentEntrySchema = z.union([
57
+ z.string().min(1),
58
+ z.object({
59
+ name: z.string().min(1),
60
+ endpoint: z.string().min(1).optional(),
61
+ }),
62
+ ]);
63
+
16
64
  export const ChantConfigSchema = z.object({
17
65
  lexicons: z.array(z.string().min(1)).optional(),
18
66
  capabilities: z.array(z.string().min(1)).optional(),
19
- environments: z.array(z.string().min(1)).optional(),
67
+ environments: z.array(EnvironmentEntrySchema).optional(),
20
68
  sourceDir: z.string().min(1).optional(),
21
69
  lint: z.record(z.string(), z.unknown()).optional(),
22
70
  ownership: z.object({
@@ -87,8 +135,18 @@ export interface ChantConfig {
87
135
  */
88
136
  capabilities?: string[];
89
137
 
90
- /** Environment names (e.g. ["staging", "prod"]) */
91
- environments?: string[];
138
+ /**
139
+ * Declared environments (e.g. `["staging", "prod"]`). An entry is either a
140
+ * bare name (unchanged since always) or `{ name, endpoint }` (#1166) when
141
+ * that environment's `--live` reads should target a specific endpoint —
142
+ * a local emulator like Floci (`{ name: "floci", endpoint:
143
+ * "http://localhost:4566" }`) chief among them. See {@link
144
+ * environmentEndpoint} and `./live-endpoint.ts`'s `applyLiveEndpoint`, which
145
+ * injects the declared endpoint into the ambient env var each observing
146
+ * lexicon's CLI shell-out reads (e.g. `AWS_ENDPOINT_URL`) — unless that var
147
+ * is already set, in which case the ambient value always wins.
148
+ */
149
+ environments?: EnvironmentDeclaration[];
92
150
 
93
151
  /**
94
152
  * Directory (relative to the project root) that holds the chant infrastructure
@@ -0,0 +1,234 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ MASKED,
4
+ UNRESOLVED,
5
+ deepObservation,
6
+ deepPathSet,
7
+ deepValueEqual,
8
+ flattenDeepProperties,
9
+ isDeepObservationResult,
10
+ isSensitiveKey,
11
+ normalizeDeepObservation,
12
+ normalizeDeepProperties,
13
+ type DeepNormalizationHooks,
14
+ } from "./deep-observation";
15
+
16
+ describe("the deep observation envelope", () => {
17
+ test("is discriminated by its version literal", () => {
18
+ expect(isDeepObservationResult(deepObservation({}))).toBe(true);
19
+ expect(isDeepObservationResult({ resources: {} })).toBe(false);
20
+ expect(isDeepObservationResult({ observation: "v1", resources: {} })).toBe(false);
21
+ expect(isDeepObservationResult(null)).toBe(false);
22
+ });
23
+
24
+ test("omits an empty unobserved map rather than emitting one", () => {
25
+ expect(deepObservation({}, {})).toEqual({ deepObservation: "v1", resources: {} });
26
+ });
27
+
28
+ test("normalizing undefined yields two empty maps", () => {
29
+ expect(normalizeDeepObservation(undefined)).toEqual({ resources: {}, unobserved: {} });
30
+ });
31
+
32
+ test("carries the tri-state contract's unobserved entries verbatim", () => {
33
+ const result = deepObservation(
34
+ { a: { type: "T", properties: {} } },
35
+ { b: { type: "T", reason: "unsupported-kind", detail: "no reader" } },
36
+ );
37
+ expect(normalizeDeepObservation(result).unobserved.b.reason).toBe("unsupported-kind");
38
+ });
39
+ });
40
+
41
+ describe("normalizeDeepProperties", () => {
42
+ test("canonicalizes object key order", () => {
43
+ const out = normalizeDeepProperties(
44
+ { zeta: 1, alpha: 2, mid: { z: 1, a: 2 } },
45
+ { entityType: "T", side: "live" },
46
+ );
47
+ expect(Object.keys(out)).toEqual(["alpha", "mid", "zeta"]);
48
+ expect(Object.keys(out.mid as Record<string, unknown>)).toEqual(["a", "z"]);
49
+ });
50
+
51
+ test("leaves array order alone with no ordering hook", () => {
52
+ const out = normalizeDeepProperties({ Tags: [{ Key: "z" }, { Key: "a" }] }, { entityType: "T", side: "live" });
53
+ expect(out.Tags).toEqual([{ Key: "z" }, { Key: "a" }]);
54
+ });
55
+
56
+ test("orders an array when the hook keys every element", () => {
57
+ const hooks: DeepNormalizationHooks = {
58
+ orderKey: (el) => (el.pattern === "Tags" ? String((el.element as { Key: string }).Key) : undefined),
59
+ };
60
+ const out = normalizeDeepProperties(
61
+ { Tags: [{ Key: "z" }, { Key: "a" }], Steps: ["second", "first"] },
62
+ { entityType: "T", side: "live", hooks },
63
+ );
64
+ expect(out.Tags).toEqual([{ Key: "a" }, { Key: "z" }]);
65
+ // No key for Steps — order is left alone, because list order is often
66
+ // semantic and a guess here is worse than a stable false negative.
67
+ expect(out.Steps).toEqual(["second", "first"]);
68
+ });
69
+
70
+ test("a partially-keyed array keeps its order", () => {
71
+ const hooks: DeepNormalizationHooks = {
72
+ orderKey: (el) => (el.index === 0 ? "a" : undefined),
73
+ };
74
+ const out = normalizeDeepProperties({ List: ["x", "y"] }, { entityType: "T", side: "live", hooks });
75
+ expect(out.List).toEqual(["x", "y"]);
76
+ });
77
+
78
+ test("prunes by hook, and prunes the whole subtree", () => {
79
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.pattern === "Status" };
80
+ const out = normalizeDeepProperties(
81
+ { Status: { Phase: "Ready", Conditions: [1, 2] }, Name: "n" },
82
+ { entityType: "T", side: "live", hooks },
83
+ );
84
+ expect(out).toEqual({ Name: "n" });
85
+ });
86
+
87
+ test("hooks see an index-erased pattern alongside the exact path", () => {
88
+ const seen: Array<[string, string]> = [];
89
+ const hooks: DeepNormalizationHooks = {
90
+ prune: (n) => {
91
+ seen.push([n.path, n.pattern]);
92
+ return false;
93
+ },
94
+ };
95
+ normalizeDeepProperties({ Tags: [{ Key: "a" }] }, { entityType: "T", side: "live", hooks });
96
+ expect(seen).toContainEqual(["Tags[0].Key", "Tags[].Key"]);
97
+ });
98
+
99
+ test("counterpart is `unknown` for a one-sided pass and resolved when paths are supplied", () => {
100
+ const seen: Record<string, string> = {};
101
+ const hooks: DeepNormalizationHooks = {
102
+ prune: (n) => {
103
+ seen[n.pattern] = n.counterpart;
104
+ return false;
105
+ },
106
+ };
107
+ normalizeDeepProperties({ A: 1, B: 2 }, { entityType: "T", side: "live", hooks });
108
+ expect(seen).toEqual({ A: "unknown", B: "unknown" });
109
+
110
+ normalizeDeepProperties(
111
+ { A: 1, B: 2 },
112
+ { entityType: "T", side: "live", hooks, counterpartPaths: deepPathSet({ A: 9 }) },
113
+ );
114
+ expect(seen).toEqual({ A: "present", B: "absent" });
115
+ });
116
+
117
+ test("an array element counts as declared when the pattern is declared at any index", () => {
118
+ const seen: Record<string, string> = {};
119
+ const hooks: DeepNormalizationHooks = {
120
+ prune: (n) => {
121
+ seen[n.path] = n.counterpart;
122
+ return false;
123
+ },
124
+ };
125
+ normalizeDeepProperties(
126
+ { Tags: [{ Key: "b" }, { Key: "a" }] },
127
+ { entityType: "T", side: "live", hooks, counterpartPaths: deepPathSet({ Tags: [{ Key: "a" }] }) },
128
+ );
129
+ // Source declares one tag; both live tags match the `Tags[].Key` pattern.
130
+ expect(seen["Tags[1].Key"]).toBe("present");
131
+ });
132
+
133
+ test("masks secret-bearing property names without recursing into them", () => {
134
+ const out = normalizeDeepProperties(
135
+ { MasterUserPassword: "hunter2", Nested: { ClientSecret: { a: 1 } }, Tags: [{ Key: "k", Value: "v" }] },
136
+ { entityType: "T", side: "live" },
137
+ );
138
+ expect(out.MasterUserPassword).toBe(MASKED);
139
+ expect((out.Nested as Record<string, unknown>).ClientSecret).toBe(MASKED);
140
+ // `Key`/`Value` are not secrets — masking them would be its own drift signal.
141
+ expect(out.Tags).toEqual([{ Key: "k", Value: "v" }]);
142
+ });
143
+
144
+ test("collapses non-JSON values (an unevaluated intrinsic) to the unresolved sentinel", () => {
145
+ class SubIntrinsic {
146
+ constructor(readonly template: string) {}
147
+ }
148
+ const out = normalizeDeepProperties(
149
+ { BucketName: new SubIntrinsic("${AWS::StackName}-data"), Plain: "x" },
150
+ { entityType: "T", side: "declared" },
151
+ );
152
+ expect(out.BucketName).toBe(UNRESOLVED);
153
+ expect(out.Plain).toBe("x");
154
+ });
155
+
156
+ test("drops undefined but keeps null", () => {
157
+ const out = normalizeDeepProperties({ a: undefined, b: null }, { entityType: "T", side: "live" });
158
+ expect("a" in out).toBe(false);
159
+ expect(out.b).toBeNull();
160
+ });
161
+ });
162
+
163
+ describe("isSensitiveKey", () => {
164
+ test("matches the secret-bearing names and nothing broader", () => {
165
+ for (const k of ["Password", "clientSecret", "AuthToken", "PrivateKey", "credentials", "ConnectionString"]) {
166
+ expect(isSensitiveKey(k), k).toBe(true);
167
+ }
168
+ for (const k of ["Key", "KeyName", "KmsKeyId", "Value", "Name"]) {
169
+ expect(isSensitiveKey(k), k).toBe(false);
170
+ }
171
+ });
172
+ });
173
+
174
+ describe("flattenDeepProperties", () => {
175
+ test("flattens to leaf paths, keeping empty containers as values", () => {
176
+ const flat = flattenDeepProperties({
177
+ A: { B: 1 },
178
+ List: [{ C: "x" }, "y"],
179
+ EmptyObj: {},
180
+ EmptyArr: [],
181
+ });
182
+ expect(Object.fromEntries(flat)).toEqual({
183
+ "A.B": 1,
184
+ "List[0].C": "x",
185
+ "List[1]": "y",
186
+ EmptyObj: {},
187
+ EmptyArr: [],
188
+ });
189
+ });
190
+ });
191
+
192
+ describe("flattenDeepProperties with an ordering hook", () => {
193
+ const hooks: DeepNormalizationHooks = {
194
+ orderKey: (el) => (el.pattern === "Tags" ? String((el.element as { Key: string }).Key) : undefined),
195
+ };
196
+ const opts = { entityType: "T", side: "live" as const, hooks };
197
+
198
+ test("addresses a keyable array by key, so an inserted element shifts nothing", () => {
199
+ const flat = flattenDeepProperties({ Tags: [{ Key: "env", Value: "prod" }] }, opts);
200
+ expect([...flat.keys()].sort()).toEqual(["Tags[#env].Key", "Tags[#env].Value"]);
201
+
202
+ const withExtra = flattenDeepProperties(
203
+ { Tags: [{ Key: "cost", Value: "x" }, { Key: "env", Value: "prod" }] },
204
+ opts,
205
+ );
206
+ expect(withExtra.get("Tags[#env].Value")).toBe("prod");
207
+ });
208
+
209
+ test("falls back to positional paths when keys collide", () => {
210
+ const flat = flattenDeepProperties({ Tags: [{ Key: "env", Value: "a" }, { Key: "env", Value: "b" }] }, opts);
211
+ expect([...flat.keys()]).toContain("Tags[0].Value");
212
+ });
213
+
214
+ test("falls back to positional paths for an array the hook cannot key", () => {
215
+ const flat = flattenDeepProperties({ Steps: ["a", "b"] }, opts);
216
+ expect([...flat.keys()]).toEqual(["Steps[0]", "Steps[1]"]);
217
+ });
218
+ });
219
+
220
+ describe("deepPathSet", () => {
221
+ test("records both the exact path and the index-erased pattern", () => {
222
+ const set = deepPathSet({ Tags: [{ Key: "a" }] });
223
+ expect([...set].sort()).toEqual(["Tags", "Tags[0]", "Tags[0].Key", "Tags[]", "Tags[].Key"]);
224
+ });
225
+ });
226
+
227
+ describe("deepValueEqual", () => {
228
+ test("compares structurally", () => {
229
+ expect(deepValueEqual({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
230
+ expect(deepValueEqual({ a: [1, 2] }, { a: [2, 1] })).toBe(false);
231
+ expect(deepValueEqual(null, undefined)).toBe(false);
232
+ expect(deepValueEqual(1, 1)).toBe(true);
233
+ });
234
+ });