diff --git a/.github/scripts/gen-action-docs.ts b/.github/scripts/gen-action-docs.ts index bf8cbb86..d34f2d8a 100644 --- a/.github/scripts/gen-action-docs.ts +++ b/.github/scripts/gen-action-docs.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { err, ok, type Result } from "neverthrow"; +import { err, ok, Result } from "neverthrow"; import { Document, type DocumentOptions, @@ -15,12 +15,7 @@ import { z } from "zod"; import { OUTPUT_DECLS } from "../../src/action/io.js"; import type { InputDecl } from "../../src/flows/inputs.js"; import { INPUT_DECLS } from "../../src/flows/inputs.js"; -import { - renderedMismatch, - renderTable, - tableFault, - tableRoundTrip, -} from "../../src/report/markdown.js"; +import { renderTable, tableBody, tableFault } from "../../src/report/markdown.js"; import { UNDECLARED_POLICY_SECTIONS, type UndeclaredPolicySection } from "../../src/schema.js"; import { overrideAdviceLevel } from "../../src/sections/contract/errors.js"; import type { SectionMeta } from "../../src/sections/contract/module.js"; @@ -38,8 +33,14 @@ import { import { SECTIONS } from "../../src/sections/registry.js"; import { agree } from "../../src/text.js"; import type { UndeclaredPolicy } from "../../src/types.js"; -import { countWord } from "./lib/count-word.js"; -import { type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js"; +import { COUNT_WORD_MAX, countWord } from "./lib/count-word.js"; +import { + block, + blockLine, + blockLines, + GeneratedRegion, + regenerateRegions, +} from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); @@ -148,7 +149,16 @@ export function renderActionOutputs( })); } +/** Why `item` cannot be one item of a prose list: a comma or the word "and" would read back as a joiner. */ +function proseItemFault(item: string): string | undefined { + return /,|(^|\s)and(\s|$)/.test(item) ? 'holds a comma or the word "and"' : undefined; +} + function proseList(items: readonly string[]): string { + const faulty = items.find((item) => proseItemFault(item) !== undefined); + if (faulty !== undefined) { + throw new Error(`"${faulty}" ${proseItemFault(faulty)}, which an item of a prose list cannot`); + } if (items.length <= 2) { return items.join(" and "); } @@ -160,19 +170,57 @@ export interface KnobbedSection { readonly undeclaredDefault: "delete" | "keep"; } -function deleteFirst(sections: readonly KnobbedSection[]): KnobbedSection[] { +function deleteFirst(items: readonly T[], policy: (item: T) => UndeclaredPolicy): T[] { return [ - ...sections.filter((section) => section.undeclaredDefault === "delete"), - ...sections.filter((section) => section.undeclaredDefault === "keep"), + ...items.filter((item) => policy(item) === "delete"), + ...items.filter((item) => policy(item) === "keep"), ]; } const COUNT_SENTENCE_LEAD = " sections list the live resources sitting next to the declared ones: "; +/** The count sentence over `keys` in the order it lists them: the count in words, then the keys in code spans. */ +function countSentence(keys: readonly string[]): string { + const word = countWord(keys.length); + const spans = keys.map((key) => `\`${key}\``); + return `${word.charAt(0).toUpperCase()}${word.slice(1)}${COUNT_SENTENCE_LEAD}${proseList(spans)}.`; +} + +function countSentenceKeys(sections: readonly KnobbedSection[]): string[] { + return deleteFirst(sections, (section) => section.undeclaredDefault).map( + (section) => section.key, + ); +} + export function renderPolicyCountSentence(sections: readonly KnobbedSection[]): string { - const word = countWord(sections.length); - const keys = deleteFirst(sections).map((section) => `\`${section.key}\``); - return `${word.charAt(0).toUpperCase()}${word.slice(1)}${COUNT_SENTENCE_LEAD}${proseList(keys)}.`; + return countSentence(countSentenceKeys(sections)); +} + +const COUNT_SENTENCE = new RegExp( + String.raw`^[A-Z][a-z-]*${RegExp.escape(COUNT_SENTENCE_LEAD)}(.+)\.$`, +); + +/** + * The keys a count sentence lists; the count word is the renderer's to re-derive, so the byte compare judges it. + * A count past the word table is refused here, since the renderer throws on it instead of rendering. + */ +function parseCountSentence(body: string): Result { + return blockLine(body).andThen((line) => { + const listed = COUNT_SENTENCE.exec(line)?.[1]; + if (listed === undefined) { + return err("line 1 is not the count sentence"); + } + const keys = [...listed.matchAll(/`([a-z_]+)`/g)].map((span) => span[1] ?? ""); + if (keys.length === 0) { + return err("line 1 names no section key in a code span"); + } + if (keys.length > COUNT_WORD_MAX) { + return err( + `line 1 lists ${keys.length} section keys, past the ${COUNT_WORD_MAX} the count words name`, + ); + } + return ok(keys); + }); } /** A Defaults-per-section row's prose: the default's parenthesized `caveat`, and what the opposite policy (`override`) buys. */ @@ -233,11 +281,12 @@ export function policyTableFault(rows: readonly PolicyRow[]): string | undefined return tableFault(DEFAULTS_TABLE_HEADER, rows.map(policyCells), "keyed"); } -export function renderPolicyDefaultsTable( +/** Each section's Defaults row with its prose, delete-first; a section without prose stops the build naming it. */ +function policyRows( sections: readonly KnobbedSection[], prose: Readonly>, -): string { - const rows = deleteFirst(sections).map((section): PolicyRow => { +): PolicyRow[] { + return deleteFirst(sections, (section) => section.undeclaredDefault).map((section): PolicyRow => { const text = prose[section.key]; if (text === undefined) { throw new Error(`no Defaults-per-section prose for the "${section.key}" section`); @@ -245,20 +294,29 @@ export function renderPolicyDefaultsTable( // Read once, so the fault check and the cells see the same values. return { section, prose: { caveat: text.caveat, override: text.override } }; }); - const fault = policyTableFault(rows); +} + +/** The Defaults table in delete-first order, the order the guard holds a committed page to through the byte compare. */ +function renderPolicyRows(rows: readonly PolicyRow[]): string { + const ordered = deleteFirst(rows, (row) => row.section.undeclaredDefault); + const fault = policyTableFault(ordered); if (fault !== undefined) { throw new Error(fault); } - return renderTable(DEFAULTS_TABLE_HEADER, rows.map(policyCells)); + return renderTable(DEFAULTS_TABLE_HEADER, ordered.map(policyCells)); +} + +export function renderPolicyDefaultsTable( + sections: readonly KnobbedSection[], + prose: Readonly>, +): string { + return renderPolicyRows(policyRows(sections, prose)); } /** A Defaults row read back to the section and prose it renders from; the delete-first order is then the * renderer's to check, through the byte compare. The dotAll flag keeps the two Unicode line separators * cellFault() admits inside the prose. */ -function policyRow( - cells: readonly string[], - key: string, -): Result<{ readonly section: KnobbedSection; readonly prose: PolicyRowProse }, string> { +function policyRow(cells: readonly string[], key: string): Result { const policy = /^(delete|keep)(?: \((.*)\))?$/s.exec(cells[1] ?? ""); const buys = /^`(delete|keep)`: (.*)$/s.exec(cells[2] ?? ""); if (policy === null) { @@ -342,7 +400,14 @@ function orgLabel(permission: SectionPermission): string[] { const GRANT_SENTENCE_LEAD = "To manage everything in one PAT, grant "; -export function renderGrantSentence(sections: readonly SectionMeta[]): string { +/** The labels the grant sentence names: the write grants, the read-only extras, and the organization grants. */ +interface GrantLabels { + readonly writes: readonly string[]; + readonly reads: readonly string[]; + readonly orgs: readonly string[]; +} + +function grantLabels(sections: readonly SectionMeta[]): GrantLabels { const writes: string[] = []; const reads: string[] = []; const orgs: string[] = []; @@ -362,54 +427,188 @@ export function renderGrantSentence(sections: readonly SectionMeta[]): string { } } const writeList = unique(writes); - const readList = unique(reads).filter((label) => !writeList.includes(label)); - const orgList = unique(orgs); + return { + writes: writeList, + reads: unique(reads).filter((label) => !writeList.includes(label)), + orgs: unique(orgs), + }; +} + +function grantSentence({ writes, reads, orgs }: GrantLabels): string { const extras = [ - ...(readList.length > 0 ? [`${proseList(readList)} at read`] : []), - ...(orgList.length > 0 + ...(reads.length > 0 ? [`${proseList(reads)} at read`] : []), + ...(orgs.length > 0 ? [ - `(for org repos) the ${proseList(orgList)} organization ${agree(orgList.length, "permission", "permissions")} at read`, + `(for org repos) the ${proseList(orgs)} organization ${agree(orgs.length, "permission", "permissions")} at read`, ] : []), ]; const plus = extras.length > 0 ? `, plus ${extras.join(" and ")}` : ""; - return `${GRANT_SENTENCE_LEAD}${proseList(writeList)} at write${plus}.`; + return `${GRANT_SENTENCE_LEAD}${proseList(writes)} at write${plus}.`; +} + +export function renderGrantSentence(sections: readonly SectionMeta[]): string { + return grantSentence(grantLabels(sections)); +} + +/** `items` as a prose list's items, or the first one proseList() would refuse. */ +function proseListItems(items: readonly string[]): Result { + const faulty = items.find((item) => proseItemFault(item) !== undefined); + return faulty === undefined + ? ok(items) + : err(`lists "${faulty}", which ${proseItemFault(faulty)}`); +} + +/** + * proseList() read back: "a", "a and b", or "a, b, and c". Exact, since every item passes proseItemFault(); text + * whose items would not is refused rather than handed to a renderer that throws. + */ +function proseItems(text: string): Result { + const parts = text.split(", "); + const last = parts.at(-1); + return proseListItems( + parts.length > 2 && last?.startsWith("and ") + ? [...parts.slice(0, -1), last.slice("and ".length)] + : text.split(" and "), + ); +} + +const GRANT_SENTENCE = new RegExp( + String.raw`^${RegExp.escape(GRANT_SENTENCE_LEAD)}(.+?) at write(?:, plus (.+))?\.$`, +); +const GRANT_EXTRAS = + /^(?:(.+) at read and )?\(for org repos\) the (.+) organization permissions? at read$|^(.+) at read$/; + +function parseGrantSentence(body: string): Result { + return blockLine(body).andThen((line) => { + const sentence = GRANT_SENTENCE.exec(line); + if (sentence === null) { + return err("line 1 is not the grant sentence"); + } + const extras = sentence[2] === undefined ? null : GRANT_EXTRAS.exec(sentence[2]); + if (sentence[2] !== undefined && extras === null) { + return err("line 1 adds grants in neither the read form nor the organization form"); + } + const reads = extras?.[1] ?? extras?.[3]; + const orgs = extras?.[2]; + return Result.combine([ + proseItems(sentence[1] ?? ""), + reads === undefined ? ok([]) : proseItems(reads), + orgs === undefined ? ok([]) : proseItems(orgs), + ]) + .map(([writes = [], reads = [], orgs = []]): GrantLabels => ({ writes, reads, orgs })) + .mapErr((fault) => `line 1 ${fault}`); + }); +} + +/** + * One gated-reads bullet's facts: which of a section's reads GitHub gates at write, and the grant that unlocks + * them. + */ +type GatedRead = + | { + readonly key: string; + readonly gated: "every"; + readonly labels: readonly string[]; + readonly ownGrant: boolean; + } + | { + readonly key: string; + readonly gated: "some"; + readonly routes: readonly string[]; + readonly labels: readonly string[]; + }; + +function gatedReads(sections: readonly SectionMeta[]): GatedRead[] { + return sections.flatMap((section): GatedRead[] => { + const gated = writeGatedReads(section); + if (gated.length === 0) { + return []; + } + const labels = unique(gated.map((read) => primaryLabel(read.permission))); + if (readGating(section) === "write-gated") { + const ownGrant = gated.every((read) => samePermission(read.permission, section.permission)); + return [{ key: section.key, gated: "every", labels, ownGrant }]; + } + return [{ key: section.key, gated: "some", routes: gated.map((read) => read.route), labels }]; + }); +} + +function gatedBullet(read: GatedRead): string { + if (read.gated === "every") { + const grant = read.ownGrant ? "its write grant" : `the ${proseList(read.labels)} write grant`; + return `- GitHub gates even the ${proseList(read.labels)} reads at write, so \`${read.key}\` needs ${grant} in check mode too.`; + } + const routes = read.routes.map((route) => `\`${route}\``); + return `- GitHub gates the ${proseList(routes)} reads at write, so \`${read.key}\` needs its ${proseList(read.labels)} write grant in check mode to verify what they return.`; +} + +function gatedBullets(reads: readonly GatedRead[]): string { + return reads.map(gatedBullet).join("\n"); } export function renderGatedReads(sections: readonly SectionMeta[]): string { - return sections - .flatMap((section) => { - const gated = writeGatedReads(section); - if (gated.length === 0) { - return []; - } - const labels = unique(gated.map((read) => primaryLabel(read.permission))); - if (readGating(section) === "write-gated") { - const grant = gated.every((read) => samePermission(read.permission, section.permission)) - ? "its write grant" - : `the ${proseList(labels)} write grant`; - return [ - `- GitHub gates even the ${proseList(labels)} reads at write, so \`${section.key}\` needs ${grant} in check mode too.`, - ]; - } - const routes = gated.map((read) => `\`${read.route}\``); - return [ - `- GitHub gates the ${proseList(routes)} reads at write, so \`${section.key}\` needs its ${proseList(labels)} write grant in check mode to verify what they return.`, - ]; - }) - .join("\n"); + return gatedBullets(gatedReads(sections)); +} + +const EVERY_GATED = + /^- GitHub gates even the (.+) reads at write, so `([a-z_]+)` needs (its write grant|the .+ write grant) in check mode too\.$/; +const SOME_GATED = + /^- GitHub gates the (.+) reads at write, so `([a-z_]+)` needs its (.+) write grant in check mode to verify what they return\.$/; + +/** `lines` read back as gated-reads bullets, the first sitting at line `first`. */ +function parseGatedBullets(lines: readonly string[], first: number): Result { + const reads: GatedRead[] = []; + for (const [index, line] of lines.entries()) { + const every = EVERY_GATED.exec(line); + const some = SOME_GATED.exec(line); + if (every === null && some === null) { + return err(`line ${first + index} is not a gated-reads bullet`); + } + const labels = proseItems((every ?? some)?.[every !== null ? 1 : 3] ?? ""); + // A route is listed as a code span, so the span is the item the prose rule judges. + const spans = proseListItems([...(some?.[1] ?? "").matchAll(/`[^`]+`/g)].map((m) => m[0])); + const read = Result.combine([labels, spans]); + if (read.isErr()) { + return err(`line ${first + index} ${read.error}`); + } + const [readLabels = [], routeSpans = []] = read.value; + if (every !== null) { + const ownGrant = every[3] === "its write grant"; + reads.push({ key: every[2] ?? "", gated: "every", labels: readLabels, ownGrant }); + } else if (some !== null) { + const routes = routeSpans.map((span) => span.slice(1, -1)); + reads.push({ key: some[2] ?? "", gated: "some", routes, labels: readLabels }); + } + } + return ok(reads); } const NO_GATED_READS = "A read-only PAT covers every section in check mode."; const GATED_READS_LEAD_IN = "The read-only rule has exceptions, each a section to drop from the preview or grant at write:"; +function checkModeGatedReads(reads: readonly GatedRead[]): string { + return reads.length === 0 ? NO_GATED_READS : `${GATED_READS_LEAD_IN}\n\n${gatedBullets(reads)}`; +} + export function renderCheckModeGatedReads(sections: readonly SectionMeta[]): string { - const bullets = renderGatedReads(sections); - if (bullets === "") { - return NO_GATED_READS; - } - return `${GATED_READS_LEAD_IN}\n\n${bullets}`; + return checkModeGatedReads(gatedReads(sections)); +} + +function parseCheckModeGatedReads(body: string): Result { + return blockLines(body).andThen((lines) => { + if (lines.length === 1 && lines[0] === NO_GATED_READS) { + return ok([]); + } + if (lines[0] !== GATED_READS_LEAD_IN) { + return err("line 1 is neither the read-only sentence nor the lead-in to the gated reads"); + } + if (lines[1] !== "") { + return err("line 2 is not the blank line under the lead-in"); + } + return parseGatedBullets(lines.slice(2), 3); + }); } function knobbedSections(): KnobbedSection[] { @@ -423,121 +622,109 @@ function knobbedSections(): KnobbedSection[] { }); } -/** The empty alternative admits a freshly placed region and an empty rendering alike. */ -function blockShape(lines: string): RegExp { - return new RegExp(String.raw`^\n(?:${lines}|\n)?$`); -} - -/** The emitter is its own grammar: a body is admitted when re-rendering the declarations it parses to reproduces it - * byte for byte, so no spelling the emitter does not write gets through. A freshly placed region holds "\n" and - * renders next. */ -function roundTrip( +/** + * A YAML region's body read back as the `key` mapping's declarations under `entry`, every own name kept; the + * emitter is its own grammar, so a spelling it does not write fails the byte compare that follows. + */ +function yamlDeclarations( key: string, entry: z.ZodType, - render: (decls: Readonly>) => string, -): (body: string) => string | undefined { +): (body: string) => Result, string> { return (body) => { - if (body === "\n") { - return undefined; - } - const doc = parseDocument(`${key}:${body}`, YAML_DOCUMENT); + const doc = parseDocument(`${key}:${body}`, { ...YAML_DOCUMENT, prettyErrors: false }); const error = doc.errors[0]; if (error !== undefined) { - return `it does not parse as YAML (${error.message.split("\n")[0]})`; + // The library counts the prepended key line as line 1, so the line is counted in the body instead. + const line = body.slice(0, error.pos[0] - `${key}:`.length).split("\n").length - 1; + return err(`line ${line} does not parse as YAML (${error.message})`); } let root: unknown; try { root = doc.toJS(); } catch (failure) { // An alias without its anchor parses but does not convert; to the guard that is a refusal. - return `it does not convert from YAML (${failure instanceof Error ? failure.message : String(failure)})`; + return err( + `it does not convert from YAML (${failure instanceof Error ? failure.message : String(failure)})`, + ); } const mapping = typeof root === "object" && root !== null ? ((root as Record)[key] ?? {}) : root; if (typeof mapping !== "object" || mapping === null || Array.isArray(mapping)) { - return `it is not a mapping of ${key} declarations`; + return err(`it is not a mapping of ${key} declarations`); } const declared = declarations(entry, mapping as Record); - if (typeof declared === "string") { - return `it is not a set of ${key} declarations (${declared})`; - } - return renderedMismatch(body, `\n${render(declared)}\n`); + return typeof declared === "string" + ? err(`it is not a set of ${key} declarations (${declared})`) + : ok(declared); }; } -const GATED_READ_BULLET = - String.raw`- GitHub gates (?:even )?the [^\n]+ reads at write, so \x60[a-z_]+\x60 needs ` + - String.raw`(?:its|the)(?: [^\n]+)? write grant in check mode (?:too|to verify what they ` + - String.raw`return)\.\n`; - -function block(render: () => string): () => string { - return () => `\n${render()}\n`; -} - -/** Each region's `body` matches every body this generator could have written for it, so a marker moved elsewhere - * fails instead of regenerating in the wrong place or erasing authored text. */ +/** + * 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" }, - body: roundTrip("inputs", INPUT_ENTRY, renderActionInputs), - render: block(() => renderActionInputs(INPUT_DECLS)), - }, - { + data: () => INPUT_DECLS, + render: block(renderActionInputs), + parse: yamlDeclarations("inputs", INPUT_ENTRY), + }), + GeneratedRegion.of>>({ name: "action-outputs", placement: { kind: "under-key", key: "outputs" }, - body: roundTrip("outputs", OUTPUT_ENTRY, renderActionOutputs), - render: block(() => renderActionOutputs(OUTPUT_DECLS)), - }, + 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" }, - body: blockShape(String.raw`[A-Z][a-z-]*${RegExp.escape(COUNT_SENTENCE_LEAD)}[^\n]+\.\n`), - render: block(() => renderPolicyCountSentence(knobbedSections())), - }, - { + data: () => countSentenceKeys(knobbedSections()), + render: block(countSentence), + parse: parseCountSentence, + }), + GeneratedRegion.of({ name: "policy-defaults-table", placement: { kind: "under-heading", heading: "## Defaults per section" }, - body: tableRoundTrip(DEFAULTS_TABLE_HEADER, "keyed", policyRow, (rows) => - renderPolicyDefaultsTable( - rows.map((row) => row.section), - Object.fromEntries(rows.map((row) => [row.section.key, row.prose])), - ), - ), - render: block(() => renderPolicyDefaultsTable(knobbedSections(), POLICY_ROW_PROSE)), - }, + 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" }, - body: blockShape(String.raw`${RegExp.escape(GRANT_SENTENCE_LEAD)}[^\n]+\.\n`), - render: block(() => renderGrantSentence(SECTIONS)), - }, - { + data: () => grantLabels(SECTIONS), + render: block(grantSentence), + parse: parseGrantSentence, + }), + GeneratedRegion.of({ name: "permissions-gated-reads", placement: { kind: "under-heading", heading: "## How a denial surfaces" }, - body: blockShape(`(?:${GATED_READ_BULLET})+`), - render: block(() => renderGatedReads(SECTIONS)), - }, + 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", }, - body: blockShape( - String.raw`${RegExp.escape(NO_GATED_READS)}\n|${RegExp.escape(GATED_READS_LEAD_IN)}\n\n(?:${GATED_READ_BULLET})+`, - ), - render: block(() => renderCheckModeGatedReads(SECTIONS)), - }, + data: () => gatedReads(SECTIONS), + render: block(checkModeGatedReads), + parse: parseCheckModeGatedReads, + }), ], }; diff --git a/.github/scripts/gen-docs.ts b/.github/scripts/gen-docs.ts index 381aaad5..a44753a5 100644 --- a/.github/scripts/gen-docs.ts +++ b/.github/scripts/gen-docs.ts @@ -1,13 +1,12 @@ import { readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { err, ok, Result, safeTry } from "neverthrow"; -import { RUN_RESULTS, type RunOutcome } from "../../src/engine/outcome.js"; +import { RUN_RESULTS } from "../../src/engine/outcome.js"; import { cellFault, renderTable, - roundTrip, + tableBody, tableFault, - tableRoundTrip, tableRows, } from "../../src/report/markdown.js"; import type { CoverageRow, SectionDocs } from "../../src/sections/contract/docs.js"; @@ -21,10 +20,17 @@ import { RESOURCE_SLUGS } from "../../src/sections/contract/permissions.js"; import { DOCS } from "../../src/sections/docs-registry.js"; import { SECTIONS } from "../../src/sections/registry.js"; import type { UndeclaredPolicy } from "../../src/types.js"; -import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js"; +import { type Architecture, readArchitecture, renderArchitectureMermaid } from "./arch-lint.js"; import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js"; import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js"; -import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js"; +import { + block, + blockLine, + blockLines, + GeneratedRegion, + regenerateRegions, + regionBounds, +} from "./lib/generated-regions.js"; const ROOT = join(import.meta.dir, "..", ".."); export const COVERAGE_PATH = "docs/reference/coverage.md"; @@ -121,11 +127,7 @@ export function sectionsCells( } // The generator's boundary: the table rule is the author's to meet, so a faulty row stops the build naming it. -export function renderSectionsTable( - sections: readonly SectionsTableRow[], - docs: Readonly>>, -): string { - const cells = sectionsCells(sections, docs); +export function renderSectionsRows(cells: ReadonlyArray): string { const fault = tableFault(SECTIONS_TABLE_HEADER, cells, "keyed"); if (fault !== undefined) { throw new Error(fault); @@ -133,6 +135,13 @@ export function renderSectionsTable( return renderTable(SECTIONS_TABLE_HEADER, cells); } +export function renderSectionsTable( + sections: readonly SectionsTableRow[], + docs: Readonly>>, +): string { + return renderSectionsRows(sectionsCells(sections, docs)); +} + /** * A cell of authored prose, refused rather than escaped: an escape would silently change the rendered text. * `where` names the cell as its author knows it. @@ -622,14 +631,26 @@ function parseCoveragePage(body: string): Result { }); } -/** The prose after the enumeration; the region's body regex admits exactly this tail. */ +/** The prose after the enumeration, read back as the one tail the guard admits. */ const RESULT_TAIL = ", worst first across the run's targets; the exit code is 1 exactly when it is `failed`, or `drift` in mode: check"; -export function renderOutputsList(results: readonly RunOutcome[]): string { +export function renderOutputsList(results: readonly string[]): string { return `${results.map((value) => `\`${value}\``).join(" / ")}${RESULT_TAIL}`; } +const OUTPUTS_LIST = new RegExp( + String.raw`^(\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*)${RegExp.escape(RESULT_TAIL)}$`, +); + +/** The outcome words of an inline outputs list, which sits on the BEGIN marker's own line. */ +function parseOutputsList(body: string): Result { + const list = OUTPUTS_LIST.exec(body)?.[1]; + return list === undefined + ? err("line 0 is not the outcome words in code spans followed by the tail") + : ok(list.split(" / ").map((span) => span.slice(1, -1))); +} + export type TaggedOperation = Pick & { readonly section: string; }; @@ -700,46 +721,99 @@ function sectionsRow(cells: readonly string[]): Result - renderTable(SECTIONS_TABLE_HEADER, rows), - ), - render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`, - }; + data: () => sectionsCells(SECTIONS, DOCS), + render: block(renderSectionsRows), + parse: tableBody(SECTIONS_TABLE_HEADER, "keyed", sectionsRow), + }); } function outputsListRegion(name: string, heading: string): GeneratedRegion { - return { + return GeneratedRegion.of({ name, placement: { kind: "under-heading", heading }, - body: new RegExp( - String.raw`^(?:\x60[a-z]+\x60(?: / \x60[a-z]+\x60)*${RegExp.escape(RESULT_TAIL)})?$`, - ), - render: () => renderOutputsList(RUN_RESULTS), - }; + data: () => RUN_RESULTS, + render: renderOutputsList, + parse: parseOutputsList, + }); } +const PAT_FORM_DEFINITION = new RegExp(String.raw`^\[${RegExp.escape(PAT_FORM_LABEL)}\]: (\S+)$`); + /** The page's `[...][pat-form]` reference resolves through this tail definition. */ function patUrlRegion(name: string): GeneratedRegion { - return { + return GeneratedRegion.of({ name, placement: { kind: "tail" }, - body: new RegExp(String.raw`^\n(?:\[${RegExp.escape(PAT_FORM_LABEL)}\]: \S+\n)?$`), - render: () => `\n[${PAT_FORM_LABEL}]: ${patFormUrl()}\n`, - }; + data: patFormUrl, + render: block((url: string) => `[${PAT_FORM_LABEL}]: ${url}`), + parse: (body) => + blockLine(body).andThen((line) => { + const url = PAT_FORM_DEFINITION.exec(line)?.[1]; + return url === undefined + ? err(`line 1 is not the [${PAT_FORM_LABEL}] definition`) + : ok(url); + }), + }); +} + +const MERMAID_OPEN = "```mermaid"; +const FENCE = "```"; +const GRAPH_LINE = "graph TD"; +// A node id is whatever the renderer makes of a layer name (only "-" is rewritten), so the byte compare judges it. +const NODE_LINE = /^ {2}(\S+)\["([^"]*)"\]$/; +const EDGE_LINE = /^ {2}(\S+) --> (\S+)$/; + +/** What the module map draws: the layers with their paths and the import edges; the lint's exclusions are not drawn. */ +type ArchitectureMap = Pick; + +/** + * The diagram read back into the layers and edges it draws, under the node ids the renderer writes; a line that is + * neither a node nor an edge is authored. Entries are collected before the records are built, so an id such as + * `__proto__` is a key and not a prototype write. + */ +function parseArchitectureMap(body: string): Result { + return blockLines(body).andThen((lines) => { + if (lines[0] !== MERMAID_OPEN) { + return err("line 1 does not open the mermaid fence"); + } + if (lines[1] !== GRAPH_LINE) { + return err(`line 2 is not the "${GRAPH_LINE}" line`); + } + if (lines.length < 3 || lines.at(-1) !== FENCE) { + return err(`line ${lines.length} does not close the mermaid fence`); + } + const layers: Array<[string, readonly string[]]> = []; + const edges = new Map(); + for (const [index, line] of lines.slice(2, -1).entries()) { + const node = NODE_LINE.exec(line); + const edge = EDGE_LINE.exec(line); + if (node !== null) { + layers.push([node[1] ?? "", (node[2] ?? "").split("
")]); + } else if (edge !== null) { + const from = edge[1] ?? ""; + edges.set(from, [...(edges.get(from) ?? []), edge[2] ?? ""]); + } else { + return err(`line ${index + 3} is neither a layer node nor an import edge`); + } + } + return ok({ layers: Object.fromEntries(layers), edges: Object.fromEntries(edges) }); + }); } // Rendered from architecture.yml, which the lint keeps equal to the tree. function architectureMapRegion(name: string, heading: string): GeneratedRegion { - return { + return GeneratedRegion.of({ name, placement: { kind: "under-heading", heading }, - body: /^\n(?:\x60{3}mermaid\n(?:[^\x60\n][^\n]*\n)*\x60{3}\n)?$/, - render: () => - `\n\x60\x60\x60mermaid\n${renderArchitectureMermaid(readArchitecture(ROOT))}\n\x60\x60\x60\n`, - }; + data: () => readArchitecture(ROOT), + render: block( + (map) => `${MERMAID_OPEN}\n${renderArchitectureMermaid({ ...map, exclude: [] })}\n${FENCE}`, + ), + parse: parseArchitectureMap, + }); } export const PAGE_REGIONS: Readonly> = { @@ -779,12 +853,13 @@ export function renderPage(path: string, text: string): string { // 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" }, - body: roundTrip(parseCoveragePage, renderCoveragePage), - render: () => `\n${renderCoverage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS)}\n`, - }, + data: () => coveragePage(SECTIONS, DOCS, COVERAGE_DATA, ENDPOINT_ANCHORS), + render: block(renderCoveragePage), + parse: parseCoveragePage, + }), ]; /** The page's frontmatter and title; the sidebar reads `order`, and 115 sits the page right after the Sections table (110). */ diff --git a/.github/scripts/lib/count-word.ts b/.github/scripts/lib/count-word.ts index 2238de35..ce1b0bdf 100644 --- a/.github/scripts/lib/count-word.ts +++ b/.github/scripts/lib/count-word.ts @@ -28,6 +28,9 @@ const COUNT_WORDS = [ "twenty", ] as const; +/** The largest count the table names: a parser reading a count from a page refuses one past it before rendering. */ +export const COUNT_WORD_MAX = COUNT_WORDS.length - 1; + export function countWord(n: number): string { const word = COUNT_WORDS[n]; if (word === undefined) { diff --git a/.github/scripts/lib/generated-regions.ts b/.github/scripts/lib/generated-regions.ts index 1e1977e8..02cef67b 100644 --- a/.github/scripts/lib/generated-regions.ts +++ b/.github/scripts/lib/generated-regions.ts @@ -1,8 +1,10 @@ // The marker grammar every generator splices through: `BEGIN GENERATED: (hint)` to // `END GENERATED: `, each written in the comment syntax of the file's own language, and -// the placement check each region passes before its splice (a RegionSpec says where it belongs). +// the two checks each region passes before its splice: its placement, and the parse-render round +// trip over its body that GeneratedRegion.of() is the one way to declare. import { extname } from "node:path"; +import { err, ok, type Result } from "neverthrow"; import { Parser } from "yaml"; /** Which comment syntax a file's markers use: a complete `` comment, or a whole-line YAML `#` comment. */ @@ -134,29 +136,111 @@ export function replaceRegion( * heading line above both markers, hashes included) or close the file; YAML regions sit directly * under a top-level mapping key and end its mapping. */ -type RegionPlacement = +export type RegionPlacement = | { readonly kind: "under-heading"; readonly heading: string } | { readonly kind: "tail" } | { readonly kind: "under-key"; readonly key: string }; /** - * What a region's body may hold: every body its generator could have written (stale or fresh), and nothing - * authored. A regex names a shape; a check returns why a body is refused, or undefined to admit it. + * What a generator declares for one region: its markers' name, where it belongs, and its body as the + * data it renders from. The body is every byte between the markers, so a block region's `render` + * usually wraps in block(). */ -export type BodyShape = RegExp | ((body: string) => string | undefined); - -/** A generated region as its generator declares it: its markers' name, its home, and the shape of its body. */ -export interface RegionSpec { +export interface RegionDeclaration { readonly name: string; readonly placement: RegionPlacement; - readonly body: BodyShape; + /** The data the committed file is regenerated from. */ + readonly data: () => Data; + /** The body for `data`. */ + readonly render: (data: Data) => string; + /** + * A body read back into the data it renders from, or why it is not one `render` wrote; a line it + * names counts from the BEGIN marker's line as line 0. + */ + readonly parse: (body: string) => Result; +} + +/** The first line on which `body` differs from `rendered`, or undefined when they are the same bytes. */ +function renderedMismatch(body: string, rendered: string): string | undefined { + if (rendered === body) { + return undefined; + } + const authored = body.split("\n"); + const expected = rendered.split("\n"); + const differing = authored.findIndex((line, i) => line !== expected[i]); + const at = differing === -1 ? authored.length : differing; + return `line ${at} reads ${JSON.stringify(authored[at] ?? "")} where the generator writes ${JSON.stringify(expected[at] ?? "")}`; +} + +/** + * The body check of a declaration: a body holding nothing (fresh markers, or an empty rendering) is + * admitted and renders next; any other must parse, and re-rendering what it parsed to must + * reproduce it byte for byte, so an authored spelling the renderer never writes is refused rather + * than erased. + */ +function roundTrip(decl: RegionDeclaration): (body: string) => string | undefined { + return (body) => + body.trim() === "" + ? undefined + : decl.parse(body).match( + (data) => renderedMismatch(body, decl.render(data)), + (refusal) => refusal, + ); } -export function bodyRefusal(shape: BodyShape, body: string): string | undefined { - if (shape instanceof RegExp) { - return shape.test(body) ? undefined : "it does not match the body shape"; +/** + * A region as the generators hand it to regenerateRegions(). Only of() mints one: the constructor is + * private and the class nominal, so a region's body check is always its own declaration's round trip + * and no region can carry a grammar written beside its renderer. + */ +export class GeneratedRegion { + private constructor( + readonly name: string, + readonly placement: RegionPlacement, + private readonly check: (body: string) => string | undefined, + private readonly body: () => string, + ) {} + + static of(decl: RegionDeclaration): GeneratedRegion { + return new GeneratedRegion(decl.name, decl.placement, roundTrip(decl), () => + decl.render(decl.data()), + ); + } + + /** Why `body` is not one the renderer wrote, or undefined for a body it could have written. */ + bodyRefusal(body: string): string | undefined { + return this.check(body); + } + + /** The body rendered from the declaration's data. */ + render(): string { + return this.body(); } - return shape(body); +} + +/** The body of a block region: `render`'s text on the lines between the markers, each marker owning its line. */ +export function block(render: (data: Data) => string): (data: Data) => string { + return (data) => `\n${render(data)}\n`; +} + +/** A block region's body as its lines, the first being line 1; or why the markers do not own their lines. */ +export function blockLines(body: string): Result { + if (!body.startsWith("\n")) { + return err("line 0 runs on past the BEGIN marker"); + } + if (!body.endsWith("\n")) { + return err("the last line runs on into the END marker"); + } + return ok(body.slice(1, -1).split("\n")); +} + +/** The one line of a block region holding one line, or why the body is not that. */ +export function blockLine(body: string): Result { + return blockLines(body).andThen((lines) => + lines.length === 1 + ? ok(lines[0] ?? "") + : err(`line ${lines.length} is a line past the one the generator writes`), + ); } interface MarkdownScan { @@ -259,7 +343,7 @@ function scanMarkdown(text: string): MarkdownScan { function assertMarkdownPlacement( text: string, - spec: RegionSpec, + name: string, placement: Exclude, begin: MarkerSpan, end: MarkerSpan, @@ -277,22 +361,22 @@ function assertMarkdownPlacement( const indent = line.match(/^[ \t]*/)?.[0] ?? ""; if (indent.includes("\t") || indent.length >= 4) { throw new Error( - `the ${spec.name} region's ${marker} marker sits on a line indented as code in ${path}`, + `the ${name} region's ${marker} marker sits on a line indented as code in ${path}`, ); } if (inside(scan.fenced, at)) { - throw new Error(`the ${spec.name} region sits inside a fenced code block in ${path}`); + throw new Error(`the ${name} region sits inside a fenced code block in ${path}`); } if (inside(scan.raw, at)) { - throw new Error(`the ${spec.name} region sits inside a raw HTML block in ${path}`); + throw new Error(`the ${name} region sits inside a raw HTML block in ${path}`); } if (inside(scan.quoted, at)) { - throw new Error(`the ${spec.name} region sits inside a blockquote in ${path}`); + throw new Error(`the ${name} region sits inside a blockquote in ${path}`); } } if (placement.kind === "tail") { if (text.slice(end[1]).trim() !== "") { - throw new Error(`the ${spec.name} region must close ${path}`); + throw new Error(`the ${name} region must close ${path}`); } return; } @@ -307,7 +391,7 @@ function assertMarkdownPlacement( const found = actual === undefined ? "no heading precedes" : `"${actual}" is the heading above`; throw new Error( - `the ${spec.name} region must sit under "${placement.heading}" in ${path}; ${found} its ${marker} marker`, + `the ${name} region must sit under "${placement.heading}" in ${path}; ${found} its ${marker} marker`, ); } } @@ -315,7 +399,7 @@ function assertMarkdownPlacement( function assertYamlPlacement( text: string, - spec: RegionSpec, + name: string, key: string, begin: MarkerSpan, end: MarkerSpan, @@ -330,63 +414,53 @@ function assertYamlPlacement( ? "nothing precedes its BEGIN marker" : `"${above.trim()}" precedes its BEGIN marker`; throw new Error( - `the ${spec.name} region must sit directly under the "${key}:" mapping in ${path}; ${found}`, + `the ${name} region must sit directly under the "${key}:" mapping in ${path}; ${found}`, ); } // The END marker's own line holds only trailing blanks past the span, so the search skips it. const below = text.slice(end[1]).split("\n").slice(1).find(content); if (below !== undefined && /^\s/.test(below)) { throw new Error( - `the ${spec.name} region must end the "${key}:" mapping in ${path}; "${below.trim()}" follows its END marker`, + `the ${name} region must end the "${key}:" mapping in ${path}; "${below.trim()}" follows its END marker`, ); } } /** - * Throw unless region `spec.name` sits where `spec.placement` says and encloses only a body - * `spec.body` matches; a relocated marker would otherwise regenerate cleanly while the page - * reads wrong, or erase the authored text it came to enclose. `path` picks the marker syntax. + * Throw unless `region` sits where its placement says and encloses only a body its round trip + * admits; a relocated marker would otherwise regenerate cleanly while the page reads wrong, or + * erase the authored text it came to enclose. `path` picks the marker syntax. */ -export function assertRegionPlacement(text: string, spec: RegionSpec, path: string): void { - if (spec.body instanceof RegExp && /[gy]/.test(spec.body.flags)) { - throw new Error( - `the ${spec.name} region's body shape carries the stateful "${spec.body.flags}" flags; test() would alternate between calls`, - ); - } +export function assertRegionPlacement(text: string, region: GeneratedRegion, path: string): void { const syntax = markerSyntaxFor(path); - const { begin, end } = regionBounds(text, spec.name, syntax); - if (spec.placement.kind === "under-key") { + const { begin, end } = regionBounds(text, region.name, syntax); + if (region.placement.kind === "under-key") { if (syntax !== "yaml") { throw new Error( - `the ${spec.name} region declares a YAML parent key, but ${path} uses ${syntax} markers`, + `the ${region.name} region declares a YAML parent key, but ${path} uses ${syntax} markers`, ); } - assertYamlPlacement(text, spec, spec.placement.key, begin, end, path); + assertYamlPlacement(text, region.name, region.placement.key, begin, end, path); } else { if (syntax !== "html") { throw new Error( - `the ${spec.name} region declares a markdown placement, but ${path} uses ${syntax} markers`, + `the ${region.name} region declares a markdown placement, but ${path} uses ${syntax} markers`, ); } - assertMarkdownPlacement(text, spec, spec.placement, begin, end, path); + assertMarkdownPlacement(text, region.name, region.placement, begin, end, path); } - const refusal = bodyRefusal(spec.body, text.slice(begin[1], end[0])); + const refusal = region.bodyRefusal(text.slice(begin[1], end[0])); if (refusal !== undefined) { throw new Error( - `the ${spec.name} region in ${path} encloses content the generator would not write; move its marker back (${refusal})`, + `the ${region.name} region in ${path} encloses content the generator would not write; move its marker back (${refusal}; line N is N lines below the BEGIN marker)`, ); } } -/** A generated region with the renderer that writes its body: the exact text between the markers. */ -export interface GeneratedRegion extends RegionSpec { - readonly render: () => string; -} - /** * `text` with every region in `regions` checked for placement first, then its body re-rendered; - * a rendering its own shape rejects fails here rather than on the next run. `path` picks the - * marker syntax. + * a rendering its own parse does not read back fails here rather than on the next run. `path` + * picks the marker syntax. */ export function regenerateRegions( text: string, @@ -404,10 +478,10 @@ export function regenerateRegions( const syntax = markerSyntaxFor(path); return regions.reduce((current, region) => { const body = region.render(); - const refusal = bodyRefusal(region.body, body); + const refusal = region.bodyRefusal(body); if (refusal !== undefined) { throw new Error( - `the ${region.name} region's renderer wrote a body its own shape rejects: ${JSON.stringify(body)} (${refusal})`, + `the ${region.name} region's renderer wrote a body its own parse does not read back: ${JSON.stringify(body)} (${refusal})`, ); } return replaceRegion(current, region.name, body, syntax); diff --git a/src/report/composer.ts b/src/report/composer.ts index 587a8caf..7abe9237 100644 --- a/src/report/composer.ts +++ b/src/report/composer.ts @@ -4,7 +4,7 @@ */ import type { AnnotationLevel } from "../io.js"; -import { markdownCell } from "./markdown.js"; +import { markdownCell, renderTable } from "./markdown.js"; interface TranscriptLine { level?: AnnotationLevel; @@ -48,14 +48,14 @@ export function composeReport(input: ReportInput): string { "", "Full, unredacted report for this target. The public run redacts it; this document is its private mirror.", "", - "| | |", - "|---|---|", - `| Target | ${markdownCell(input.target)} |`, - `| Admin repository | ${markdownCell(input.adminRepo)} |`, - `| Run | ${markdownCell(input.runUrl)} |`, - `| Mode | ${markdownCell(input.mode)} |`, - `| Result | ${markdownCell(input.result)} |`, - `| Generated | ${markdownCell(input.timestamp)} |`, + renderTable("| | |\n|---|---|", [ + ["Target", markdownCell(input.target)], + ["Admin repository", markdownCell(input.adminRepo)], + ["Run", markdownCell(input.runUrl)], + ["Mode", markdownCell(input.mode)], + ["Result", markdownCell(input.result)], + ["Generated", markdownCell(input.timestamp)], + ]), "", "## Sections", "", @@ -63,11 +63,16 @@ export function composeReport(input: ReportInput): string { if (input.outcomes.length === 0) { lines.push("No sections ran for this target."); } else { - lines.push("| Section | Status | Detail |", "|---|---|---|"); - for (const outcome of input.outcomes) { - const detail = outcome.detail.map(markdownCell).join("
"); - lines.push(`| ${markdownCell(outcome.key)} | ${markdownCell(outcome.status)} | ${detail} |`); - } + lines.push( + renderTable( + "| Section | Status | Detail |\n|---|---|---|", + input.outcomes.map((outcome) => [ + markdownCell(outcome.key), + markdownCell(outcome.status), + outcome.detail.map(markdownCell).join("
"), + ]), + ), + ); } lines.push("", "## Transcript", ""); if (input.transcript.length === 0) { diff --git a/src/report/markdown.ts b/src/report/markdown.ts index 85495bec..2082680d 100644 --- a/src/report/markdown.ts +++ b/src/report/markdown.ts @@ -5,7 +5,7 @@ * One renderer, two kinds of cell: * step summary -> free text the caller escapes with markdownCell(); never refused * generated page -> authored prose refused rather than escaped, since an escape would change the author's text: - * tableFault() before rendering, tableRoundTrip() when the committed page is read back + * tableFault() before rendering, tableBody() when the committed page is read back */ import { err, ok, type Result } from "neverthrow"; @@ -90,7 +90,7 @@ function rowFault( /** * The one statement of what a table under `header` may hold, naming the first faulty row: a generator throws it - * before rendering and tableRoundTrip() refuses by the same rule, so a test can hold both sides to it. + * before rendering and tableBody() refuses by the same rule, so a test can hold both sides to it. */ export function tableFault( header: string, @@ -121,52 +121,20 @@ function rowKey(cells: readonly string[], rule: TableRule): string { return rule === "keyed" ? (KEY_SPAN.exec(first)?.[1] ?? "") : first; } -/** A round-trip guard's verdict: undefined when `body` is `rendered` byte for byte, else the first differing line. */ -export function renderedMismatch(body: string, rendered: string): string | undefined { - if (rendered === body) { - return undefined; - } - const authored = body.split("\n"); - const expected = rendered.split("\n"); - const differing = authored.findIndex((line, i) => line !== expected[i]); - const at = differing === -1 ? authored.length : differing; - return `line ${at} reads ${JSON.stringify(authored[at] ?? "")} where the generator writes ${JSON.stringify(expected[at] ?? "")}`; -} - -/** - * The body guard of a generated region: `parse` reads the body back into the data it renders from, and `render` - * over that data must reproduce the body byte for byte, so an authored spelling the renderer never writes is - * refused rather than erased. The verdict is the refusal, or undefined for a body the generator could have - * written. A freshly placed region holds "\n" and renders next. - */ -export function roundTrip( - parse: (body: string) => Result, - render: (data: Data) => string, -): (body: string) => string | undefined { - return (body) => - body === "\n" - ? undefined - : parse(body).match( - (data) => renderedMismatch(body, `\n${render(data)}\n`), - (refusal) => refusal, - ); -} - /** - * roundTrip() for a region that is one table: the body splits into rows and cells, the rows pass `rule`, - * `parseRow` turns each row's cells into the record it renders from (`key` as rowKey() reads it), and - * `renderRows` re-renders the records. A body not opening with `header` parses to no rows, so the byte - * compare names its first line. + * The parse of a block region that is one table: the body splits into rows and cells, the rows pass `rule`, + * and `parseRow` turns each row's cells into the record it renders from (`key` as rowKey() reads it), each + * refusal naming the row's line below the BEGIN marker. A body not opening with `header` parses to no rows, + * so the region's byte compare names its first line. */ -export function tableRoundTrip( +export function tableBody( header: string, rule: TableRule, parseRow: (cells: readonly string[], key: string) => Result, - renderRows: (rows: readonly Row[]) => string, -): (body: string) => string | undefined { +): (body: string) => Result { const headerLines = header.split("\n").length; const line = (row: number): number => row + headerLines + 1; - return roundTrip((body): Result => { + return (body) => { if (!(body.startsWith(`\n${header}\n`) && body.endsWith("\n"))) { return ok([]); } @@ -183,5 +151,5 @@ export function tableRoundTrip( } return ok(parsed); }); - }, renderRows); + }; } diff --git a/test/scripts/gen-action-docs.test.ts b/test/scripts/gen-action-docs.test.ts index 507b9275..c5146916 100644 --- a/test/scripts/gen-action-docs.test.ts +++ b/test/scripts/gen-action-docs.test.ts @@ -20,10 +20,8 @@ import { renderPolicyDefaultsTable, } from "../../.github/scripts/gen-action-docs.js"; import { - type BodyShape, - bodyRefusal, + type GeneratedRegion, markerSyntaxFor, - type RegionSpec, regionBounds, } from "../../.github/scripts/lib/generated-regions.js"; import { OUTPUT_DECLS } from "../../src/action/io.js"; @@ -281,7 +279,7 @@ describe("permissions renderers", () => { describe("generated files", () => { test.each( Object.entries(GENERATED_REGIONS).flatMap(([path, regions]) => - regions.map((region): [name: string, path: string, region: RegionSpec] => [ + regions.map((region): [name: string, path: string, region: GeneratedRegion] => [ region.name, path, region, @@ -305,16 +303,22 @@ describe("generated files", () => { ).toThrow(`the ${name} region ${home}`); }); - const shapes = new Map( + const regions = new Map( Object.values(GENERATED_REGIONS) .flat() - .map((region) => [region.name, region.body]), + .map((region) => [region.name, region]), ); - const shapeOf = (name: string): BodyShape => shapes.get(name) ?? (() => `no ${name} region`); + const regionOf = (name: string): GeneratedRegion => { + const region = regions.get(name); + if (region === undefined) { + throw new Error(`no ${name} region is registered`); + } + return region; + }; - test("each region's body shape accepts its renderer's output on edge-case declarations", () => { + test("each region admits its renderer's output on edge-case declarations", () => { const accepts = (name: string, rendered: string): void => { - expect(bodyRefusal(shapeOf(name), `\n${rendered}\n`), name).toBeUndefined(); + expect(regionOf(name).bodyRefusal(`\n${rendered}\n`), name).toBeUndefined(); }; accepts( "action-inputs", @@ -376,9 +380,8 @@ describe("generated files", () => { } }); - test("each region's body shape rejects authored text and every other region's body", () => { - // A shape loosened to accept anything would still pass the renderer test above; a sibling sharing the shape is the same table in another home, - // which the shape accepts by design. + 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 })), ); @@ -393,11 +396,11 @@ describe("generated files", () => { const foreign = [ "\nAuthored prose the generator never writes.\n", "\n## A heading\n", - ...[...bodies].filter(([name]) => shapes.get(name) !== region.body).map(([, body]) => body), + ...[...bodies].filter(([name]) => name !== region.name).map(([, body]) => body), ]; for (const body of foreign) { expect( - bodyRefusal(region.body, body), + region.bodyRefusal(body), `${region.name} accepts ${JSON.stringify(body.slice(0, 40))}`, ).toBeDefined(); } @@ -478,7 +481,7 @@ describe("generated files", () => { ], ["a body without its closing newline", policyTable(policyRow).slice(0, -1), /line 3 reads/], ])("the Defaults table guard refuses %s, which does not round-trip", (_label, body, refusal) => { - expect(bodyRefusal(shapeOf("policy-defaults-table"), body)).toMatch(refusal); + expect(regionOf("policy-defaults-table").bodyRefusal(body)).toMatch(refusal); }); // The equivalence the round trip promises, held to the one statement both sides consult: the renderer throws @@ -486,7 +489,7 @@ describe("generated files", () => { // guard refuses the same cells joined without the renderer whenever the statement faults. A check on one side // only turns a generated input red. test("the Defaults renderer and guard agree with the shared statement on every generated input", () => { - const shape = shapeOf("policy-defaults-table"); + const region = regionOf("policy-defaults-table"); const outcomes = new Map(); const hold = ( input: string, @@ -511,11 +514,11 @@ describe("generated files", () => { } expect(thrown, input).toBe(fault); if (rendered !== undefined) { - expect(bodyRefusal(shape, `\n${rendered}\n`), input).toBeUndefined(); + expect(region.bodyRefusal(`\n${rendered}\n`), input).toBeUndefined(); outcomes.set(input, "written"); } else { const joined = [DEFAULTS_TABLE_HEADER, ...rows.map(policyCells).map(tableRow)].join("\n"); - expect(bodyRefusal(shape, `\n${joined}\n`), input).toBeDefined(); + expect(region.bodyRefusal(`\n${joined}\n`), input).toBeDefined(); outcomes.set(input, "faulted"); } }; @@ -561,14 +564,53 @@ describe("generated files", () => { expect(outcomes.size - written).toBeGreaterThan(0); }); - test("a region that renders a table carries a round trip, never a hand-written grammar", () => { - // A table region's guard and its renderer share one grammar only through the round trip, so a RegExp body on a - // table region is a second grammar. - for (const region of Object.values(GENERATED_REGIONS).flat()) { - if (region.render().startsWith("\n|")) { - expect(region.body, region.name).not.toBeInstanceOf(RegExp); - } - } + // A prose list the renderer could never have written comes back as a refusal naming the line, never as the + // renderer's own throw escaping the round trip. + test.each<[label: string, name: string, body: string, refusal: RegExp]>([ + [ + 'a comma-joined pair with no "and"', + "permissions-grant-sentence", + "\nTo manage everything in one PAT, grant Issues, Pages at write.\n", + /^line 1 lists "Issues, Pages", which holds a comma or the word "and"$/, + ], + [ + "a doubled joiner", + "permissions-gated-reads", + "\n- GitHub gates even the A and and and B reads at write, so `x` needs its write grant in check mode too.\n", + /^line 1 lists "and", which holds a comma or the word "and"$/, + ], + [ + 'a route span holding the word "and"', + "check-mode-gated-reads", + [ + "", + "The read-only rule has exceptions, each a section to drop from the preview or grant at write:", + "", + "- GitHub gates the `GET /repos/{owner}/{repo}/labels and more` reads at write, so `labels` needs its Issues write grant in check mode to verify what they return.", + "", + ].join("\n"), + /^line 3 lists "`GET \/repos\/\{owner\}\/\{repo\}\/labels and more`", which holds a comma or the word "and"$/, + ], + ])("a malformed prose list, %s, is refused naming its line", (_label, name, body, refusal) => { + expect(regionOf(name).bodyRefusal(body)).toMatch(refusal); + }); + + test("the renderer refuses a list item a reader could not tell from a joiner", () => { + // A key's span is an item of the count sentence's prose list, so one holding "and" would read back as two. + expect(() => + renderPolicyCountSentence([{ key: "labels and more", undeclaredDefault: "delete" }]), + ).toThrow( + '"`labels and more`" holds a comma or the word "and", which an item of a prose list cannot', + ); + }); + + test("a count sentence past the word table is refused naming its line, never handed to the renderer's throw", () => { + const spans = Array.from({ length: 21 }, () => "`labels`"); + const listed = `${spans.slice(0, -1).join(", ")}, and ${spans.at(-1)}`; + const body = `\nTwenty-one sections list the live resources sitting next to the declared ones: ${listed}.\n`; + expect(regionOf("policy-count-sentence").bodyRefusal(body)).toBe( + "line 1 lists 21 section keys, past the 20 the count words name", + ); }); // Each row is YAML the emitter never writes; admitted, it would be replaced on the next regeneration. The tab inside @@ -601,8 +643,8 @@ describe("generated files", () => { ["text that is not YAML", "\n x: [\n", /does not parse as YAML/], ["an alias without its anchor", "\n x: *missing\n", /does not convert from YAML/], ])("the guard refuses %s, which does not round-trip", (label, body, refusal) => { - const shape = shapeOf(label.includes("outputs") ? "action-outputs" : "action-inputs"); - expect(bodyRefusal(shape, body)).toMatch(refusal); + const region = regionOf(label.includes("outputs") ? "action-outputs" : "action-inputs"); + expect(region.bodyRefusal(body)).toMatch(refusal); }); test.each<[label: string, name: string, decl: { description: string; default: string }]>([ @@ -618,7 +660,7 @@ describe("generated files", () => { ], ])("legitimate emitter output with %s round-trips through the guard", (_label, name, decl) => { const text = renderActionInputs(Object.fromEntries([[name, decl]])); - expect(bodyRefusal(shapeOf("action-inputs"), `\n${text}\n`)).toBeUndefined(); + expect(regionOf("action-inputs").bodyRefusal(`\n${text}\n`)).toBeUndefined(); expect(Object.entries(parseYaml(`inputs:\n${text}\n`).inputs)).toEqual([ [name, { ...decl, required: false }], ]); diff --git a/test/scripts/gen-docs.test.ts b/test/scripts/gen-docs.test.ts index e3665f89..1a03825f 100644 --- a/test/scripts/gen-docs.test.ts +++ b/test/scripts/gen-docs.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { renderArchitectureMermaid } from "../../.github/scripts/arch-lint.js"; import type { CoverageData } from "../../.github/scripts/coverage-data.js"; import type { EndpointAnchors } from "../../.github/scripts/endpoint-docs.js"; import { @@ -18,11 +19,23 @@ import { type SectionsTableRow, sectionsCells, } from "../../.github/scripts/gen-docs.js"; -import { bodyRefusal } from "../../.github/scripts/lib/generated-regions.js"; +import type { GeneratedRegion } from "../../.github/scripts/lib/generated-regions.js"; import { tableFault, tableRow } from "../../src/report/markdown.js"; import type { SectionDocs } from "../../src/sections/contract/docs.js"; import { ROOT } from "../root.js"; +/** The region `name` among `regions`; a page that lost it fails here, not on an undefined read. */ +function regionNamed( + regions: readonly GeneratedRegion[] | undefined, + name: string, +): GeneratedRegion { + const region = regions?.find((region) => region.name === name); + if (region === undefined) { + throw new Error(`no ${name} region is registered`); + } + return region; +} + describe("renderSectionsTable", () => { test("renders one row per section, derived cells around the authored ones", () => { const table = renderSectionsTable( @@ -81,9 +94,7 @@ describe("renderSectionsTable", () => { }); describe("the Sections table guard", () => { - const shape = - PAGE_REGIONS["docs/reference/sections.md"]?.find((region) => region.name === "sections-table") - ?.body ?? (() => "no sections-table region"); + const region = regionNamed(PAGE_REGIONS["docs/reference/sections.md"], "sections-table"); const sectionsRow = "| `labels` | labels CRUD | Issues: write | deleted (settable) | upsert by name |"; const sectionsTable = (rows: string): string => @@ -102,9 +113,9 @@ describe("the Sections table guard", () => { }, ); expect(rendered).toContain("| labels (CRUD): `x` |"); - expect(bodyRefusal(shape, `\n${rendered}\n`)).toBeUndefined(); - expect(bodyRefusal(shape, sectionsTable("").slice(0, -1))).toBeUndefined(); - expect(bodyRefusal(shape, "\n")).toBeUndefined(); + expect(region.bodyRefusal(`\n${rendered}\n`)).toBeUndefined(); + expect(region.bodyRefusal(sectionsTable("").slice(0, -1))).toBeUndefined(); + expect(region.bodyRefusal("\n")).toBeUndefined(); }); // Each body is table text the renderer never writes; admitted, it would be erased on the next regeneration. @@ -156,7 +167,7 @@ describe("the Sections table guard", () => { ], ["a blank line after the last row", sectionsTable(`${sectionsRow}\n`), /line 4 has 0 cells/], ])("refuses %s, which does not round-trip", (_label, body, refusal) => { - expect(bodyRefusal(shape, body)).toMatch(refusal); + expect(region.bodyRefusal(body)).toMatch(refusal); }); // The equivalence the round trip promises, held to the one statement both sides consult: the renderer throws @@ -185,11 +196,11 @@ describe("the Sections table guard", () => { } expect(thrown, input).toBe(fault); if (rendered !== undefined) { - expect(bodyRefusal(shape, `\n${rendered}\n`), input).toBeUndefined(); + expect(region.bodyRefusal(`\n${rendered}\n`), input).toBeUndefined(); outcomes.set(input, "written"); } else { const joined = [SECTIONS_TABLE_HEADER, ...cells.map(tableRow)].join("\n"); - expect(bodyRefusal(shape, `\n${joined}\n`), input).toBeDefined(); + expect(region.bodyRefusal(`\n${joined}\n`), input).toBeDefined(); outcomes.set(input, "faulted"); } }; @@ -215,16 +226,6 @@ describe("the Sections table guard", () => { expect(written).toBeGreaterThan(0); expect(outcomes.size - written).toBeGreaterThan(0); }); - - test("a region that renders a table carries a round trip, never a hand-written grammar", () => { - // A table region's guard and its renderer share one grammar only through the round trip, so a RegExp body on a - // table region is a second grammar. - for (const region of Object.values(PAGE_REGIONS).flat()) { - if (region.render().startsWith("\n|")) { - expect(region.body, region.name).not.toBeInstanceOf(RegExp); - } - } - }); }); describe("renderCoverage", () => { @@ -358,7 +359,7 @@ describe("renderCoverage", () => { // Prose may repeat a structural line word for word, and keys may hold the two Unicode line separators the cell // rule admits; the guard reads the structure from the lines around a line, never from one line alone, or a page // the generator wrote would be refused as authored. - test("a page whose prose repeats the Supported heading or the gaps header line, or whose keys hold a line separator, still reads back as the generator's", () => { + test("prose repeating a structural line, or keys holding a line separator, still read back as the generator's", () => { const rendered = render({ docs: { ...docs, @@ -731,6 +732,22 @@ describe("the committed pages", () => { }, "the outputs-list region in docs/reference/inputs.md encloses content the generator would not write", ], + [ + "the architecture map markers around an authored line inside the fence", + "docs/reference/architecture.md", + (page) => page.replace("```mermaid\ngraph TD\n", "```mermaid\ngraph TD\nAuthored note.\n"), + "the architecture-map region in docs/reference/architecture.md encloses content the generator would not write; move its marker back (line 3 is neither a layer node nor an import edge", + ], + [ + "the architecture map markers around an authored line after the diagram", + "docs/reference/architecture.md", + (page) => + page.replace( + "```\n", + "```\nAuthored note.\n", + ), + "the architecture-map region in docs/reference/architecture.md encloses content the generator would not write", + ], ])("refuses to regenerate %s in %s", (_label, path, mutate, error) => { // Each page reads wrong while its markers still pair up: the enclosed text is not the generator's, or a token-form reference // has no tail definition. A region moved away from its home is refused by the placement checks generated-regions.test.ts pins. @@ -739,6 +756,20 @@ describe("the committed pages", () => { }); }); +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 diagram = renderArchitectureMermaid({ + layers: { "plain.data": ["src/plain-data.ts"], v2: ["src/v2/", "src/v2.ts"] }, + edges: { "plain.data": ["v2"] }, + exclude: [], + }); + expect(region.bodyRefusal(`\n\`\`\`mermaid\n${diagram}\n\`\`\`\n`)).toBeUndefined(); + }); +}); + describe("the committed coverage page", () => { const coverage = readFileSync(join(ROOT, "docs/reference/coverage.md"), "utf8"); diff --git a/test/scripts/generated-regions.test.ts b/test/scripts/generated-regions.test.ts index 2320b23c..1b6a2100 100644 --- a/test/scripts/generated-regions.test.ts +++ b/test/scripts/generated-regions.test.ts @@ -1,13 +1,19 @@ import { describe, expect, test } from "bun:test"; +import { err, ok } from "neverthrow"; import { assertRegionPlacement, + block, + blockLine, + blockLines, + GeneratedRegion, type MarkerSyntax, markerSyntaxFor, - type RegionSpec, + type RegionPlacement, regenerateRegions, regionBounds, replaceRegion, } from "../../.github/scripts/lib/generated-regions.js"; +import { renderTable, tableBody } from "../../src/report/markdown.js"; import { relocatedRegion } from "./relocated-region.js"; const HTML_BLOCK = [ @@ -269,22 +275,64 @@ const PAGE = [ "", ].join("\n"); -const TABLE_BODY = /^\n(?:\| A \| B \|\n\|---\|---\|\n(?:\| `[a-z]+` \| \d+ \|\n)*)?$/; -const TABLE: RegionSpec = { - name: "table", - placement: { kind: "under-heading", heading: "## Inputs" }, - body: TABLE_BODY, -}; -const LIST: RegionSpec = { - name: "list", - placement: { kind: "under-heading", heading: "## Inputs" }, - body: /^(?:`[a-z]`(?: \/ `[a-z]`)*)?$/, -}; -const LINK: RegionSpec = { - name: "link", - placement: { kind: "tail" }, - body: /^\n(?:\[form\]: \S+\n)?$/, -}; +const TABLE_HEADER = "| A | B |\n|---|---|"; +const UNDER_INPUTS: RegionPlacement = { kind: "under-heading", heading: "## Inputs" }; + +/** The block fixture: key and number rows under "| A | B |", rendered from `rows`. */ +function tableRegion( + placement: RegionPlacement = UNDER_INPUTS, + rows: ReadonlyArray = [], +): GeneratedRegion { + return GeneratedRegion.of>({ + name: "table", + placement, + data: () => rows, + render: block((rows) => + renderTable( + TABLE_HEADER, + rows.map(([key, n]) => [`\`${key}\``, String(n)]), + ), + ), + parse: tableBody(TABLE_HEADER, "keyed", (cells, key) => + /^\d+$/.test(cells[1] ?? "") + ? ok([key, Number(cells[1])] as const) + : err("has no number in its second cell"), + ), + }); +} + +/** The inline fixture: single letters in code spans joined by " / ", on the marker's own line. */ +function listRegion(letters: readonly string[] = []): GeneratedRegion { + return GeneratedRegion.of({ + name: "list", + placement: UNDER_INPUTS, + data: () => letters, + render: (letters) => letters.map((letter) => `\`${letter}\``).join(" / "), + parse: (body) => + /^`[a-z]`(?: \/ `[a-z]`)*$/.test(body) + ? ok(body.split(" / ").map((span) => span.slice(1, -1))) + : err("line 0 is not a list of letters in code spans"), + }); +} + +/** The tail fixture: one `[form]: ` definition closing the file. */ +function linkRegion(url = "https://example.com"): GeneratedRegion { + return GeneratedRegion.of({ + name: "link", + placement: { kind: "tail" }, + data: () => url, + render: block((url) => `[form]: ${url}`), + parse: (body) => + blockLine(body).andThen((line) => { + const url = /^\[form\]: (\S+)$/.exec(line)?.[1]; + return url === undefined ? err("line 1 is not the [form] definition") : ok(url); + }), + }); +} + +const TABLE = tableRegion(); +const LIST = listRegion(); +const LINK = linkRegion(); // An action manifest: two mapping regions, the END markers at column zero as action.yml writes them. const MANIFEST = [ @@ -310,7 +358,44 @@ const MANIFEST = [ "", ].join("\n"); -const ENTRIES = /^\n(?: {2}[a-z]+:\n(?: {4,}[^\n]*\n)+)*$/; +/** One entry of the manifest fixture's mappings: its key line and the deeper-indented lines under it. */ +interface Entry { + readonly key: string; + readonly lines: readonly string[]; +} + +/** The YAML fixture: two-space-indented entries, each followed by its deeper lines. */ +function yamlRegion( + name: string, + placement: RegionPlacement, + entries: readonly Entry[] = [], +): GeneratedRegion { + return GeneratedRegion.of({ + name, + placement, + data: () => entries, + render: block((entries) => + entries.flatMap((entry) => [` ${entry.key}:`, ...entry.lines]).join("\n"), + ), + parse: (body) => + blockLines(body).andThen((lines) => { + const parsed: Array<{ key: string; lines: string[] }> = []; + for (const [index, line] of lines.entries()) { + const key = /^ {2}([a-z]+):$/.exec(line)?.[1]; + const last = parsed.at(-1); + if (key !== undefined) { + parsed.push({ key, lines: [] }); + } else if (last !== undefined && /^ {4,}/.test(line)) { + last.lines.push(line); + } else { + return err(`line ${index + 1} is neither an entry key nor an indented line of one`); + } + } + return ok(parsed); + }), + }); +} + /** The file each fixture region is checked in, which picks its marker syntax. */ const PATH_OF: Readonly> = { table: "doc.md", @@ -319,16 +404,8 @@ const PATH_OF: Readonly> = { ins: "action.yml", outs: "action.yml", }; -const INS: RegionSpec = { - name: "ins", - placement: { kind: "under-key", key: "inputs" }, - body: ENTRIES, -}; -const OUTS: RegionSpec = { - name: "outs", - placement: { kind: "under-key", key: "outputs" }, - body: ENTRIES, -}; +const INS = yamlRegion("ins", { kind: "under-key", key: "inputs" }); +const OUTS = yamlRegion("outs", { kind: "under-key", key: "outputs" }); describe("assertRegionPlacement", () => { test("accepts each region where its spec puts it, with an authored comment or a fenced heading-shaped line in between", () => { @@ -374,7 +451,7 @@ describe("assertRegionPlacement", () => { expect(() => assertRegionPlacement(commented, INS, "action.yml")).not.toThrow(); }); - test.each<[label: string, text: string, spec: RegionSpec, error: string]>([ + test.each<[label: string, text: string, region: GeneratedRegion, error: string]>([ [ "a table moved under another heading", relocatedRegion(PAGE, "table", "html", "## Notes\n\n"), @@ -474,7 +551,7 @@ describe("assertRegionPlacement", () => { [ "a mapping region with only comments above it", "# authored\n# BEGIN GENERATED: outs\nresult: 1\n# END GENERATED: outs\n", - { ...OUTS, body: /[\s\S]*/ }, + OUTS, 'the outs region must sit directly under the "outputs:" mapping in action.yml; nothing precedes its BEGIN marker', ], [ @@ -498,23 +575,29 @@ describe("assertRegionPlacement", () => { [ "a heading placement on a YAML file", MANIFEST, - { ...INS, placement: { kind: "under-heading", heading: "## Inputs" } }, + yamlRegion("ins", UNDER_INPUTS), "the ins region declares a markdown placement, but action.yml uses yaml markers", ], [ "a mapping placement on a markdown file", PAGE, - { ...TABLE, placement: { kind: "under-key", key: "inputs" } }, + tableRegion({ kind: "under-key", key: "inputs" }), "the table region declares a YAML parent key, but doc.md uses html markers", ], - ...(["g", "y"] as const).map((flag): [string, string, RegionSpec, string] => [ - `a body shape with the stateful "${flag}" flag, which would pass and fail on alternate calls`, - PAGE, - { ...TABLE, body: new RegExp(TABLE_BODY.source, flag) }, - `the table region's body shape carries the stateful "${flag}" flags`, - ]), - ])("refuses %s", (_label, text, spec, error) => { - expect(() => assertRegionPlacement(text, spec, PATH_OF[spec.name] ?? "")).toThrow(error); + ])("refuses %s", (_label, text, region, error) => { + expect(() => assertRegionPlacement(text, region, PATH_OF[region.name] ?? "")).toThrow(error); + }); + + test("a refusal's line number counts down from the BEGIN marker's line, as its message says", () => { + // The message states the numbering and each parse computes it; a reader sent to the wrong line is what drifts + // if the two disagree. + const lines = PAGE.split("\n"); + const begin = lines.findIndex((line) => line.startsWith("