@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.
- package/dist/cli/commands/check-lexicon-docs.d.ts +42 -0
- package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- 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 +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +16 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +1 -1
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +0 -10
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/lifecycle/rollback.d.ts +18 -0
- package/dist/lifecycle/rollback.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +53 -0
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
- package/src/cli/commands/check-lexicon-docs.ts +71 -0
- package/src/cli/commands/check-lexicon.ts +15 -0
- package/src/cli/handlers/components.ts +54 -3
- package/src/cli/handlers/graph.test.ts +61 -0
- package/src/cli/handlers/lifecycle.ts +8 -1
- package/src/cli/main.ts +2 -0
- package/src/cli/registry.ts +2 -0
- package/src/codegen/docs-sections.ts +1 -1
- package/src/codegen/docs.ts +122 -5
- package/src/codegen/fetch.test.ts +24 -5
- package/src/codegen/fetch.ts +19 -1
- package/src/deep-observation.test.ts +151 -0
- package/src/deep-observation.ts +66 -2
- package/src/lifecycle/rollback.test.ts +78 -1
- package/src/lifecycle/rollback.ts +41 -2
- package/src/lifecycle/status.test.ts +90 -5
- package/src/lifecycle/status.ts +85 -2
- package/src/yaml.test.ts +89 -0
- 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") {
|
package/src/cli/registry.ts
CHANGED
|
@@ -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 [
|
|
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
|
"|----------|-------------|------------|------|--------|",
|
package/src/codegen/docs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
!
|
|
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 (!
|
|
131
|
+
if (!claimed("rules") && rules.length > 0) {
|
|
115
132
|
pages.set("rules.mdx", generateRules(config, rules));
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
if (!
|
|
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("
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
214
|
-
|
|
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 () => {
|
package/src/codegen/fetch.ts
CHANGED
|
@@ -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 =
|
|
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
|
+
});
|
package/src/deep-observation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|