Skip to content

Commit a485333

Browse files
authored
build(scripts): seal generated regions behind a parse-render round trip (#483)
1 parent f52055c commit a485333

9 files changed

Lines changed: 831 additions & 382 deletions

File tree

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

Lines changed: 312 additions & 125 deletions
Large diffs are not rendered by default.

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

Lines changed: 112 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { readFileSync, writeFileSync } from "node:fs";
22
import { join } from "node:path";
33
import { err, ok, Result, safeTry } from "neverthrow";
4-
import { RUN_RESULTS, type RunOutcome } from "../../src/engine/outcome.js";
4+
import { RUN_RESULTS } from "../../src/engine/outcome.js";
55
import {
66
cellFault,
77
renderTable,
8-
roundTrip,
8+
tableBody,
99
tableFault,
10-
tableRoundTrip,
1110
tableRows,
1211
} from "../../src/report/markdown.js";
1312
import type { CoverageRow, SectionDocs } from "../../src/sections/contract/docs.js";
@@ -21,10 +20,17 @@ import { RESOURCE_SLUGS } from "../../src/sections/contract/permissions.js";
2120
import { DOCS } from "../../src/sections/docs-registry.js";
2221
import { SECTIONS } from "../../src/sections/registry.js";
2322
import type { UndeclaredPolicy } from "../../src/types.js";
24-
import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
23+
import { type Architecture, readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
2524
import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js";
2625
import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js";
27-
import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js";
26+
import {
27+
block,
28+
blockLine,
29+
blockLines,
30+
GeneratedRegion,
31+
regenerateRegions,
32+
regionBounds,
33+
} from "./lib/generated-regions.js";
2834

2935
const ROOT = join(import.meta.dir, "..", "..");
3036
export const COVERAGE_PATH = "docs/reference/coverage.md";
@@ -121,18 +127,21 @@ export function sectionsCells(
121127
}
122128

123129
// The generator's boundary: the table rule is the author's to meet, so a faulty row stops the build naming it.
124-
export function renderSectionsTable(
125-
sections: readonly SectionsTableRow[],
126-
docs: Readonly<Record<string, Pick<SectionDocs, "sections_table">>>,
127-
): string {
128-
const cells = sectionsCells(sections, docs);
130+
export function renderSectionsRows(cells: ReadonlyArray<readonly string[]>): string {
129131
const fault = tableFault(SECTIONS_TABLE_HEADER, cells, "keyed");
130132
if (fault !== undefined) {
131133
throw new Error(fault);
132134
}
133135
return renderTable(SECTIONS_TABLE_HEADER, cells);
134136
}
135137

138+
export function renderSectionsTable(
139+
sections: readonly SectionsTableRow[],
140+
docs: Readonly<Record<string, Pick<SectionDocs, "sections_table">>>,
141+
): string {
142+
return renderSectionsRows(sectionsCells(sections, docs));
143+
}
144+
136145
/**
137146
* A cell of authored prose, refused rather than escaped: an escape would silently change the rendered text.
138147
* `where` names the cell as its author knows it.
@@ -622,14 +631,26 @@ function parseCoveragePage(body: string): Result<CoveragePage, string> {
622631
});
623632
}
624633

625-
/** The prose after the enumeration; the region's body regex admits exactly this tail. */
634+
/** The prose after the enumeration, read back as the one tail the guard admits. */
626635
const RESULT_TAIL =
627636
", worst first across the run's targets; the exit code is 1 exactly when it is `failed`, or `drift` in mode: check";
628637

629-
export function renderOutputsList(results: readonly RunOutcome[]): string {
638+
export function renderOutputsList(results: readonly string[]): string {
630639
return `${results.map((value) => `\`${value}\``).join(" / ")}${RESULT_TAIL}`;
631640
}
632641

642+
const OUTPUTS_LIST = new RegExp(
643+
String.raw`^(\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*)${RegExp.escape(RESULT_TAIL)}$`,
644+
);
645+
646+
/** The outcome words of an inline outputs list, which sits on the BEGIN marker's own line. */
647+
function parseOutputsList(body: string): Result<readonly string[], string> {
648+
const list = OUTPUTS_LIST.exec(body)?.[1];
649+
return list === undefined
650+
? err("line 0 is not the outcome words in code spans followed by the tail")
651+
: ok(list.split(" / ").map((span) => span.slice(1, -1)));
652+
}
653+
633654
export type TaggedOperation = Pick<SectionOperation, "role" | "grade" | "permission"> & {
634655
readonly section: string;
635656
};
@@ -700,46 +721,99 @@ function sectionsRow(cells: readonly string[]): Result<readonly string[], string
700721
}
701722

702723
function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
703-
return {
724+
return GeneratedRegion.of({
704725
name,
705726
placement: { kind: "under-heading", heading },
706-
body: tableRoundTrip(SECTIONS_TABLE_HEADER, "keyed", sectionsRow, (rows) =>
707-
renderTable(SECTIONS_TABLE_HEADER, rows),
708-
),
709-
render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`,
710-
};
727+
data: () => sectionsCells(SECTIONS, DOCS),
728+
render: block(renderSectionsRows),
729+
parse: tableBody(SECTIONS_TABLE_HEADER, "keyed", sectionsRow),
730+
});
711731
}
712732

713733
function outputsListRegion(name: string, heading: string): GeneratedRegion {
714-
return {
734+
return GeneratedRegion.of<readonly string[]>({
715735
name,
716736
placement: { kind: "under-heading", heading },
717-
body: new RegExp(
718-
String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${RegExp.escape(RESULT_TAIL)})?$`,
719-
),
720-
render: () => renderOutputsList(RUN_RESULTS),
721-
};
737+
data: () => RUN_RESULTS,
738+
render: renderOutputsList,
739+
parse: parseOutputsList,
740+
});
722741
}
723742

743+
const PAT_FORM_DEFINITION = new RegExp(String.raw`^\[${RegExp.escape(PAT_FORM_LABEL)}\]: (\S+)$`);
744+
724745
/** The page's `[...][pat-form]` reference resolves through this tail definition. */
725746
function patUrlRegion(name: string): GeneratedRegion {
726-
return {
747+
return GeneratedRegion.of({
727748
name,
728749
placement: { kind: "tail" },
729-
body: new RegExp(String.raw`^\n(?:\[${RegExp.escape(PAT_FORM_LABEL)}\]: \S+\n)?$`),
730-
render: () => `\n[${PAT_FORM_LABEL}]: ${patFormUrl()}\n`,
731-
};
750+
data: patFormUrl,
751+
render: block((url: string) => `[${PAT_FORM_LABEL}]: ${url}`),
752+
parse: (body) =>
753+
blockLine(body).andThen((line) => {
754+
const url = PAT_FORM_DEFINITION.exec(line)?.[1];
755+
return url === undefined
756+
? err(`line 1 is not the [${PAT_FORM_LABEL}] definition`)
757+
: ok(url);
758+
}),
759+
});
760+
}
761+
762+
const MERMAID_OPEN = "```mermaid";
763+
const FENCE = "```";
764+
const GRAPH_LINE = "graph TD";
765+
// A node id is whatever the renderer makes of a layer name (only "-" is rewritten), so the byte compare judges it.
766+
const NODE_LINE = /^ {2}(\S+)\["([^"]*)"\]$/;
767+
const EDGE_LINE = /^ {2}(\S+) --> (\S+)$/;
768+
769+
/** What the module map draws: the layers with their paths and the import edges; the lint's exclusions are not drawn. */
770+
type ArchitectureMap = Pick<Architecture, "layers" | "edges">;
771+
772+
/**
773+
* The diagram read back into the layers and edges it draws, under the node ids the renderer writes; a line that is
774+
* neither a node nor an edge is authored. Entries are collected before the records are built, so an id such as
775+
* `__proto__` is a key and not a prototype write.
776+
*/
777+
function parseArchitectureMap(body: string): Result<ArchitectureMap, string> {
778+
return blockLines(body).andThen((lines) => {
779+
if (lines[0] !== MERMAID_OPEN) {
780+
return err("line 1 does not open the mermaid fence");
781+
}
782+
if (lines[1] !== GRAPH_LINE) {
783+
return err(`line 2 is not the "${GRAPH_LINE}" line`);
784+
}
785+
if (lines.length < 3 || lines.at(-1) !== FENCE) {
786+
return err(`line ${lines.length} does not close the mermaid fence`);
787+
}
788+
const layers: Array<[string, readonly string[]]> = [];
789+
const edges = new Map<string, string[]>();
790+
for (const [index, line] of lines.slice(2, -1).entries()) {
791+
const node = NODE_LINE.exec(line);
792+
const edge = EDGE_LINE.exec(line);
793+
if (node !== null) {
794+
layers.push([node[1] ?? "", (node[2] ?? "").split("<br>")]);
795+
} else if (edge !== null) {
796+
const from = edge[1] ?? "";
797+
edges.set(from, [...(edges.get(from) ?? []), edge[2] ?? ""]);
798+
} else {
799+
return err(`line ${index + 3} is neither a layer node nor an import edge`);
800+
}
801+
}
802+
return ok({ layers: Object.fromEntries(layers), edges: Object.fromEntries(edges) });
803+
});
732804
}
733805

734806
// Rendered from architecture.yml, which the lint keeps equal to the tree.
735807
function architectureMapRegion(name: string, heading: string): GeneratedRegion {
736-
return {
808+
return GeneratedRegion.of<ArchitectureMap>({
737809
name,
738810
placement: { kind: "under-heading", heading },
739-
body: /^\n(?:\x60{3}mermaid\n(?:[^\x60\n][^\n]*\n)*\x60{3}\n)?$/,
740-
render: () =>
741-
`\n\x60\x60\x60mermaid\n${renderArchitectureMermaid(readArchitecture(ROOT))}\n\x60\x60\x60\n`,
742-
};
811+
data: () => readArchitecture(ROOT),
812+
render: block(
813+
(map) => `${MERMAID_OPEN}\n${renderArchitectureMermaid({ ...map, exclude: [] })}\n${FENCE}`,
814+
),
815+
parse: parseArchitectureMap,
816+
});
743817
}
744818

745819
export const PAGE_REGIONS: Readonly<Record<string, readonly GeneratedRegion[]>> = {
@@ -779,12 +853,13 @@ export function renderPage(path: string, text: string): string {
779853
// The one region closes the file and holds everything below the title (or an empty body between fresh markers), so
780854
// a marker moved over authored prose fails instead of erasing it.
781855
const COVERAGE_REGIONS: readonly GeneratedRegion[] = [
782-
{
856+
GeneratedRegion.of({
783857
name: "coverage",
784858
placement: { kind: "tail" },
785-
body: roundTrip(parseCoveragePage, renderCoveragePage),
786-
render: () => `\n${renderCoverage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS)}\n`,
787-
},
859+
data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS),
860+
render: block(renderCoveragePage),
861+
parse: parseCoveragePage,
862+
}),
788863
];
789864

790865
/** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */

‎.github/scripts/lib/count-word.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ const COUNT_WORDS = [
2929
"twenty",
3030
] as const;
3131

32+
/** The largest count the table names: a parser reading a count from a page refuses one past it before rendering. */
33+
export const COUNT_WORD_MAX = COUNT_WORDS.length - 1;
34+
3235
export function countWord(n: number): string {
3336
const word = COUNT_WORDS[n];
3437
if (word === undefined) {

0 commit comments

Comments
 (0)