@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.
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +5 -3
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +46 -4
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +257 -0
- package/dist/deep-observation.d.ts.map +1 -0
- package/dist/discovery/fold-import.d.ts +153 -17
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/sandbox/config-wire.d.ts +3 -2
- package/dist/discovery/sandbox/config-wire.d.ts.map +1 -1
- package/dist/env.d.ts +5 -2
- package/dist/env.d.ts.map +1 -1
- package/dist/fold/fold.d.ts +35 -3
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +9 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +73 -4
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/kubectl-context.d.ts +27 -0
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +78 -6
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +26 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +103 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -0
- package/dist/lifecycle/deep-observe.d.ts +62 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -0
- package/dist/lifecycle/index.d.ts +3 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observation-baseline.d.ts +118 -0
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +4 -2
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +26 -1
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +8 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/live-endpoint.d.ts +92 -0
- package/dist/live-endpoint.d.ts.map +1 -0
- package/dist/observation.d.ts +123 -0
- package/dist/observation.d.ts.map +1 -0
- package/dist/stack-output.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +63 -4
- package/src/cli/handlers/components.ts +78 -35
- package/src/cli/handlers/graph.test.ts +155 -6
- package/src/cli/handlers/graph.ts +124 -29
- package/src/cli/handlers/lifecycle.test.ts +410 -6
- package/src/cli/handlers/lifecycle.ts +475 -182
- package/src/cli/main.test.ts +6 -0
- package/src/cli/main.ts +12 -0
- package/src/cli/registry.ts +14 -0
- package/src/config.test.ts +75 -0
- package/src/config.ts +61 -3
- package/src/deep-observation.test.ts +234 -0
- package/src/deep-observation.ts +489 -0
- package/src/discovery/fold-composite.test.ts +594 -0
- package/src/discovery/fold-import.test.ts +372 -1
- package/src/discovery/fold-import.ts +1216 -116
- package/src/discovery/sandbox/config-wire.ts +3 -2
- package/src/env.test.ts +12 -0
- package/src/env.ts +12 -4
- package/src/fold/fold.test.ts +105 -0
- package/src/fold/fold.ts +100 -20
- package/src/fold/subset.test.ts +38 -7
- package/src/fold/subset.ts +9 -0
- package/src/graph-ir-live.test.ts +28 -1
- package/src/graph-ir.ts +115 -12
- package/src/index.ts +2 -0
- package/src/kubectl-context.ts +81 -0
- package/src/lexicon.ts +100 -6
- package/src/lifecycle/change-set.test.ts +93 -1
- package/src/lifecycle/change-set.ts +65 -13
- package/src/lifecycle/deep-diff.test.ts +157 -0
- package/src/lifecycle/deep-diff.ts +213 -0
- package/src/lifecycle/deep-observe.test.ts +174 -0
- package/src/lifecycle/deep-observe.ts +173 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/live-diff.test.ts +39 -0
- package/src/lifecycle/live-diff.ts +51 -5
- package/src/lifecycle/observation-baseline.test.ts +99 -0
- package/src/lifecycle/observation-baseline.ts +217 -0
- package/src/lifecycle/observe.test.ts +74 -3
- package/src/lifecycle/observe.ts +82 -22
- package/src/lifecycle/snapshot.test.ts +39 -1
- package/src/lifecycle/snapshot.ts +40 -20
- package/src/lifecycle/status.test.ts +89 -8
- package/src/lifecycle/status.ts +53 -3
- package/src/lifecycle/types.ts +8 -0
- package/src/live-endpoint.test.ts +115 -0
- package/src/live-endpoint.ts +148 -0
- package/src/observation.test.ts +96 -0
- package/src/observation.ts +213 -0
- package/src/stack-output.test.ts +55 -0
- package/src/stack-output.ts +41 -20
package/src/cli/main.test.ts
CHANGED
|
@@ -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
|
package/src/cli/registry.ts
CHANGED
|
@@ -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
|
/**
|
package/src/config.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
/**
|
|
91
|
-
|
|
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
|
+
});
|