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"] },