@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.
Files changed (45) 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-rule-scanning.d.ts.map +1 -1
  10. package/dist/codegen/docs-sections.d.ts.map +1 -1
  11. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  12. package/dist/codegen/docs.d.ts +27 -0
  13. package/dist/codegen/docs.d.ts.map +1 -1
  14. package/dist/codegen/fetch.d.ts +1 -1
  15. package/dist/codegen/fetch.d.ts.map +1 -1
  16. package/dist/deep-observation.d.ts +0 -10
  17. package/dist/deep-observation.d.ts.map +1 -1
  18. package/dist/lifecycle/rollback.d.ts +18 -0
  19. package/dist/lifecycle/rollback.d.ts.map +1 -1
  20. package/dist/lifecycle/status.d.ts +53 -0
  21. package/dist/lifecycle/status.d.ts.map +1 -1
  22. package/dist/yaml.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/cli/commands/check-lexicon-docs.test.ts +90 -0
  25. package/src/cli/commands/check-lexicon-docs.ts +71 -0
  26. package/src/cli/commands/check-lexicon.ts +15 -0
  27. package/src/cli/handlers/components.ts +54 -3
  28. package/src/cli/handlers/graph.test.ts +61 -0
  29. package/src/cli/handlers/lifecycle.ts +8 -1
  30. package/src/cli/main.ts +2 -0
  31. package/src/cli/registry.ts +2 -0
  32. package/src/codegen/docs-rule-scanning.ts +12 -3
  33. package/src/codegen/docs-sections.ts +6 -4
  34. package/src/codegen/docs-sidebar.ts +8 -2
  35. package/src/codegen/docs.ts +141 -5
  36. package/src/codegen/fetch.test.ts +24 -5
  37. package/src/codegen/fetch.ts +19 -1
  38. package/src/deep-observation.test.ts +151 -0
  39. package/src/deep-observation.ts +66 -2
  40. package/src/lifecycle/rollback.test.ts +78 -1
  41. package/src/lifecycle/rollback.ts +41 -2
  42. package/src/lifecycle/status.test.ts +90 -5
  43. package/src/lifecycle/status.ts +85 -2
  44. package/src/yaml.test.ts +89 -0
  45. 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 = normalizeObservation(
360
- await plugin.describeResources({ environment, buildOutput: "", entityNames: Array.from(declared), entities }),
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") {
@@ -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
- `title: "Lint Rules"`,
119
- `description: "Lint rules and post-synth checks provided by the ${config.displayName} lexicon"`,
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: ${lintRules.length} lint rules and ${postSynthRules.length} post-synth checks.`,
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
- const overviewExtraSlugs = new Set((config.extraPages ?? []).map((p) => p.slug));
68
- if (!suppress.has("rules") && !overviewExtraSlugs.has("lint-rules") && rules.length > 0) {
69
- lines.push(`- [Lint Rules](./rules) ${rules.length} rules`);
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 [\`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.`,
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
- if (!suppress.has("rules") && !extraSlugs.has("rules") && !extraSlugs.has("lint-rules") && result.pages.has("rules.mdx")) {
43
- items.push({ label: "Lint Rules", slug: "rules" });
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")) {
@@ -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,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("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));