From a65fcdabe9bbb5ddb5a58fbf44d74e3fed01d661 Mon Sep 17 00:00:00 2001 From: Vivswan Shah <58091053+Vivswan@users.noreply.github.com> Date: Tue, 6 Oct 2026 06:40:07 -0400 Subject: [PATCH] refactor(scripts): drive every generated region through one registry gen-docs.ts and gen-action-docs.ts each drove their generated regions by hand: a lookup in a path-to-regions record, a throw for an unregistered path, a call to regenerateRegions, a read/write loop in main, and a log line. lib/region-driver.ts is now the one driver both call. A registry maps each path to its regions and the two whole-file rules no region's placement can carry: a shape rule judged on the file as read, before any region, and a rendered rule judged over the regenerated text. gen-docs's two page rules become registry data: the coverage page's title-and-tail rule is its shape, and the token-form label rule is every other page's rendered rule. The coverage page joins the docs registry, so generated.ts derives the docs rows from one Object.keys(). The four wrappers renderPage, renderCoverageFile, regenerateText, and regenerateAll go; tests call the driver with the registry. Every generated page regenerates byte-identically. The one message change is the unregistered-path refusal, which no longer carries a "gen-docs: " prefix; no user reaches it, since paths come from the registry keys. --- .github/scripts/gen-action-docs.ts | 169 ++++++++++++--------------- .github/scripts/gen-docs.ts | 93 +++++++-------- .github/scripts/generated.ts | 8 +- .github/scripts/lib/region-driver.ts | 54 +++++++++ test/scripts/gen-action-docs.test.ts | 18 +-- test/scripts/gen-docs.test.ts | 17 ++- 6 files changed, 190 insertions(+), 169 deletions(-) create mode 100644 .github/scripts/lib/region-driver.ts diff --git a/.github/scripts/gen-action-docs.ts b/.github/scripts/gen-action-docs.ts index d34f2d8a..250a6312 100644 --- a/.github/scripts/gen-action-docs.ts +++ b/.github/scripts/gen-action-docs.ts @@ -1,4 +1,3 @@ -import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok, Result } from "neverthrow"; import { @@ -34,13 +33,8 @@ import { SECTIONS } from "../../src/sections/registry.js"; import { agree } from "../../src/text.js"; import type { UndeclaredPolicy } from "../../src/types.js"; import { COUNT_WORD_MAX, countWord } from "./lib/count-word.js"; -import { - block, - blockLine, - blockLines, - GeneratedRegion, - regenerateRegions, -} from "./lib/generated-regions.js"; +import { block, blockLine, blockLines, GeneratedRegion } from "./lib/generated-regions.js"; +import { type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js"; const ROOT = join(import.meta.dir, "..", ".."); @@ -665,96 +659,77 @@ function yamlDeclarations( * Each region reads its body back and re-renders it, so a marker moved elsewhere fails instead of regenerating * in the wrong place or erasing authored text. */ -export const GENERATED_REGIONS: Readonly> = { - "action.yml": [ - GeneratedRegion.of>>>({ - name: "action-inputs", - placement: { kind: "under-key", key: "inputs" }, - data: () => INPUT_DECLS, - render: block(renderActionInputs), - parse: yamlDeclarations("inputs", INPUT_ENTRY), - }), - GeneratedRegion.of>>({ - name: "action-outputs", - placement: { kind: "under-key", key: "outputs" }, - data: () => OUTPUT_DECLS, - render: block(renderActionOutputs), - parse: yamlDeclarations("outputs", OUTPUT_ENTRY), - }), - ], - "docs/reference/undeclared-policy.md": [ - GeneratedRegion.of({ - name: "policy-count-sentence", - placement: { kind: "under-heading", heading: "# The undeclared policy" }, - data: () => countSentenceKeys(knobbedSections()), - render: block(countSentence), - parse: parseCountSentence, - }), - GeneratedRegion.of({ - name: "policy-defaults-table", - placement: { kind: "under-heading", heading: "## Defaults per section" }, - data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE), - render: block(renderPolicyRows), - parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow), - }), - ], - "docs/reference/permissions.md": [ - GeneratedRegion.of({ - name: "permissions-grant-sentence", - placement: { kind: "under-heading", heading: "## What to grant" }, - data: () => grantLabels(SECTIONS), - render: block(grantSentence), - parse: parseGrantSentence, - }), - GeneratedRegion.of({ - name: "permissions-gated-reads", - placement: { kind: "under-heading", heading: "## How a denial surfaces" }, - data: () => gatedReads(SECTIONS), - render: block(gatedBullets), - parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)), - }), - ], - "docs/operate/check-mode.md": [ - GeneratedRegion.of({ - name: "check-mode-gated-reads", - placement: { - kind: "under-heading", - heading: "## Checking settings changes on pull requests", - }, - data: () => gatedReads(SECTIONS), - render: block(checkModeGatedReads), - parse: parseCheckModeGatedReads, - }), - ], +export const FILES: GeneratedFiles = { + "action.yml": { + regions: [ + GeneratedRegion.of>>>({ + name: "action-inputs", + placement: { kind: "under-key", key: "inputs" }, + data: () => INPUT_DECLS, + render: block(renderActionInputs), + parse: yamlDeclarations("inputs", INPUT_ENTRY), + }), + GeneratedRegion.of>>({ + name: "action-outputs", + placement: { kind: "under-key", key: "outputs" }, + data: () => OUTPUT_DECLS, + render: block(renderActionOutputs), + parse: yamlDeclarations("outputs", OUTPUT_ENTRY), + }), + ], + }, + "docs/reference/undeclared-policy.md": { + regions: [ + GeneratedRegion.of({ + name: "policy-count-sentence", + placement: { kind: "under-heading", heading: "# The undeclared policy" }, + data: () => countSentenceKeys(knobbedSections()), + render: block(countSentence), + parse: parseCountSentence, + }), + GeneratedRegion.of({ + name: "policy-defaults-table", + placement: { kind: "under-heading", heading: "## Defaults per section" }, + data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE), + render: block(renderPolicyRows), + parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow), + }), + ], + }, + "docs/reference/permissions.md": { + regions: [ + GeneratedRegion.of({ + name: "permissions-grant-sentence", + placement: { kind: "under-heading", heading: "## What to grant" }, + data: () => grantLabels(SECTIONS), + render: block(grantSentence), + parse: parseGrantSentence, + }), + GeneratedRegion.of({ + name: "permissions-gated-reads", + placement: { kind: "under-heading", heading: "## How a denial surfaces" }, + data: () => gatedReads(SECTIONS), + render: block(gatedBullets), + parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)), + }), + ], + }, + "docs/operate/check-mode.md": { + regions: [ + GeneratedRegion.of({ + name: "check-mode-gated-reads", + placement: { + kind: "under-heading", + heading: "## Checking settings changes on pull requests", + }, + data: () => gatedReads(SECTIONS), + render: block(checkModeGatedReads), + parse: parseCheckModeGatedReads, + }), + ], + }, }; -export function regenerateText(path: string, text: string): string { - const regions = GENERATED_REGIONS[path]; - if (regions === undefined) { - throw new Error(`no generated regions are registered for ${path}`); - } - return regenerateRegions(text, regions, path); -} - -export function regenerateAll(): string[] { - const changed: string[] = []; - for (const path of Object.keys(GENERATED_REGIONS)) { - const file = join(ROOT, path); - const before = readFileSync(file, "utf8"); - const after = regenerateText(path, before); - if (after !== before) { - writeFileSync(file, after); - changed.push(path); - } - } - return changed; -} - if (import.meta.main) { - const changed = regenerateAll(); - console.log( - changed.length === 0 - ? "generated regions already up to date" - : `regenerated ${changed.join(", ")}`, - ); + regenerateFiles("gen-action-docs", FILES, ROOT); } diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index a44753a5..e0fb0d54 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -1,4 +1,3 @@ -import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok, Result, safeTry } from "neverthrow"; import { RUN_RESULTS } from "../../src/engine/outcome.js"; @@ -28,12 +27,11 @@ import { blockLine, blockLines, GeneratedRegion, - regenerateRegions, regionBounds, } from "./lib/generated-regions.js"; +import { type GeneratedFile, type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js"; const ROOT = join(import.meta.dir, "..", ".."); -export const COVERAGE_PATH = "docs/reference/coverage.md"; /** The repository these pages document; the token form's name and description derive from it. */ const REPO_SLUG = "Vivswan/github-settings-as-code"; @@ -816,79 +814,68 @@ function architectureMapRegion(name: string, heading: string): GeneratedRegion { }); } -export const PAGE_REGIONS: Readonly> = { - "README.md": [patUrlRegion("readme-pat-url")], - "docs/start/getting-started.md": [patUrlRegion("pat-url")], - "docs/reference/sections.md": [sectionsTableRegion("sections-table", "# Sections")], - "docs/reference/inputs.md": [outputsListRegion("outputs-list", "## Outputs")], - "docs/reference/architecture.md": [ - architectureMapRegion("architecture-map", "## The module map"), - ], -}; - -// The result must reference the token-form label exactly as often as it defines it, at most once: a renamed +// A rendered page must reference the token-form label exactly as often as it defines it, at most once: a renamed // reference, a second one, or a stale definition ahead of the generated one (it wins) would leave the page wrong // while regeneration stays a no-op. -export function renderPage(path: string, text: string): string { - const regions = PAGE_REGIONS[path]; - if (regions === undefined) { - throw new Error(`gen-docs: no generated regions are registered for ${path}`); - } - const out = regenerateRegions(text, regions, path); +function patFormFault(page: string, path: string): string | undefined { // CommonMark trims and case-folds labels and lets the first definition win, so every spelling counts: a mention // opening a line and ending in ":" is a definition, any other bracketed mention is a reference. - const mentions = [...out.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter( + const mentions = [...page.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter( (match) => (match[1] ?? match[2] ?? "").trim().toLowerCase() === PAT_FORM_LABEL, ); const definitions = mentions.filter((match) => match[1] !== undefined).length; const references = mentions.length - definitions; if (references !== definitions || definitions > 1) { - throw new Error( - `gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, at most once; found ${references} and ${definitions}`, + return ( + `gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, ` + + `at most once; found ${references} and ${definitions}` ); } - return out; + return undefined; } -// The one region closes the file and holds everything below the title (or an empty body between fresh markers), so -// a marker moved over authored prose fails instead of erasing it. -const COVERAGE_REGIONS: readonly GeneratedRegion[] = [ - GeneratedRegion.of({ - name: "coverage", - placement: { kind: "tail" }, - data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS), - render: block(renderCoveragePage), - parse: parseCoveragePage, - }), -]; +/** A page with regions, held to the token-form rule once rendered. */ +function page(...regions: GeneratedRegion[]): GeneratedFile { + return { regions, rendered: patFormFault }; +} /** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */ const COVERAGE_TITLE = "---\norder: 115\n---\n\n# Coverage\n\n"; // Beyond the shared placement checks, the page must be exactly the title, the region, and one final newline, or // prose left outside could drift from the generator's. -export function renderCoverageFile(coverage: string): string { +function coverageShapeFault(coverage: string, path: string): string | undefined { const { begin, end } = regionBounds(coverage, "coverage", "html"); if (coverage.slice(0, begin[0]) !== COVERAGE_TITLE || coverage.slice(end[1]) !== "\n") { - throw new Error( - `gen-docs: ${COVERAGE_PATH} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`, - ); + return `gen-docs: ${path} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`; } - return regenerateRegions(coverage, COVERAGE_REGIONS, COVERAGE_PATH); + return undefined; } +export const PAGES: GeneratedFiles = { + "docs/reference/coverage.md": { + // The one region closes the file and holds everything below the title (or an empty body between fresh + // markers), so a marker moved over authored prose fails instead of erasing it. + regions: [ + GeneratedRegion.of({ + name: "coverage", + placement: { kind: "tail" }, + data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS), + render: block(renderCoveragePage), + parse: parseCoveragePage, + }), + ], + shape: coverageShapeFault, + }, + "README.md": page(patUrlRegion("readme-pat-url")), + "docs/start/getting-started.md": page(patUrlRegion("pat-url")), + "docs/reference/sections.md": page(sectionsTableRegion("sections-table", "# Sections")), + "docs/reference/inputs.md": page(outputsListRegion("outputs-list", "## Outputs")), + "docs/reference/architecture.md": page( + architectureMapRegion("architecture-map", "## The module map"), + ), +}; + if (import.meta.main) { - const pages: ReadonlyArray string]> = [ - [COVERAGE_PATH, renderCoverageFile], - ...Object.keys(PAGE_REGIONS).map( - (path) => [path, (text: string) => renderPage(path, text)] as const, - ), - ]; - for (const [file, render] of pages) { - const path = join(ROOT, file); - const before = readFileSync(path, "utf8"); - const after = render(before); - writeFileSync(path, after); - console.log(`gen-docs: wrote ${path}${after === before ? " (unchanged)" : ""}`); - } + regenerateFiles("gen-docs", PAGES, ROOT); } diff --git a/.github/scripts/generated.ts b/.github/scripts/generated.ts index 26b1395c..2730b01e 100644 --- a/.github/scripts/generated.ts +++ b/.github/scripts/generated.ts @@ -4,8 +4,8 @@ */ import { join } from "node:path"; -import { GENERATED_REGIONS } from "./gen-action-docs.js"; -import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js"; +import { FILES } from "./gen-action-docs.js"; +import { PAGES } from "./gen-docs.js"; import { INDEX_PATH } from "./gen-gaps-index.js"; import { INPUTS_PAGE_PATH } from "./gen-inputs-table.js"; @@ -32,8 +32,8 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[] */ export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [ { path: INDEX_PATH, generator: "build:gaps-index", kind: "file" }, - ...regions("build:docs", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]), - ...regions("build:action-docs", Object.keys(GENERATED_REGIONS)), + ...regions("build:docs", Object.keys(PAGES)), + ...regions("build:action-docs", Object.keys(FILES)), { path: INPUTS_PAGE_PATH, generator: "build:inputs-table", kind: "regions" }, ]; diff --git a/.github/scripts/lib/region-driver.ts b/.github/scripts/lib/region-driver.ts new file mode 100644 index 00000000..fb17d784 --- /dev/null +++ b/.github/scripts/lib/region-driver.ts @@ -0,0 +1,54 @@ +// The registry shape and the regeneration loop every region generator shares, so a generator declares its +// files and the rules around their regions, and drives nothing by hand. + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { type GeneratedRegion, regenerateRegions } from "./generated-regions.js"; + +/** One file a generator writes into: its regions, and the whole-file rules no region's placement can carry. */ +export interface GeneratedFile { + readonly regions: readonly GeneratedRegion[]; + /** Why the file as read is not one the generator writes into, judged before any region is; `path` names the file. */ + readonly shape?: (text: string, path: string) => string | undefined; + /** Why the file reads wrong once every region is re-rendered, judged over the regenerated text. */ + readonly rendered?: (text: string, path: string) => string | undefined; +} + +/** A generator's registry: every file it regenerates, by repo-relative path, in the order it writes them. */ +export type GeneratedFiles = Readonly>; + +export function regenerateFile(files: GeneratedFiles, path: string, text: string): string { + const file = files[path]; + if (file === undefined) { + throw new Error(`no generated regions are registered for ${path}`); + } + const shape = file.shape?.(text, path); + if (shape !== undefined) { + throw new Error(shape); + } + const out = regenerateRegions(text, file.regions, path); + const rendered = file.rendered?.(out, path); + if (rendered !== undefined) { + throw new Error(rendered); + } + return out; +} + +/** A generator's main; `generator` names it in the one line printed. */ +export function regenerateFiles(generator: string, files: GeneratedFiles, root: string): void { + const changed: string[] = []; + for (const path of Object.keys(files)) { + const file = join(root, path); + const before = readFileSync(file, "utf8"); + const after = regenerateFile(files, path, before); + if (after !== before) { + writeFileSync(file, after); + changed.push(path); + } + } + console.log( + changed.length === 0 + ? `${generator}: generated regions already up to date` + : `${generator}: regenerated ${changed.join(", ")}`, + ); +} diff --git a/test/scripts/gen-action-docs.test.ts b/test/scripts/gen-action-docs.test.ts index c5146916..4823aa48 100644 --- a/test/scripts/gen-action-docs.test.ts +++ b/test/scripts/gen-action-docs.test.ts @@ -4,13 +4,12 @@ import { join } from "node:path"; import { parse as parseYaml } from "yaml"; import { DEFAULTS_TABLE_HEADER, - GENERATED_REGIONS, + FILES, type KnobbedSection, type PolicyRow, type PolicyRowProse, policyCells, policyTableFault, - regenerateText, renderActionInputs, renderActionOutputs, renderCheckModeGatedReads, @@ -24,6 +23,7 @@ import { markerSyntaxFor, regionBounds, } from "../../.github/scripts/lib/generated-regions.js"; +import { regenerateFile } from "../../.github/scripts/lib/region-driver.js"; import { OUTPUT_DECLS } from "../../src/action/io.js"; import { INPUT_DECLS } from "../../src/flows/inputs.js"; import { tableRow } from "../../src/report/markdown.js"; @@ -278,8 +278,8 @@ describe("permissions renderers", () => { describe("generated files", () => { test.each( - Object.entries(GENERATED_REGIONS).flatMap(([path, regions]) => - regions.map((region): [name: string, path: string, region: GeneratedRegion] => [ + Object.entries(FILES).flatMap(([path, file]) => + file.regions.map((region): [name: string, path: string, region: GeneratedRegion] => [ region.name, path, region, @@ -299,13 +299,13 @@ describe("generated files", () => { ? `must sit under "${placement.heading}" in ${path}` : `must close ${path}`; expect(() => - regenerateText(path, relocatedRegion(text, name, markerSyntaxFor(path), anchor)), + regenerateFile(FILES, path, relocatedRegion(text, name, markerSyntaxFor(path), anchor)), ).toThrow(`the ${name} region ${home}`); }); const regions = new Map( - Object.values(GENERATED_REGIONS) - .flat() + Object.values(FILES) + .flatMap((file) => file.regions) .map((region) => [region.name, region]), ); const regionOf = (name: string): GeneratedRegion => { @@ -382,8 +382,8 @@ describe("generated files", () => { test("each region refuses authored text and every other region's body", () => { // A parse loosened to read anything back would still pass the renderer test above. - const regions = Object.entries(GENERATED_REGIONS).flatMap(([path, list]) => - list.map((region) => ({ path, region })), + const regions = Object.entries(FILES).flatMap(([path, file]) => + file.regions.map((region) => ({ path, region })), ); const bodies = new Map( regions.map(({ path, region }) => { diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index 1a03825f..790b605c 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -7,11 +7,9 @@ import type { EndpointAnchors } from "../../.github/scripts/endpoint-docs.js"; import { type CoverageSection, FACT_WORD_CAP, - PAGE_REGIONS, + PAGES, patFormParameters, renderCoverage, - renderCoverageFile, - renderPage, renderPatCell, renderPatFormUrl, renderSectionsTable, @@ -20,10 +18,14 @@ import { sectionsCells, } from "../../.github/scripts/gen-docs.js"; import type { GeneratedRegion } from "../../.github/scripts/lib/generated-regions.js"; +import { regenerateFile } from "../../.github/scripts/lib/region-driver.js"; import { tableFault, tableRow } from "../../src/report/markdown.js"; import type { SectionDocs } from "../../src/sections/contract/docs.js"; import { ROOT } from "../root.js"; +const renderPage = (path: string, text: string): string => regenerateFile(PAGES, path, text); +const renderCoverageFile = (text: string): string => renderPage("docs/reference/coverage.md", text); + /** The region `name` among `regions`; a page that lost it fails here, not on an undefined read. */ function regionNamed( regions: readonly GeneratedRegion[] | undefined, @@ -94,7 +96,7 @@ describe("renderSectionsTable", () => { }); describe("the Sections table guard", () => { - const region = regionNamed(PAGE_REGIONS["docs/reference/sections.md"], "sections-table"); + const region = regionNamed(PAGES["docs/reference/sections.md"]?.regions, "sections-table"); const sectionsRow = "| `labels` | labels CRUD | Issues: write | deleted (settable) | upsert by name |"; const sectionsTable = (rows: string): string => @@ -647,7 +649,7 @@ describe("patFormParameters and renderPatFormUrl", () => { describe("the committed pages", () => { test("a page without registered regions is refused", () => { expect(() => renderPage("docs/README.md", "")).toThrow( - "gen-docs: no generated regions are registered for docs/README.md", + "no generated regions are registered for docs/README.md", ); }); @@ -760,7 +762,10 @@ describe("the architecture map guard", () => { test("a layer id the renderer leaves as is (a dot, a digit) reads back, since only dashes are rewritten", () => { // The parse and renderArchitectureMermaid() must agree on what a node id may hold, or a renamed layer would // refuse its own freshly generated page. - const region = regionNamed(PAGE_REGIONS["docs/reference/architecture.md"], "architecture-map"); + const region = regionNamed( + PAGES["docs/reference/architecture.md"]?.regions, + "architecture-map", + ); const diagram = renderArchitectureMermaid({ layers: { "plain.data": ["src/plain-data.ts"], v2: ["src/v2/", "src/v2.ts"] }, edges: { "plain.data": ["v2"] },