@intentius/chant 0.34.1 → 0.37.2
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-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/docs-sections.d.ts.map +1 -1
- package/dist/codegen/docs-sidebar.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts +27 -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-rule-scanning.ts +12 -3
- package/src/codegen/docs-sections.ts +6 -4
- package/src/codegen/docs-sidebar.ts +8 -2
- package/src/codegen/docs.ts +141 -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
|
@@ -46,7 +46,7 @@ import { discoverComponents } from "../../components/discover";
|
|
|
46
46
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
47
47
|
import type { CommandContext } from "../registry";
|
|
48
48
|
import type { LexiconPlugin } from "../../lexicon";
|
|
49
|
-
import { normalizeObservation, unobservedAll, type NormalizedObservation } from "../../observation";
|
|
49
|
+
import { normalizeObservation, mergeObservations, unobservedAll, type NormalizedObservation } from "../../observation";
|
|
50
50
|
import type { Phase, Component } from "../../components/component";
|
|
51
51
|
|
|
52
52
|
/**
|
|
@@ -184,6 +184,22 @@ interface StatusJsonRow {
|
|
|
184
184
|
/** Why live state could not be read for this component (#1089). Mutually exclusive with `live`. */
|
|
185
185
|
unobserved?: { reason: string; detail?: string };
|
|
186
186
|
stack?: { name: string; status?: string; healthy?: boolean };
|
|
187
|
+
/**
|
|
188
|
+
* How this component's own resources answered (behold#98, chant#1300).
|
|
189
|
+
*
|
|
190
|
+
* `stack` above exists only where the substrate has a deploy object to read,
|
|
191
|
+
* which is AWS; these counts are the substrate-neutral answer to the same
|
|
192
|
+
* question, and finer-grained than a single stack verdict wherever both are
|
|
193
|
+
* present. #1300 added the field to `ComponentStatusRow` but not to this
|
|
194
|
+
* projection, which is the only surface a consumer sees — so until behold#100
|
|
195
|
+
* it never left the CLI.
|
|
196
|
+
*
|
|
197
|
+
* On AWS the counts inherit `describe-stack-resources`, so they report
|
|
198
|
+
* CloudFormation's per-resource inventory rather than independently observed
|
|
199
|
+
* existence; the per-type reader registry (#1269/#1271) applied to this thin
|
|
200
|
+
* path is what would make them a live check.
|
|
201
|
+
*/
|
|
202
|
+
resources?: { total: number; present: number; absent: number; unobserved: number };
|
|
187
203
|
}
|
|
188
204
|
|
|
189
205
|
/**
|
|
@@ -340,6 +356,28 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
|
|
|
340
356
|
try {
|
|
341
357
|
const targetSerializers = serializers;
|
|
342
358
|
const buildResult = await build(resolve(args.src ?? config.sourceDir ?? "."), targetSerializers);
|
|
359
|
+
// Which deployed stack(s) to read the change set from (behold#100).
|
|
360
|
+
//
|
|
361
|
+
// `describeResources` defaults to the single-stack convention — the
|
|
362
|
+
// stack named after the environment — which is wrong for exactly the
|
|
363
|
+
// projects this command exists for: a component project deploys the
|
|
364
|
+
// stack its own `cfn-deploy` names, and that is almost never the env
|
|
365
|
+
// name. Every declared resource then came back absent, so the rollup
|
|
366
|
+
// #1300 computes read `present: 0` over a healthy estate. That was
|
|
367
|
+
// invisible while nothing consumed the rollup; behold#100 paints from
|
|
368
|
+
// it, so it has to be right.
|
|
369
|
+
//
|
|
370
|
+
// The components' own `cfn-deploy` stacks are the authority (the same
|
|
371
|
+
// ones `observeComponentStacks` reads below), with `config.stacks` as
|
|
372
|
+
// the declared override for a project whose stacks aren't derivable
|
|
373
|
+
// from a deploy step. `[undefined]` keeps the old single-read
|
|
374
|
+
// behaviour for a project with neither.
|
|
375
|
+
const componentStackNames = new Set<string>();
|
|
376
|
+
for (const { component } of discovery.components.values()) {
|
|
377
|
+
for (const stack of cfnDeployStacks(component.deploy)) componentStackNames.add(stack);
|
|
378
|
+
}
|
|
379
|
+
for (const stack of config.stacks ?? []) componentStackNames.add(stack.name);
|
|
380
|
+
const readTargets: Array<string | undefined> = componentStackNames.size ? [...componentStackNames] : [undefined];
|
|
343
381
|
const merged: { env: string; entries: import("../../lifecycle/change-set").ChangeSetEntry[] } = { env: environment, entries: [] };
|
|
344
382
|
for (const plugin of plugins) {
|
|
345
383
|
if (!plugin.describeResources) continue;
|
|
@@ -356,8 +394,20 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
|
|
|
356
394
|
}
|
|
357
395
|
let observed: NormalizedObservation;
|
|
358
396
|
try {
|
|
359
|
-
observed =
|
|
360
|
-
await
|
|
397
|
+
observed = mergeObservations(
|
|
398
|
+
await Promise.all(
|
|
399
|
+
readTargets.map(async (stack) =>
|
|
400
|
+
normalizeObservation(
|
|
401
|
+
await plugin.describeResources!({
|
|
402
|
+
environment,
|
|
403
|
+
buildOutput: "",
|
|
404
|
+
entityNames: Array.from(declared),
|
|
405
|
+
entities,
|
|
406
|
+
...(stack ? { stack } : {}),
|
|
407
|
+
}),
|
|
408
|
+
),
|
|
409
|
+
),
|
|
410
|
+
),
|
|
361
411
|
);
|
|
362
412
|
} catch (err) {
|
|
363
413
|
// A failed read is not an empty environment (#1089): mark every
|
|
@@ -464,6 +514,7 @@ export async function runComponentsStatus(ctx: CommandContext): Promise<number>
|
|
|
464
514
|
...(row.live !== undefined ? { live: row.live } : {}),
|
|
465
515
|
...(row.unobserved ? { unobserved: row.unobserved } : {}),
|
|
466
516
|
...(row.stack ? { stack: row.stack } : {}),
|
|
517
|
+
...(row.resources ? { resources: row.resources } : {}),
|
|
467
518
|
});
|
|
468
519
|
}
|
|
469
520
|
}
|
|
@@ -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 */
|
|
@@ -109,17 +109,26 @@ function extractDescriptionFromComment(
|
|
|
109
109
|
return ruleId;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
function plural(n: number, noun: string): string {
|
|
113
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
export function generateRules(config: DocsConfig, rules: RuleMeta[]): string {
|
|
113
117
|
const lintRules = rules.filter((r) => r.type === "lint");
|
|
114
118
|
const postSynthRules = rules.filter((r) => r.type === "post-synth");
|
|
115
119
|
|
|
116
120
|
const lines: string[] = [
|
|
117
121
|
"---",
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
// "All Rules", not "Lint Rules": most lexicons also ship a hand-written
|
|
123
|
+
// page under the latter title that covers a selected subset, and two pages
|
|
124
|
+
// with the same title reads as a duplicate rather than a complete table.
|
|
125
|
+
`title: "All Rules"`,
|
|
126
|
+
`description: "Every lint rule and post-synth check provided by the ${config.displayName} lexicon"`,
|
|
120
127
|
"---",
|
|
121
128
|
"",
|
|
122
|
-
`The ${config.displayName} lexicon provides **${rules.length}** rules:
|
|
129
|
+
`The ${config.displayName} lexicon provides **${rules.length}** rules: ` +
|
|
130
|
+
`${plural(lintRules.length, "lint rule")} and ` +
|
|
131
|
+
`${plural(postSynthRules.length, "post-synth check")}.`,
|
|
123
132
|
"",
|
|
124
133
|
];
|
|
125
134
|
|
|
@@ -64,9 +64,11 @@ export function generateOverview(
|
|
|
64
64
|
`- [Pseudo-Parameters](./pseudo-parameters) — ${Object.keys(manifest.pseudoParameters).length} pseudo-parameters`,
|
|
65
65
|
);
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
// Same reasoning as the sidebar's rules entry: link the complete table on
|
|
68
|
+
// every lexicon, under the label the sidebar uses, whether or not a prose
|
|
69
|
+
// `lint-rules` page also exists.
|
|
70
|
+
if (!suppress.has("rules") && rules.length > 0) {
|
|
71
|
+
lines.push(`- [All Rules](./rules) — ${rules.length} rules`);
|
|
70
72
|
}
|
|
71
73
|
if (!suppress.has("serialization")) {
|
|
72
74
|
lines.push(`- [Serialization](./serialization) — output format details`);
|
|
@@ -95,7 +97,7 @@ export function generateIntrinsics(
|
|
|
95
97
|
"",
|
|
96
98
|
`The ${config.displayName} lexicon provides **${intrinsics.length}** intrinsic functions.`,
|
|
97
99
|
"",
|
|
98
|
-
`**Tag?** shows how an intrinsic is authored: a genuine tagged template (\`Sub\\\`...\\\`\`) or a plain function call (\`Ref(...)\`). **Folds?** shows whether [
|
|
100
|
+
`**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
101
|
"",
|
|
100
102
|
"| Function | Description | Output Key | Tag? | Folds? |",
|
|
101
103
|
"|----------|-------------|------------|------|--------|",
|
|
@@ -39,8 +39,14 @@ export function buildSidebar(
|
|
|
39
39
|
items.push({ label: "Pseudo-Parameters", slug: "pseudo-parameters" });
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// Every lexicon links its generated rules table, whether or not it also
|
|
43
|
+
// ships a prose `lint-rules` page. Skipping it when one existed was how gcp
|
|
44
|
+
// ended up emitting a page nothing pointed at (#1312), and it left readers
|
|
45
|
+
// with no complete list on the lexicons whose prose covers only part of the
|
|
46
|
+
// set — aws documented 26 of 50 that way. The label distinguishes the
|
|
47
|
+
// generated table from a prose page rather than competing with it.
|
|
48
|
+
if (!suppress.has("rules") && !extraSlugs.has("rules") && result.pages.has("rules.mdx")) {
|
|
49
|
+
items.push({ label: "All Rules", slug: "rules" });
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
if (!suppress.has("serialization") && !extraSlugs.has("serialization") && result.pages.has("serialization.mdx")) {
|
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,85 @@ 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
|
+
* Render the complete rules table for a lexicon that has no {@link docsPipeline}
|
|
182
|
+
* site of its own.
|
|
183
|
+
*
|
|
184
|
+
* The docker lexicon hand-authors its docs, which left its rule table the only
|
|
185
|
+
* one in the repo that could drift from source without anything noticing
|
|
186
|
+
* (#1312). This is the one page worth generating even when the rest of a site
|
|
187
|
+
* is hand-written; the caller writes the result to `rules.mdx` and links it.
|
|
188
|
+
* Returns null when the lexicon declares no rules.
|
|
189
|
+
*/
|
|
190
|
+
export function generateRulesPage(
|
|
191
|
+
config: DocsConfig,
|
|
192
|
+
srcDir: string,
|
|
193
|
+
): string | null {
|
|
194
|
+
const rules = scanRules(srcDir);
|
|
195
|
+
if (rules.length === 0) return null;
|
|
196
|
+
return withGeneratedMarker(config, generateRules(config, rules));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Marks a page as pipeline output. Used both to warn readers off editing the
|
|
201
|
+
* file and, in {@link writeDocsSite}, to tell a page this pipeline owns from a
|
|
202
|
+
* hand-written one when reaping pages it no longer emits.
|
|
203
|
+
*/
|
|
204
|
+
export const GENERATED_MARKER_TAG = "GENERATED-BY-CHANT-DOCS";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Every slug a Starlight sidebar reaches, including nested group items.
|
|
208
|
+
*/
|
|
209
|
+
export function collectSidebarSlugs(items: Array<Record<string, unknown>>): Set<string> {
|
|
210
|
+
const slugs = new Set<string>();
|
|
211
|
+
const walk = (list: Array<Record<string, unknown>>): void => {
|
|
212
|
+
for (const item of list) {
|
|
213
|
+
if (typeof item.slug === "string") slugs.add(item.slug);
|
|
214
|
+
if (Array.isArray(item.items)) walk(item.items as Array<Record<string, unknown>>);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
walk(items);
|
|
218
|
+
return slugs;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Content pages that no sidebar entry points at.
|
|
223
|
+
*
|
|
224
|
+
* `index` is always the site root and never needs an entry of its own.
|
|
225
|
+
*/
|
|
226
|
+
export function unreachablePages(
|
|
227
|
+
contentDir: string,
|
|
228
|
+
sidebar: Array<Record<string, unknown>>,
|
|
229
|
+
): string[] {
|
|
230
|
+
if (!existsSync(contentDir)) return [];
|
|
231
|
+
const slugs = collectSidebarSlugs(sidebar);
|
|
232
|
+
return readdirSync(contentDir)
|
|
233
|
+
.filter((f) => f.endsWith(".mdx") || f.endsWith(".md"))
|
|
234
|
+
.map((f) => f.replace(/\.mdx?$/, ""))
|
|
235
|
+
.filter((slug) => slug !== "index" && !slugs.has(slug))
|
|
236
|
+
.sort();
|
|
237
|
+
}
|
|
238
|
+
|
|
134
239
|
/**
|
|
135
240
|
* Write generated docs pages to disk.
|
|
136
241
|
*/
|
|
@@ -161,6 +266,23 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
|
|
|
161
266
|
const filePath = join(contentDir, filename);
|
|
162
267
|
rmSync(filePath, { force: true });
|
|
163
268
|
}
|
|
269
|
+
|
|
270
|
+
// Reap pages this pipeline used to emit and no longer does. Suppressing a
|
|
271
|
+
// page only stops it being written; the copy from the last run stayed on
|
|
272
|
+
// disk, unreferenced by the sidebar and indistinguishable from a
|
|
273
|
+
// hand-written page — which is how azure, gcp and helm each kept a `rules`
|
|
274
|
+
// page after adopting their own (#1312). The provenance marker is what makes
|
|
275
|
+
// this safe: only a file this pipeline stamped is ever removed.
|
|
276
|
+
if (existsSync(contentDir)) {
|
|
277
|
+
for (const filename of readdirSync(contentDir)) {
|
|
278
|
+
if (!filename.endsWith(".mdx") && !filename.endsWith(".md")) continue;
|
|
279
|
+
if (result.pages.has(filename)) continue;
|
|
280
|
+
const filePath = join(contentDir, filename);
|
|
281
|
+
if (readFileSync(filePath, "utf-8").includes(GENERATED_MARKER_TAG)) {
|
|
282
|
+
rmSync(filePath, { force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
164
286
|
rmSync(join(outDir, ".astro"), { recursive: true, force: true });
|
|
165
287
|
rmSync(join(outDir, "node_modules", ".astro"), { recursive: true, force: true });
|
|
166
288
|
|
|
@@ -170,6 +292,20 @@ export function writeDocsSite(config: DocsConfig, result: DocsResult): void {
|
|
|
170
292
|
// Build sidebar from generated pages
|
|
171
293
|
const sidebar = buildSidebar(config, result);
|
|
172
294
|
|
|
295
|
+
// Starlight does not auto-discover pages, so a page absent from the sidebar
|
|
296
|
+
// is reachable only by typing its URL. The pipeline deliberately preserves
|
|
297
|
+
// hand-written pages it did not emit (above), which makes it easy to add one
|
|
298
|
+
// and never wire it up — azure, temporal, helm and github each accumulated
|
|
299
|
+
// several that way (#1312). Report them; `chant dev check-lexicon` gates on
|
|
300
|
+
// the same condition.
|
|
301
|
+
const unreachable = unreachablePages(contentDir, sidebar);
|
|
302
|
+
if (unreachable.length > 0) {
|
|
303
|
+
console.warn(
|
|
304
|
+
`[docs:${config.name}] ${unreachable.length} page(s) in no sidebar entry — reachable only by direct URL: ${unreachable.join(", ")}.\n` +
|
|
305
|
+
` Declare them in this lexicon's DocsConfig \`sidebarExtra\` (or \`extraPages\`) to surface them.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
173
309
|
// package.json
|
|
174
310
|
writeFileSync(
|
|
175
311
|
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));
|