@intentius/chant 0.34.1 → 0.37.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 (40) hide show
  1. package/dist/cli/commands/check-lexicon-docs.d.ts +42 -0
  2. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
  3. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  4. package/dist/cli/handlers/components.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/registry.d.ts +2 -0
  8. package/dist/cli/registry.d.ts.map +1 -1
  9. package/dist/codegen/docs.d.ts +16 -0
  10. package/dist/codegen/docs.d.ts.map +1 -1
  11. package/dist/codegen/fetch.d.ts +1 -1
  12. package/dist/codegen/fetch.d.ts.map +1 -1
  13. package/dist/deep-observation.d.ts +0 -10
  14. package/dist/deep-observation.d.ts.map +1 -1
  15. package/dist/lifecycle/rollback.d.ts +18 -0
  16. package/dist/lifecycle/rollback.d.ts.map +1 -1
  17. package/dist/lifecycle/status.d.ts +53 -0
  18. package/dist/lifecycle/status.d.ts.map +1 -1
  19. package/dist/yaml.d.ts.map +1 -1
  20. package/package.json +1 -1
  21. package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
  22. package/src/cli/commands/check-lexicon-docs.ts +71 -0
  23. package/src/cli/commands/check-lexicon.ts +15 -0
  24. package/src/cli/handlers/components.ts +54 -3
  25. package/src/cli/handlers/graph.test.ts +61 -0
  26. package/src/cli/handlers/lifecycle.ts +8 -1
  27. package/src/cli/main.ts +2 -0
  28. package/src/cli/registry.ts +2 -0
  29. package/src/codegen/docs-sections.ts +1 -1
  30. package/src/codegen/docs.ts +122 -5
  31. package/src/codegen/fetch.test.ts +24 -5
  32. package/src/codegen/fetch.ts +19 -1
  33. package/src/deep-observation.test.ts +151 -0
  34. package/src/deep-observation.ts +66 -2
  35. package/src/lifecycle/rollback.test.ts +78 -1
  36. package/src/lifecycle/rollback.ts +41 -2
  37. package/src/lifecycle/status.test.ts +90 -5
  38. package/src/lifecycle/status.ts +85 -2
  39. package/src/yaml.test.ts +89 -0
  40. package/src/yaml.ts +66 -9
@@ -572,6 +572,67 @@ describe("runGraph", () => {
572
572
  expect((opts as { owned: boolean }).owned).toBe(true);
573
573
  });
574
574
 
575
+ // #1158: a project that declares its stacks in `ChantConfig.stacks` — rather
576
+ // than deriving them from `*.component.ts` — must observe each declared
577
+ // stack, the same contract `resolveStackTargets` gives lifecycle
578
+ // snapshot/diff. Every other test here passes `stacks: []`, so without this
579
+ // the declared-stack path is implemented and unguarded.
580
+ test("ChantConfig.stacks: observeResources gets every declared stack, with its region and src", async () => {
581
+ resolveLexMock.mockResolvedValue(["aws"]);
582
+ loadPluginsMock.mockResolvedValue([
583
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
584
+ ]);
585
+ loadChantConfigMock.mockResolvedValue({
586
+ config: {
587
+ stacks: [
588
+ { name: "estate-east", region: "us-east-1", src: "east/src" },
589
+ { name: "estate-west", region: "us-west-2", src: "west/src" },
590
+ ],
591
+ },
592
+ });
593
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
594
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
595
+ expect(exit).toBe(0);
596
+ expect(observeMock).toHaveBeenCalledTimes(1);
597
+ const [, , , opts] = observeMock.mock.calls[0];
598
+ // The region/src carry through — a multi-region estate observes each
599
+ // stack in its own region, not all of them in the ambient default.
600
+ expect((opts as { stacks: Array<{ name: string; region?: string; src?: string }> }).stacks).toEqual([
601
+ { name: "estate-east", region: "us-east-1", src: "east/src" },
602
+ { name: "estate-west", region: "us-west-2", src: "west/src" },
603
+ ]);
604
+ });
605
+
606
+ // A stack can be both component-derived and declared. It must be observed
607
+ // once — `describeResources` is a live API call per stack, so a duplicate
608
+ // is a wasted round trip and a doubled node set to reconcile.
609
+ test("ChantConfig.stacks: a stack also derived from a component is not observed twice", async () => {
610
+ resolveLexMock.mockResolvedValue(["aws"]);
611
+ loadPluginsMock.mockResolvedValue([
612
+ { name: "aws", serializer: {}, describeResources: () => Promise.resolve({}) },
613
+ ]);
614
+ discoverComponentsMock.mockResolvedValue({
615
+ errors: [],
616
+ sourceFiles: [],
617
+ components: new Map([
618
+ ["loom-db", { component: { name: "loom-db", dependsOn: [], deploy: [
619
+ { phase: "deploy", steps: [{ kind: "cfn-deploy", stack: "shared-estate" }] },
620
+ ] }, exportName: "loomDb", filePath: "components/loom-db.component.ts" }],
621
+ ]),
622
+ });
623
+ loadChantConfigMock.mockResolvedValue({
624
+ config: { stacks: [{ name: "shared-estate" }, { name: "estate-west" }] },
625
+ });
626
+ observeMock.mockResolvedValue({ observations: [], errors: [], warnings: [] });
627
+ const exit = await runGraph({ args: makeArgs({ format: "ir", live: true, env: "prod" }), plugins: [], serializers: [] });
628
+ expect(exit).toBe(0);
629
+ const [, , , opts] = observeMock.mock.calls[0];
630
+ expect((opts as { stacks: Array<{ name: string }> }).stacks.map((s) => s.name)).toEqual([
631
+ "shared-estate",
632
+ "estate-west",
633
+ ]);
634
+ });
635
+
575
636
  test("component discovery errors: falls back to the single-stack path with a warning", async () => {
576
637
  resolveLexMock.mockResolvedValue(["aws"]);
577
638
  loadPluginsMock.mockResolvedValue([
@@ -273,11 +273,18 @@ export async function runLifecycleRollback(ctx: CommandContext): Promise<number>
273
273
  const { config } = await loadChantConfig(resolve("."));
274
274
  const sourceDir = config.sourceDir ?? ".";
275
275
  try {
276
- const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve(".") });
276
+ const result = await rollbackToRevision({ ref, env: environment, sourceDir, cwd: resolve("."), dryRun: args.dryRun });
277
277
  if (result.noop) {
278
278
  console.error(formatSuccess(`${sourceDir} already matches ${ref} — nothing to roll back`));
279
279
  return 0;
280
280
  }
281
+ if (args.dryRun) {
282
+ // The delta on stdout, so it pipes and diffs like any other patch; the
283
+ // summary on stderr, matching the PR path's split.
284
+ process.stdout.write(result.diff ?? "");
285
+ console.error(formatSuccess(`Rollback delta for ${ref} computed — no PR opened, nothing pushed`));
286
+ return 0;
287
+ }
281
288
  console.log(result.prUrl); // the PR URL — the consumer (behold) reads this from stdout
282
289
  console.error(formatSuccess(`Opened rollback PR on ${result.branch}`));
283
290
  return 0;
package/src/cli/main.ts CHANGED
@@ -211,6 +211,8 @@ export function parseArgs(args: string[]): ParsedArgs {
211
211
  result.migrateTo = args[++i];
212
212
  } else if (arg === "--emit") {
213
213
  result.emit = args[++i];
214
+ } else if (arg === "--dry-run") {
215
+ result.dryRun = true;
214
216
  } else if (arg === "--strict") {
215
217
  result.strict = true;
216
218
  } else if (arg === "--validate") {
@@ -63,6 +63,8 @@ export interface ParsedArgs {
63
63
  selectName?: string;
64
64
  /** `chant import --owned` — restrict live import to chant-owned resources */
65
65
  owned?: boolean;
66
+ /** `chant lifecycle rollback --dry-run` — compute the rollback delta and print it; open no PR, push nothing, leave no branch. */
67
+ dryRun?: boolean;
66
68
  /** `chant import --verbatim` — keep server-defaulted fields in live import */
67
69
  verbatim?: boolean;
68
70
  /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
@@ -95,7 +95,7 @@ export function generateIntrinsics(
95
95
  "",
96
96
  `The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
97
97
  "",
98
- `**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [\`chant build --fold\`](/chant/concepts/typescript-as-data/#folded-vs-run) can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
98
+ `**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [folding](/chant/concepts/typescript-as-data/#folded-vs-run) — the default \`chant build\` path — can reduce a use of this intrinsic today, without running the file — generated directly from this lexicon's registration, not restated by hand. A tagged template folds once it is registered. A plain call folds only where this lexicon has opted that intrinsic in one at a time (\`foldsAsCall\`), so a \`No\` in this column means this intrinsic has not been opted in, not that calls in general cannot fold.`,
99
99
  "",
100
100
  "| Function | Description | Output Key | Tag? | Folds? |",
101
101
  "|----------|-------------|------------|------|--------|",
@@ -7,7 +7,7 @@
7
7
  * (service grouping, resource type URLs, custom overview content).
8
8
  */
9
9
 
10
- import { copyFileSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
10
+ import { copyFileSync, existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { fileURLToPath } from "url";
13
13
 
@@ -71,6 +71,7 @@ export function docsPipeline(config: DocsConfig): DocsResult {
71
71
  }
72
72
  pages.set("index.mdx", overviewContent);
73
73
  const suppress = new Set(config.suppressPages ?? []);
74
+ const extraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
74
75
 
75
76
  // Extra pages from lexicon config
76
77
  if (config.extraPages) {
@@ -96,12 +97,28 @@ export function docsPipeline(config: DocsConfig): DocsResult {
96
97
  }
97
98
  }
98
99
 
99
- if (!suppress.has("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
100
+ // A generated page must not overwrite one the lexicon explicitly declared.
101
+ // The extraPages above are written into `pages` first, so an unguarded
102
+ // `pages.set` below silently discards them: helm declared its own
103
+ // "Intrinsics Reference" and pre-synth rules pages and shipped neither for
104
+ // as long as both slugs collided (#1312). Explicit authorship wins, and the
105
+ // collision is reported rather than resolved in silence.
106
+ const claimed = (slug: string): boolean => {
107
+ if (suppress.has(slug)) return true;
108
+ if (!extraSlugs.has(slug)) return false;
109
+ console.warn(
110
+ `[docs:${config.name}] extraPages declares "${slug}", which is also a generated page — keeping the declared one.\n` +
111
+ ` Add "${slug}" to suppressPages to make that explicit, or rename the extraPage if both are wanted.`,
112
+ );
113
+ return true;
114
+ };
115
+
116
+ if (!claimed("intrinsics") && manifest.intrinsics && manifest.intrinsics.length > 0) {
100
117
  pages.set("intrinsics.mdx", generateIntrinsics(config, manifest));
101
118
  }
102
119
 
103
120
  if (
104
- !suppress.has("pseudo-parameters") &&
121
+ !claimed("pseudo-parameters") &&
105
122
  manifest.pseudoParameters &&
106
123
  Object.keys(manifest.pseudoParameters).length > 0
107
124
  ) {
@@ -111,14 +128,23 @@ export function docsPipeline(config: DocsConfig): DocsResult {
111
128
  );
112
129
  }
113
130
 
114
- if (!suppress.has("rules") && rules.length > 0) {
131
+ if (!claimed("rules") && rules.length > 0) {
115
132
  pages.set("rules.mdx", generateRules(config, rules));
116
133
  }
117
134
 
118
- if (!suppress.has("serialization")) {
135
+ if (!claimed("serialization")) {
119
136
  pages.set("serialization.mdx", generateSerialization(config));
120
137
  }
121
138
 
139
+ // Stamp every emitted page with its provenance. These files look exactly
140
+ // like the hand-written pages sitting beside them, and without a marker they
141
+ // get edited directly — the k8s "Live Cluster" sidebar group and the AWS
142
+ // intrinsics guide's #1044 claim were both fixed in the emitted `.mdx` and
143
+ // silently reverted by the next regen (#1312).
144
+ for (const [filename, content] of pages) {
145
+ pages.set(filename, withGeneratedMarker(config, content));
146
+ }
147
+
122
148
  return {
123
149
  pages,
124
150
  stats: {
@@ -131,6 +157,66 @@ export function docsPipeline(config: DocsConfig): DocsResult {
131
157
  };
132
158
  }
133
159
 
160
+ /**
161
+ * Insert a provenance comment directly after a page's frontmatter.
162
+ *
163
+ * MDX parses `<!-- -->` as JSX rather than a comment, so this uses the
164
+ * `{/* … *\/}` form the rest of the docs already use for generated markers.
165
+ */
166
+ function withGeneratedMarker(config: DocsConfig, content: string): string {
167
+ const marker = `{/* ${GENERATED_MARKER_TAG} by \`npm run docs -w @intentius/chant-lexicon-${config.name}\` — DO NOT EDIT.\n Edit lexicons/${config.name}/src/codegen/docs.ts instead; changes here are overwritten. */}`;
168
+ const lines = content.split("\n");
169
+ // Frontmatter is the leading `---` … `---` block; the marker goes after it.
170
+ if (lines[0] === "---") {
171
+ const close = lines.indexOf("---", 1);
172
+ if (close > 0) {
173
+ lines.splice(close + 1, 0, "", marker);
174
+ return lines.join("\n");
175
+ }
176
+ }
177
+ return `${marker}\n\n${content}`;
178
+ }
179
+
180
+ /**
181
+ * Marks a page as pipeline output. Used both to warn readers off editing the
182
+ * file and, in {@link writeDocsSite}, to tell a page this pipeline owns from a
183
+ * hand-written one when reaping pages it no longer emits.
184
+ */
185
+ export const GENERATED_MARKER_TAG = "GENERATED-BY-CHANT-DOCS";
186
+
187
+ /**
188
+ * Every slug a Starlight sidebar reaches, including nested group items.
189
+ */
190
+ export function collectSidebarSlugs(items: Array<Record<string, unknown>>): Set<string> {
191
+ const slugs = new Set<string>();
192
+ const walk = (list: Array<Record<string, unknown>>): void => {
193
+ for (const item of list) {
194
+ if (typeof item.slug === "string") slugs.add(item.slug);
195
+ if (Array.isArray(item.items)) walk(item.items as Array<Record<string, unknown>>);
196
+ }
197
+ };
198
+ walk(items);
199
+ return slugs;
200
+ }
201
+
202
+ /**
203
+ * Content pages that no sidebar entry points at.
204
+ *
205
+ * `index` is always the site root and never needs an entry of its own.
206
+ */
207
+ export function unreachablePages(
208
+ contentDir: string,
209
+ sidebar: Array<Record<string, unknown>>,
210
+ ): string[] {
211
+ if (!existsSync(contentDir)) return [];
212
+ const slugs = collectSidebarSlugs(sidebar);
213
+ return readdirSync(contentDir)
214
+ .filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
215
+ .map((f) => f.replace(/\.mdx?$/, ""))
216
+ .filter((slug) => slug !== "index" && !slugs.has(slug))
217
+ .sort();
218
+ }
219
+
134
220
  /**
135
221
  * Write generated docs pages to disk.
136
222
  */
@@ -161,6 +247,23 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
161
247
  const filePath = join(contentDir, filename);
162
248
  rmSync(filePath, { force: true });
163
249
  }
250
+
251
+ // Reap pages this pipeline used to emit and no longer does. Suppressing a
252
+ // page only stops it being written; the copy from the last run stayed on
253
+ // disk, unreferenced by the sidebar and indistinguishable from a
254
+ // hand-written page — which is how azure, gcp and helm each kept a `rules`
255
+ // page after adopting their own (#1312). The provenance marker is what makes
256
+ // this safe: only a file this pipeline stamped is ever removed.
257
+ if (existsSync(contentDir)) {
258
+ for (const filename of readdirSync(contentDir)) {
259
+ if (!filename.endsWith(".mdx") && !filename.endsWith(".md")) continue;
260
+ if (result.pages.has(filename)) continue;
261
+ const filePath = join(contentDir, filename);
262
+ if (readFileSync(filePath, "utf-8").includes(GENERATED_MARKER_TAG)) {
263
+ rmSync(filePath, { force: true });
264
+ }
265
+ }
266
+ }
164
267
  rmSync(join(outDir, ".astro"), { recursive: true, force: true });
165
268
  rmSync(join(outDir, "node_modules", ".astro"), { recursive: true, force: true });
166
269
 
@@ -170,6 +273,20 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
170
273
  // Build sidebar from generated pages
171
274
  const sidebar = buildSidebar(config, result);
172
275
 
276
+ // Starlight does not auto-discover pages, so a page absent from the sidebar
277
+ // is reachable only by typing its URL. The pipeline deliberately preserves
278
+ // hand-written pages it did not emit (above), which makes it easy to add one
279
+ // and never wire it up — azure, temporal, helm and github each accumulated
280
+ // several that way (#1312). Report them; `chant dev check-lexicon` gates on
281
+ // the same condition.
282
+ const unreachable = unreachablePages(contentDir, sidebar);
283
+ if (unreachable.length > 0) {
284
+ console.warn(
285
+ `[docs:${config.name}] ${unreachable.length} page(s) in no sidebar entry — reachable only by direct URL: ${unreachable.join(", ")}.\n` +
286
+ ` Declare them in this lexicon's DocsConfig \`sidebarExtra\` (or \`extraPages\`) to surface them.`,
287
+ );
288
+ }
289
+
173
290
  // package.json
174
291
  writeFileSync(
175
292
  join(outDir, "package.json"),
@@ -182,12 +182,26 @@ describe("fetchWithRetry", () => {
182
182
  expect(fetchMock).toHaveBeenCalledTimes(3);
183
183
  });
184
184
 
185
- test("calls fetch with no init argument when none is given", async () => {
185
+ test("bounds an attempt with a signal even when no init is given", async () => {
186
186
  const fetchMock = vi.fn().mockResolvedValue(ok());
187
187
  vi.stubGlobal("fetch", fetchMock);
188
188
 
189
189
  await fetchWithRetry("https://example.test/x", 4, 1);
190
- expect(fetchMock).toHaveBeenCalledWith("https://example.test/x");
190
+ // A hung connect would otherwise wait on the OS default, which is long
191
+ // enough to run a CI job past its timeout.
192
+ expect(fetchMock).toHaveBeenCalledWith(
193
+ "https://example.test/x",
194
+ expect.objectContaining({ signal: expect.any(AbortSignal) }),
195
+ );
196
+ });
197
+
198
+ test("a caller's own signal wins over the attempt bound", async () => {
199
+ const fetchMock = vi.fn().mockResolvedValue(ok());
200
+ vi.stubGlobal("fetch", fetchMock);
201
+
202
+ const controller = new AbortController();
203
+ await fetchWithRetry("https://example.test/x", 4, 1, { signal: controller.signal });
204
+ expect(fetchMock.mock.calls[0][1].signal).toBe(controller.signal);
191
205
  });
192
206
 
193
207
  test("passes request init through to fetch", async () => {
@@ -196,7 +210,10 @@ describe("fetchWithRetry", () => {
196
210
 
197
211
  const init = { headers: { Accept: "application/vnd.github+json" } };
198
212
  await fetchWithRetry("https://example.test/x", 4, 1, init);
199
- expect(fetchMock).toHaveBeenCalledWith("https://example.test/x", init);
213
+ expect(fetchMock).toHaveBeenCalledWith(
214
+ "https://example.test/x",
215
+ expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }),
216
+ );
200
217
  });
201
218
 
202
219
  test("preserves init across retries on a transient status", async () => {
@@ -210,8 +227,10 @@ describe("fetchWithRetry", () => {
210
227
  const resp = await fetchWithRetry("https://example.test/x", 4, 1, init);
211
228
  expect(resp.ok).toBe(true);
212
229
  expect(fetchMock).toHaveBeenCalledTimes(2);
213
- expect(fetchMock).toHaveBeenNthCalledWith(1, "https://example.test/x", init);
214
- expect(fetchMock).toHaveBeenNthCalledWith(2, "https://example.test/x", init);
230
+ for (const call of fetchMock.mock.calls) {
231
+ expect(call[0]).toBe("https://example.test/x");
232
+ expect(call[1]).toEqual(expect.objectContaining({ headers: init.headers, signal: expect.any(AbortSignal) }));
233
+ }
215
234
  });
216
235
 
217
236
  test("does not retry a permanent status when init is given", async () => {
@@ -30,6 +30,20 @@ const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
30
30
 
31
31
  const DEFAULT_RETRIES = 4;
32
32
  const DEFAULT_BACKOFF_MS = 1000;
33
+ /**
34
+ * Per-attempt ceiling, so an upstream that accepts a connection and then stops
35
+ * answering cannot hold a build open.
36
+ *
37
+ * Without one, a hung connect waits on the OS default — which on Linux is over
38
+ * a minute — and five of those plus back-off is enough to run a CI job past its
39
+ * timeout. That is not hypothetical: it is what pushed chant's `check` job from
40
+ * ~9m30s to a cancellation at 10m, twice, on two unreachable spec endpoints
41
+ * whose results are only ever a fallback to a committed snapshot anyway.
42
+ *
43
+ * Generous enough for a slow-but-alive endpoint; the point is a bound, not
44
+ * speed.
45
+ */
46
+ const DEFAULT_ATTEMPT_TIMEOUT_MS = 15_000;
33
47
  /** Hard cap on Retry-After delays so a rogue header cannot stall CI indefinitely. */
34
48
  const MAX_RETRY_AFTER_MS = 60_000;
35
49
 
@@ -85,6 +99,7 @@ export async function fetchWithRetry(
85
99
  retries = DEFAULT_RETRIES,
86
100
  backoffMs = DEFAULT_BACKOFF_MS,
87
101
  init?: RequestInit,
102
+ attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS,
88
103
  ): Promise<Response> {
89
104
  let lastStatus: number | undefined;
90
105
  let lastError: Error | undefined;
@@ -101,8 +116,11 @@ export async function fetchWithRetry(
101
116
  }
102
117
 
103
118
  let response: Response;
119
+ // A caller's own signal still wins; this only bounds an attempt that would
120
+ // otherwise hang with no signal at all.
121
+ const signal = init?.signal ?? AbortSignal.timeout(attemptTimeoutMs);
104
122
  try {
105
- response = init === undefined ? await fetch(url) : await fetch(url, init);
123
+ response = await fetch(url, { ...(init ?? {}), signal });
106
124
  } catch (e) {
107
125
  // Network-level failure (DNS, connection reset, timeout). Transient.
108
126
  lastError = e instanceof Error ? e : new Error(String(e));
@@ -84,6 +84,54 @@ describe("normalizeDeepProperties", () => {
84
84
  expect(out).toEqual({ Name: "n" });
85
85
  });
86
86
 
87
+ // A container the rules emptied is not a container the source declared empty.
88
+ // Keeping the husk turns a suppressed default into drift-shaped noise —
89
+ // `SecurityGroupEgress[#{}]: <undeclared> → {}` was the case that found this.
90
+ test("an object whose every field was pruned is dropped, not left as {}", () => {
91
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "CidrIp" || n.key === "IpProtocol" };
92
+ const out = normalizeDeepProperties(
93
+ { Egress: [{ CidrIp: "0.0.0.0/0", IpProtocol: "-1" }], Name: "n" },
94
+ { entityType: "T", side: "live", hooks },
95
+ );
96
+ expect(out).toEqual({ Name: "n" });
97
+ });
98
+
99
+ test("an object the source declared empty survives", () => {
100
+ const hooks: DeepNormalizationHooks = { prune: () => false };
101
+ const out = normalizeDeepProperties(
102
+ { Spec: {}, Items: [], Name: "n" },
103
+ { entityType: "T", side: "live", hooks },
104
+ );
105
+ expect(out).toEqual({ Spec: {}, Items: [], Name: "n" });
106
+ });
107
+
108
+ test("a partly pruned object keeps what survived", () => {
109
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Arn" };
110
+ const out = normalizeDeepProperties(
111
+ { Role: { Arn: "arn:…", Path: "/" } },
112
+ { entityType: "T", side: "live", hooks },
113
+ );
114
+ expect(out).toEqual({ Role: { Path: "/" } });
115
+ });
116
+
117
+ test("emptiness propagates up as far as the pruning reaches", () => {
118
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Gone" };
119
+ const out = normalizeDeepProperties(
120
+ { Outer: { Inner: { Gone: 1 } }, Name: "n" },
121
+ { entityType: "T", side: "live", hooks },
122
+ );
123
+ expect(out).toEqual({ Name: "n" });
124
+ });
125
+
126
+ test("an array keeps the elements pruning did not empty", () => {
127
+ const hooks: DeepNormalizationHooks = { prune: (n) => n.key === "Default" };
128
+ const out = normalizeDeepProperties(
129
+ { Rules: [{ Default: true }, { Port: 443 }] },
130
+ { entityType: "T", side: "live", hooks },
131
+ );
132
+ expect(out).toEqual({ Rules: [{ Port: 443 }] });
133
+ });
134
+
87
135
  test("hooks see an index-erased pattern alongside the exact path", () => {
88
136
  const seen: Array<[string, string]> = [];
89
137
  const hooks: DeepNormalizationHooks = {
@@ -232,3 +280,106 @@ describe("deepValueEqual", () => {
232
280
  expect(deepValueEqual(1, 1)).toBe(true);
233
281
  });
234
282
  });
283
+
284
+ // #1314 — a nested property authored through a lexicon's generated constructor
285
+ // is authored data, not an opaque class instance. Collapsing it to UNRESOLVED
286
+ // left the declared side empty while the live side held the real value, so
287
+ // every field of it reported `<undeclared>` on a clean apply.
288
+ describe("normalizeDeepProperties — property-kind declarables (#1314)", () => {
289
+ /** Shaped like a generated property constructor's instance. */
290
+ const propertyDeclarable = (entityType: string, props: Record<string, unknown>) => ({
291
+ entityType,
292
+ kind: "property",
293
+ props,
294
+ });
295
+
296
+ test("unwraps a property-kind declarable to its authored props", () => {
297
+ const out = normalizeDeepProperties(
298
+ {
299
+ GroupDescription: "sg",
300
+ SecurityGroupIngress: [
301
+ propertyDeclarable("AWS::EC2::SecurityGroup.Ingress", {
302
+ IpProtocol: "tcp",
303
+ FromPort: 443,
304
+ ToPort: 443,
305
+ CidrIp: "10.42.0.0/16",
306
+ }),
307
+ ],
308
+ },
309
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
310
+ );
311
+ expect(out).toEqual({
312
+ GroupDescription: "sg",
313
+ SecurityGroupIngress: [{ CidrIp: "10.42.0.0/16", FromPort: 443, IpProtocol: "tcp", ToPort: 443 }],
314
+ });
315
+ });
316
+
317
+ test("produces the same tree as the equivalent plain object — the two authoring forms must not differ", () => {
318
+ const viaConstructor = normalizeDeepProperties(
319
+ { Ingress: [propertyDeclarable("T.Ingress", { IpProtocol: "tcp", FromPort: 443 })] },
320
+ { entityType: "T", side: "declared" },
321
+ );
322
+ const viaLiteral = normalizeDeepProperties(
323
+ { Ingress: [{ IpProtocol: "tcp", FromPort: 443 }] },
324
+ { entityType: "T", side: "declared" },
325
+ );
326
+ expect(viaConstructor).toEqual(viaLiteral);
327
+ });
328
+
329
+ test("unwraps nested property declarables all the way down", () => {
330
+ const out = normalizeDeepProperties(
331
+ {
332
+ Logging: propertyDeclarable("T.Logging", {
333
+ CloudWatch: propertyDeclarable("T.CloudWatch", { Enabled: true, LogGroup: "g" }),
334
+ }),
335
+ },
336
+ { entityType: "T", side: "declared" },
337
+ );
338
+ expect(out).toEqual({ Logging: { CloudWatch: { Enabled: true, LogGroup: "g" } } });
339
+ });
340
+
341
+ test("still collapses a RESOURCE-kind declarable — it is a reference with no source-side value", () => {
342
+ // A class instance, as a lexicon actually constructs one: a resource-kind
343
+ // declarable in another resource's props is a Ref, and there is nothing on
344
+ // the source side to compare a live value against.
345
+ class VpcDeclarable {
346
+ readonly entityType = "AWS::EC2::VPC";
347
+ readonly kind = "resource";
348
+ readonly props = { CidrBlock: "10.0.0.0/16" };
349
+ }
350
+ const out = normalizeDeepProperties(
351
+ { VpcId: new VpcDeclarable() },
352
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
353
+ );
354
+ expect(out).toEqual({ VpcId: UNRESOLVED });
355
+ });
356
+
357
+ test("unwraps a property-kind declarable that is a class instance, which is how a lexicon builds one", () => {
358
+ class IngressDeclarable {
359
+ readonly entityType = "AWS::EC2::SecurityGroup.Ingress";
360
+ readonly kind = "property";
361
+ constructor(readonly props: Record<string, unknown>) {}
362
+ }
363
+ const out = normalizeDeepProperties(
364
+ { Ingress: [new IngressDeclarable({ IpProtocol: "tcp", FromPort: 443 })] },
365
+ { entityType: "AWS::EC2::SecurityGroup", side: "declared" },
366
+ );
367
+ expect(out).toEqual({ Ingress: [{ FromPort: 443, IpProtocol: "tcp" }] });
368
+ });
369
+
370
+ test("still collapses a genuine class instance, which is what the branch is for", () => {
371
+ class Sub {
372
+ constructor(readonly template: string) {}
373
+ }
374
+ const out = normalizeDeepProperties({ Name: new Sub("${AWS::StackName}-x") }, { entityType: "T", side: "declared" });
375
+ expect(out).toEqual({ Name: UNRESOLVED });
376
+ });
377
+
378
+ test("masks a secret inside an unwrapped property declarable, same as in a plain object", () => {
379
+ const out = normalizeDeepProperties(
380
+ { Auth: propertyDeclarable("T.Auth", { Username: "u", Password: "hunter2" }) },
381
+ { entityType: "T", side: "declared" },
382
+ );
383
+ expect(out).toEqual({ Auth: { Password: MASKED, Username: "u" } });
384
+ });
385
+ });
@@ -324,6 +324,36 @@ export function deepPathSet(tree: Record<string, unknown>): Set<string> {
324
324
  * on every read; array order is canonicalized only where the lexicon says the
325
325
  * array is a set, because list order often *is* semantic.
326
326
  */
327
+ /**
328
+ * A container whose every member the rules pruned.
329
+ *
330
+ * The distinction it carries is between a value that was empty in the source
331
+ * and one this pass emptied: `{}` a lexicon actually declared is a fact worth
332
+ * diffing, while `{}` left behind after both of a rule's fields were subtracted
333
+ * as provider defaults is a husk. Reporting the husk turns a suppressed default
334
+ * into `SecurityGroupEgress[#{}]: <undeclared> → {}` — noise wearing the shape
335
+ * of drift, which is the one thing the noise rules exist to prevent.
336
+ *
337
+ * Module-private: it never leaves this function's recursion.
338
+ */
339
+ const EMPTIED = Symbol("emptied-by-pruning");
340
+
341
+ /**
342
+ * A property-kind Declarable — a nested property authored through a lexicon's
343
+ * generated constructor rather than as a plain object (#1314).
344
+ *
345
+ * Duck-typed on the same three facts `declarable.ts` defines it by
346
+ * (`entityType`, `kind === "property"`, `props`) rather than imported, keeping
347
+ * this module free of a dependency on the authoring types it only ever
348
+ * inspects. A resource-kind Declarable deliberately does not match.
349
+ */
350
+ function isPropertyDeclarableValue(value: unknown): boolean {
351
+ if (typeof value !== "object" || value === null) return false;
352
+ const v = value as { entityType?: unknown; kind?: unknown; props?: unknown };
353
+ return typeof v.entityType === "string" && v.kind === "property" && typeof v.props === "object" && v.props !== null;
354
+ }
355
+
356
+
327
357
  export function normalizeDeepProperties(
328
358
  tree: Record<string, unknown>,
329
359
  options: NormalizeDeepOptions,
@@ -351,27 +381,61 @@ export function normalizeDeepProperties(
351
381
  if (isSensitiveKey(key)) return MASKED;
352
382
  if (isJsonPrimitive(value)) return value;
353
383
 
384
+ // A PROPERTY-kind Declarable is authored data wearing a class, not an
385
+ // opaque instance (#1314). The generated property constructors —
386
+ // `SecurityGroup_Ingress`, `Role_Policy`, `MicrovmImage_Logging` — are the
387
+ // typed way to write a nested property, and the serializer inlines their
388
+ // `.props` (serializer-walker.ts `visitor.propertyDeclarable`). The
389
+ // declared tree has to do the same, or the two sides disagree about a
390
+ // property the author did write: without this the declared side held
391
+ // UNRESOLVED while the live side held the real rule, so every one of its
392
+ // fields reported `<undeclared> → <value>` on a clean apply, and the noise
393
+ // scaled with how strictly a project typed its properties.
394
+ //
395
+ // Checked ahead of the array/object branches rather than in the non-JSON
396
+ // fallback below, so it holds whether the declarable arrives as a class
397
+ // instance (what a lexicon constructs) or as an equivalent plain object.
398
+ //
399
+ // Deliberately property-kind only. A RESOURCE-kind Declarable in another
400
+ // resource's props is a reference, which has no source-side value to
401
+ // compare against a live one, so UNRESOLVED stays correct for it — and a
402
+ // resource-kind instance falls through to that fallback unchanged.
403
+ if (isPropertyDeclarableValue(value)) {
404
+ return normalizeValue((value as { props?: unknown }).props ?? {}, path, pattern, key);
405
+ }
406
+
354
407
  if (Array.isArray(value)) {
355
408
  const elements: unknown[] = [];
356
409
  for (let i = 0; i < value.length; i++) {
357
410
  const elPath = joinIndex(path, i);
358
411
  const elPattern = joinPattern(pattern);
359
412
  if (prune(elPath, elPattern, String(i), value[i])) continue;
360
- elements.push(normalizeValue(value[i], elPath, elPattern, String(i)));
413
+ const element = normalizeValue(value[i], elPath, elPattern, String(i));
414
+ if (element === EMPTIED) continue;
415
+ elements.push(element);
361
416
  }
417
+ // An array that had elements and has none left was emptied by pruning,
418
+ // not declared empty. Reporting `[]` for it is reporting the husk of a
419
+ // value the rules just decided was noise.
420
+ if (elements.length === 0 && value.length > 0) return EMPTIED;
362
421
  return orderElements(elements, path, pattern);
363
422
  }
364
423
 
365
424
  if (isPlainObject(value)) {
366
425
  const out: Record<string, unknown> = {};
426
+ let had = 0;
367
427
  for (const childKey of Object.keys(value).sort()) {
368
428
  const childPath = joinPath(path, childKey);
369
429
  const childPattern = joinPath(pattern, childKey);
370
430
  const childValue = value[childKey];
371
431
  if (childValue === undefined) continue;
432
+ had += 1;
372
433
  if (prune(childPath, childPattern, childKey, childValue)) continue;
373
- out[childKey] = normalizeValue(childValue, childPath, childPattern, childKey);
434
+ const child = normalizeValue(childValue, childPath, childPattern, childKey);
435
+ if (child === EMPTIED) continue;
436
+ out[childKey] = child;
374
437
  }
438
+ if (Object.keys(out).length === 0 && had > 0) return EMPTIED;
375
439
  return out;
376
440
  }
377
441