Skip to content

Commit 0971c6c

Browse files
authored
refactor(scripts): drive every generated region through one registry (#507)
1 parent 434e269 commit 0971c6c

6 files changed

Lines changed: 190 additions & 169 deletions

File tree

‎.github/scripts/gen-action-docs.ts‎

Lines changed: 72 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { readFileSync, writeFileSync } from "node:fs";
21
import { join } from "node:path";
32
import { err, ok, Result } from "neverthrow";
43
import {
@@ -34,13 +33,8 @@ import { SECTIONS } from "../../src/sections/registry.js";
3433
import { agree } from "../../src/text.js";
3534
import type { UndeclaredPolicy } from "../../src/types.js";
3635
import { COUNT_WORD_MAX, countWord } from "./lib/count-word.js";
37-
import {
38-
block,
39-
blockLine,
40-
blockLines,
41-
GeneratedRegion,
42-
regenerateRegions,
43-
} from "./lib/generated-regions.js";
36+
import { block, blockLine, blockLines, GeneratedRegion } from "./lib/generated-regions.js";
37+
import { type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js";
4438

4539
const ROOT = join(import.meta.dir, "..", "..");
4640

@@ -665,96 +659,77 @@ function yamlDeclarations<T>(
665659
* Each region reads its body back and re-renders it, so a marker moved elsewhere fails instead of regenerating
666660
* in the wrong place or erasing authored text.
667661
*/
668-
export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
669-
"action.yml": [
670-
GeneratedRegion.of<Readonly<Record<string, Pick<InputDecl, "description" | "default">>>>({
671-
name: "action-inputs",
672-
placement: { kind: "under-key", key: "inputs" },
673-
data: () => INPUT_DECLS,
674-
render: block(renderActionInputs),
675-
parse: yamlDeclarations("inputs", INPUT_ENTRY),
676-
}),
677-
GeneratedRegion.of<Readonly<Record<string, { readonly description: string }>>>({
678-
name: "action-outputs",
679-
placement: { kind: "under-key", key: "outputs" },
680-
data: () => OUTPUT_DECLS,
681-
render: block(renderActionOutputs),
682-
parse: yamlDeclarations("outputs", OUTPUT_ENTRY),
683-
}),
684-
],
685-
"docs/reference/undeclared-policy.md": [
686-
GeneratedRegion.of<readonly string[]>({
687-
name: "policy-count-sentence",
688-
placement: { kind: "under-heading", heading: "# The undeclared policy" },
689-
data: () => countSentenceKeys(knobbedSections()),
690-
render: block(countSentence),
691-
parse: parseCountSentence,
692-
}),
693-
GeneratedRegion.of<readonly PolicyRow[]>({
694-
name: "policy-defaults-table",
695-
placement: { kind: "under-heading", heading: "## Defaults per section" },
696-
data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE),
697-
render: block(renderPolicyRows),
698-
parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow),
699-
}),
700-
],
701-
"docs/reference/permissions.md": [
702-
GeneratedRegion.of({
703-
name: "permissions-grant-sentence",
704-
placement: { kind: "under-heading", heading: "## What to grant" },
705-
data: () => grantLabels(SECTIONS),
706-
render: block(grantSentence),
707-
parse: parseGrantSentence,
708-
}),
709-
GeneratedRegion.of<readonly GatedRead[]>({
710-
name: "permissions-gated-reads",
711-
placement: { kind: "under-heading", heading: "## How a denial surfaces" },
712-
data: () => gatedReads(SECTIONS),
713-
render: block(gatedBullets),
714-
parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)),
715-
}),
716-
],
717-
"docs/operate/check-mode.md": [
718-
GeneratedRegion.of<readonly GatedRead[]>({
719-
name: "check-mode-gated-reads",
720-
placement: {
721-
kind: "under-heading",
722-
heading: "## Checking settings changes on pull requests",
723-
},
724-
data: () => gatedReads(SECTIONS),
725-
render: block(checkModeGatedReads),
726-
parse: parseCheckModeGatedReads,
727-
}),
728-
],
662+
export const FILES: GeneratedFiles = {
663+
"action.yml": {
664+
regions: [
665+
GeneratedRegion.of<Readonly<Record<string, Pick<InputDecl, "description" | "default">>>>({
666+
name: "action-inputs",
667+
placement: { kind: "under-key", key: "inputs" },
668+
data: () => INPUT_DECLS,
669+
render: block(renderActionInputs),
670+
parse: yamlDeclarations("inputs", INPUT_ENTRY),
671+
}),
672+
GeneratedRegion.of<Readonly<Record<string, { readonly description: string }>>>({
673+
name: "action-outputs",
674+
placement: { kind: "under-key", key: "outputs" },
675+
data: () => OUTPUT_DECLS,
676+
render: block(renderActionOutputs),
677+
parse: yamlDeclarations("outputs", OUTPUT_ENTRY),
678+
}),
679+
],
680+
},
681+
"docs/reference/undeclared-policy.md": {
682+
regions: [
683+
GeneratedRegion.of<readonly string[]>({
684+
name: "policy-count-sentence",
685+
placement: { kind: "under-heading", heading: "# The undeclared policy" },
686+
data: () => countSentenceKeys(knobbedSections()),
687+
render: block(countSentence),
688+
parse: parseCountSentence,
689+
}),
690+
GeneratedRegion.of<readonly PolicyRow[]>({
691+
name: "policy-defaults-table",
692+
placement: { kind: "under-heading", heading: "## Defaults per section" },
693+
data: () => policyRows(knobbedSections(), POLICY_ROW_PROSE),
694+
render: block(renderPolicyRows),
695+
parse: tableBody(DEFAULTS_TABLE_HEADER, "keyed", policyRow),
696+
}),
697+
],
698+
},
699+
"docs/reference/permissions.md": {
700+
regions: [
701+
GeneratedRegion.of({
702+
name: "permissions-grant-sentence",
703+
placement: { kind: "under-heading", heading: "## What to grant" },
704+
data: () => grantLabels(SECTIONS),
705+
render: block(grantSentence),
706+
parse: parseGrantSentence,
707+
}),
708+
GeneratedRegion.of<readonly GatedRead[]>({
709+
name: "permissions-gated-reads",
710+
placement: { kind: "under-heading", heading: "## How a denial surfaces" },
711+
data: () => gatedReads(SECTIONS),
712+
render: block(gatedBullets),
713+
parse: (body) => blockLines(body).andThen((lines) => parseGatedBullets(lines, 1)),
714+
}),
715+
],
716+
},
717+
"docs/operate/check-mode.md": {
718+
regions: [
719+
GeneratedRegion.of<readonly GatedRead[]>({
720+
name: "check-mode-gated-reads",
721+
placement: {
722+
kind: "under-heading",
723+
heading: "## Checking settings changes on pull requests",
724+
},
725+
data: () => gatedReads(SECTIONS),
726+
render: block(checkModeGatedReads),
727+
parse: parseCheckModeGatedReads,
728+
}),
729+
],
730+
},
729731
};
730732

731-
export function regenerateText(path: string, text: string): string {
732-
const regions = GENERATED_REGIONS[path];
733-
if (regions === undefined) {
734-
throw new Error(`no generated regions are registered for ${path}`);
735-
}
736-
return regenerateRegions(text, regions, path);
737-
}
738-
739-
export function regenerateAll(): string[] {
740-
const changed: string[] = [];
741-
for (const path of Object.keys(GENERATED_REGIONS)) {
742-
const file = join(ROOT, path);
743-
const before = readFileSync(file, "utf8");
744-
const after = regenerateText(path, before);
745-
if (after !== before) {
746-
writeFileSync(file, after);
747-
changed.push(path);
748-
}
749-
}
750-
return changed;
751-
}
752-
753733
if (import.meta.main) {
754-
const changed = regenerateAll();
755-
console.log(
756-
changed.length === 0
757-
? "generated regions already up to date"
758-
: `regenerated ${changed.join(", ")}`,
759-
);
734+
regenerateFiles("gen-action-docs", FILES, ROOT);
760735
}

‎.github/scripts/gen-docs.ts‎

Lines changed: 40 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { readFileSync, writeFileSync } from "node:fs";
21
import { join } from "node:path";
32
import { err, ok, Result, safeTry } from "neverthrow";
43
import { RUN_RESULTS } from "../../src/engine/outcome.js";
@@ -28,12 +27,11 @@ import {
2827
blockLine,
2928
blockLines,
3029
GeneratedRegion,
31-
regenerateRegions,
3230
regionBounds,
3331
} from "./lib/generated-regions.js";
32+
import { type GeneratedFile, type GeneratedFiles, regenerateFiles } from "./lib/region-driver.js";
3433

3534
const ROOT = join(import.meta.dir, "..", "..");
36-
export const COVERAGE_PATH = "docs/reference/coverage.md";
3735

3836
/** The repository these pages document; the token form's name and description derive from it. */
3937
const REPO_SLUG = "Vivswan/github-settings-as-code";
@@ -816,79 +814,68 @@ function architectureMapRegion(name: string, heading: string): GeneratedRegion {
816814
});
817815
}
818816

819-
export const PAGE_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
820-
"README.md": [patUrlRegion("readme-pat-url")],
821-
"docs/start/getting-started.md": [patUrlRegion("pat-url")],
822-
"docs/reference/sections.md": [sectionsTableRegion("sections-table", "# Sections")],
823-
"docs/reference/inputs.md": [outputsListRegion("outputs-list", "## Outputs")],
824-
"docs/reference/architecture.md": [
825-
architectureMapRegion("architecture-map", "## The module map"),
826-
],
827-
};
828-
829-
// The result must reference the token-form label exactly as often as it defines it, at most once: a renamed
817+
// A rendered page must reference the token-form label exactly as often as it defines it, at most once: a renamed
830818
// reference, a second one, or a stale definition ahead of the generated one (it wins) would leave the page wrong
831819
// while regeneration stays a no-op.
832-
export function renderPage(path: string, text: string): string {
833-
const regions = PAGE_REGIONS[path];
834-
if (regions === undefined) {
835-
throw new Error(`gen-docs: no generated regions are registered for ${path}`);
836-
}
837-
const out = regenerateRegions(text, regions, path);
820+
function patFormFault(page: string, path: string): string | undefined {
838821
// CommonMark trims and case-folds labels and lets the first definition win, so every spelling counts: a mention
839822
// opening a line and ending in ":" is a definition, any other bracketed mention is a reference.
840-
const mentions = [...out.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter(
823+
const mentions = [...page.matchAll(/^ {0,3}\[([^\]]+)\]:|\[([^\]]+)\]/gm)].filter(
841824
(match) => (match[1] ?? match[2] ?? "").trim().toLowerCase() === PAT_FORM_LABEL,
842825
);
843826
const definitions = mentions.filter((match) => match[1] !== undefined).length;
844827
const references = mentions.length - definitions;
845828
if (references !== definitions || definitions > 1) {
846-
throw new Error(
847-
`gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, at most once; found ${references} and ${definitions}`,
829+
return (
830+
`gen-docs: ${path} must reference [${PAT_FORM_LABEL}] exactly as often as it defines it, ` +
831+
`at most once; found ${references} and ${definitions}`
848832
);
849833
}
850-
return out;
834+
return undefined;
851835
}
852836

853-
// The one region closes the file and holds everything below the title (or an empty body between fresh markers), so
854-
// a marker moved over authored prose fails instead of erasing it.
855-
const COVERAGE_REGIONS: readonly GeneratedRegion[] = [
856-
GeneratedRegion.of({
857-
name: "coverage",
858-
placement: { kind: "tail" },
859-
data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS),
860-
render: block(renderCoveragePage),
861-
parse: parseCoveragePage,
862-
}),
863-
];
837+
/** A page with regions, held to the token-form rule once rendered. */
838+
function page(...regions: GeneratedRegion[]): GeneratedFile {
839+
return { regions, rendered: patFormFault };
840+
}
864841

865842
/** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */
866843
const COVERAGE_TITLE = "---\norder: 115\n---\n\n# Coverage\n\n";
867844

868845
// Beyond the shared placement checks, the page must be exactly the title, the region, and one final newline, or
869846
// prose left outside could drift from the generator's.
870-
export function renderCoverageFile(coverage: string): string {
847+
function coverageShapeFault(coverage: string, path: string): string | undefined {
871848
const { begin, end } = regionBounds(coverage, "coverage", "html");
872849
if (coverage.slice(0, begin[0]) !== COVERAGE_TITLE || coverage.slice(end[1]) !== "\n") {
873-
throw new Error(
874-
`gen-docs: ${COVERAGE_PATH} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`,
875-
);
850+
return `gen-docs: ${path} must be the frontmatter, the "# Coverage" title, the coverage region, and one final newline`;
876851
}
877-
return regenerateRegions(coverage, COVERAGE_REGIONS, COVERAGE_PATH);
852+
return undefined;
878853
}
879854

855+
export const PAGES: GeneratedFiles = {
856+
"docs/reference/coverage.md": {
857+
// The one region closes the file and holds everything below the title (or an empty body between fresh
858+
// markers), so a marker moved over authored prose fails instead of erasing it.
859+
regions: [
860+
GeneratedRegion.of({
861+
name: "coverage",
862+
placement: { kind: "tail" },
863+
data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS),
864+
render: block(renderCoveragePage),
865+
parse: parseCoveragePage,
866+
}),
867+
],
868+
shape: coverageShapeFault,
869+
},
870+
"README.md": page(patUrlRegion("readme-pat-url")),
871+
"docs/start/getting-started.md": page(patUrlRegion("pat-url")),
872+
"docs/reference/sections.md": page(sectionsTableRegion("sections-table", "# Sections")),
873+
"docs/reference/inputs.md": page(outputsListRegion("outputs-list", "## Outputs")),
874+
"docs/reference/architecture.md": page(
875+
architectureMapRegion("architecture-map", "## The module map"),
876+
),
877+
};
878+
880879
if (import.meta.main) {
881-
const pages: ReadonlyArray<readonly [string, (text: string) => string]> = [
882-
[COVERAGE_PATH, renderCoverageFile],
883-
...Object.keys(PAGE_REGIONS).map(
884-
(path) => [path, (text: string) => renderPage(path, text)] as const,
885-
),
886-
];
887-
for (const [file, render] of pages) {
888-
const path = join(ROOT, file);
889-
const before = readFileSync(path, "utf8");
890-
const after = render(before);
891-
writeFileSync(path, after);
892-
console.log(`gen-docs: wrote ${path}${after === before ? " (unchanged)" : ""}`);
893-
}
880+
regenerateFiles("gen-docs", PAGES, ROOT);
894881
}

‎.github/scripts/generated.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
*/
55

66
import { join } from "node:path";
7-
import { GENERATED_REGIONS } from "./gen-action-docs.js";
8-
import { COVERAGE_PATH, PAGE_REGIONS } from "./gen-docs.js";
7+
import { FILES } from "./gen-action-docs.js";
8+
import { PAGES } from "./gen-docs.js";
99
import { INDEX_PATH } from "./gen-gaps-index.js";
1010
import { INPUTS_PAGE_PATH } from "./gen-inputs-table.js";
1111

@@ -32,8 +32,8 @@ function regions(generator: string, paths: readonly string[]): GeneratedOutput[]
3232
*/
3333
export const GENERATED_OUTPUTS: readonly GeneratedOutput[] = [
3434
{ path: INDEX_PATH, generator: "build:gaps-index", kind: "file" },
35-
...regions("build:docs", [COVERAGE_PATH, ...Object.keys(PAGE_REGIONS)]),
36-
...regions("build:action-docs", Object.keys(GENERATED_REGIONS)),
35+
...regions("build:docs", Object.keys(PAGES)),
36+
...regions("build:action-docs", Object.keys(FILES)),
3737
{ path: INPUTS_PAGE_PATH, generator: "build:inputs-table", kind: "regions" },
3838
];
3939

0 commit comments

Comments
 (0)