Skip to content

Commit 0ca98e2

Browse files
committed
build(scripts): guard the generated markdown tables with a round trip instead of a regex
The sections-table and policy-defaults-table regions admitted authored text their renderers never write, so a hand edit inside the markers was erased on the next regeneration instead of refused. The sections-table shape used `[^\n]*` cells and admitted a pipe, a carriage return, a sixth cell, a blank cell, a repeated section with other prose, and an Undeclared default outside the three displays. The policy-defaults-table shape admitted an override naming the same policy as the default, a keep row above a delete row, and a repeated section. Both shapes are now one shared round trip, `tableRoundTrip` in lib/markdown-table.ts: the body splits into rows and cells by the inverse of `tableRow()`, the rows pass the table rule (the header's column count, the cell rule `tableCell()` writes by, a key span opening every row, no key repeated), a per-table parser turns the cells into the record the table renders from, and the records re-rendered must reproduce the body byte for byte. The refusal names the first differing line through `renderedMismatch`, now shared with the action.yml round trip. The Defaults table re-renders through its real renderer, so the delete-first order comes from the renderer, and its parser checks the opposite-policy binding through the same `OPPOSITE` table the renderer writes from. The Sections table re-renders through the table helper with the prose cells opaque, since its PAT cell paraphrases prose no parser inverts. The helper is the grammar on both sides. `tableFault()` is the one statement of the table rule: `renderTable()` throws its text and `tableRoundTrip()` refuses by the same rule, so a renderer cannot write a body its own guard would refuse, which would otherwise turn the next regeneration of the committed page into a throw. The Defaults statement `policyTableFault()` adds each row's prose by `proseFault`, the trimmed-blank rule `tableCell()` uses, which the parser consults too, since blank prose renders `delete ()` or a bare policy span that the cell rule admits. A compile-time proof of non-blank prose was tried and dropped: a structural type cannot keep a hidden property out of a parameter, so only the run-time check closes the boundary. Two property-style tests hold both sides to the statement over generated inputs (keys inside and outside the key span, both policies, prose with blanks, padding, pipes, carriage returns, and a Unicode separator, duplicate and reordered sections): the renderer throws exactly the statement's fault, the guard admits what the renderer wrote, and the guard refuses the same cells joined without the renderer whenever the statement faults, so a check added to one side only turns a generated input red. A rule test in each registry's suite refuses a RegExp body on any region whose rendering is a table. Refusal tables for both regions pin each input above: nine were admitted by the regexes on main, the rest were refused with the generic message and now name the cell or line.
1 parent 5a704a3 commit 0ca98e2

6 files changed

Lines changed: 619 additions & 69 deletions

File tree

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

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,14 @@ import {
3030
} from "../../src/sections/contract/permissions.js";
3131
import { SECTIONS } from "../../src/sections/registry.js";
3232
import { agree } from "../../src/text.js";
33+
import type { UndeclaredPolicy } from "../../src/types.js";
3334
import { countWord } from "./lib/count-word.js";
34-
import { type GeneratedRegion, regenerateRegions } from "./lib/generated-regions.js";
35-
import { tableCell } from "./lib/markdown-table.js";
35+
import {
36+
type GeneratedRegion,
37+
regenerateRegions,
38+
renderedMismatch,
39+
} from "./lib/generated-regions.js";
40+
import { renderTable, tableFault, tableRoundTrip } from "./lib/markdown-table.js";
3641

3742
const ROOT = join(import.meta.dir, "..", "..");
3843

@@ -174,30 +179,102 @@ export interface PolicyRowProse {
174179
readonly override: string;
175180
}
176181

177-
const DEFAULTS_TABLE_HEADER = "| Section | Default | The override buys you |\n|---|---|---|";
182+
/**
183+
* Why a row's prose cannot be rendered: blank prose would render `delete ()` or a bare policy span, cells
184+
* tableCell() admits since the policy word fills them. The renderer refuses it at its own boundary and the parser
185+
* through this same check, by the trimmed-blank rule tableCell() uses, so what the renderer writes is exactly what
186+
* the guard reads back. A structural type cannot vouch for a property a narrowing hid, so the value is inspected
187+
* as unknown.
188+
*/
189+
function proseFault(row: PolicyRowProse): string | undefined {
190+
const blank = (value: unknown): boolean => typeof value !== "string" || value.trim() === "";
191+
return blank(row.override) || (row.caveat !== undefined && blank(row.caveat))
192+
? "has a blank caveat or override"
193+
: undefined;
194+
}
195+
196+
const OPPOSITE: Readonly<Record<UndeclaredPolicy, UndeclaredPolicy>> = {
197+
delete: "keep",
198+
keep: "delete",
199+
};
200+
201+
export const DEFAULTS_TABLE_HEADER = "| Section | Default | The override buys you |\n|---|---|---|";
202+
203+
/** One Defaults row: the section and the prose its cells carry. */
204+
export interface PolicyRow {
205+
readonly section: KnobbedSection;
206+
readonly prose: PolicyRowProse;
207+
}
208+
209+
/** A row's three cells, as the renderer writes them and the guard reads them back. */
210+
export function policyCells(row: PolicyRow): readonly string[] {
211+
const caveat = row.prose.caveat === undefined ? "" : ` (${row.prose.caveat})`;
212+
return [
213+
`\`${row.section.key}\``,
214+
`${row.section.undeclaredDefault}${caveat}`,
215+
`\`${OPPOSITE[row.section.undeclaredDefault]}\`: ${row.prose.override}`,
216+
];
217+
}
218+
219+
/**
220+
* The one statement of what a Defaults table may hold: each row's prose by proseFault(), then the table rule over
221+
* the cells. The renderer consults it whole before writing; the guard consults the same two rules as it parses,
222+
* so a test can hold both sides to it.
223+
*/
224+
export function policyTableFault(rows: readonly PolicyRow[]): string | undefined {
225+
for (const row of rows) {
226+
const fault = proseFault(row.prose);
227+
if (fault !== undefined) {
228+
return `the "${row.section.key}" Defaults row ${fault}`;
229+
}
230+
}
231+
return tableFault(DEFAULTS_TABLE_HEADER, rows.map(policyCells));
232+
}
178233

179234
export function renderPolicyDefaultsTable(
180235
sections: readonly KnobbedSection[],
181236
prose: Readonly<Record<string, PolicyRowProse>>,
182237
): string {
183-
const rows = deleteFirst(sections).map((section) => {
238+
const rows = deleteFirst(sections).map((section): PolicyRow => {
184239
const text = prose[section.key];
185240
if (text === undefined) {
186241
throw new Error(`no Defaults-per-section prose for the "${section.key}" section`);
187242
}
188-
const caveat = text.caveat === undefined ? "" : ` (${text.caveat})`;
189-
const opposite = section.undeclaredDefault === "delete" ? "keep" : "delete";
190-
const cells = [
191-
`\`${section.key}\``,
192-
tableCell(`${section.undeclaredDefault}${caveat}`, `the ${section.key} Default cell`),
193-
tableCell(
194-
`\`${opposite}\`: ${text.override}`,
195-
`the ${section.key} "The override buys you" cell`,
196-
),
197-
];
198-
return `| ${cells.join(" | ")} |`;
243+
// Read once, so the fault check and the cells see the same values.
244+
return { section, prose: { caveat: text.caveat, override: text.override } };
199245
});
200-
return [DEFAULTS_TABLE_HEADER, ...rows].join("\n");
246+
const fault = policyTableFault(rows);
247+
if (fault !== undefined) {
248+
throw new Error(fault);
249+
}
250+
return renderTable(DEFAULTS_TABLE_HEADER, rows.map(policyCells));
251+
}
252+
253+
/** A Defaults row read back to the section and prose it renders from; the delete-first order is then the
254+
* renderer's to check, through the byte compare. The dotAll flag keeps the two Unicode line separators
255+
* tableCell() admits inside the prose. */
256+
function policyRow(
257+
cells: readonly string[],
258+
key: string,
259+
): { readonly section: KnobbedSection; readonly prose: PolicyRowProse } | string {
260+
const policy = /^(delete|keep)(?: \((.*)\))?$/s.exec(cells[1] ?? "");
261+
const buys = /^`(delete|keep)`: (.*)$/s.exec(cells[2] ?? "");
262+
if (policy === null) {
263+
return "states no delete or keep default";
264+
}
265+
if (buys === null) {
266+
return "names no policy span before its override prose";
267+
}
268+
const undeclaredDefault = policy[1] === "delete" ? "delete" : "keep";
269+
if (buys[1] !== OPPOSITE[undeclaredDefault]) {
270+
return `names \`${buys[1]}\` where the override is the opposite policy, \`${OPPOSITE[undeclaredDefault]}\``;
271+
}
272+
const row = { caveat: policy[2], override: buys[2] ?? "" };
273+
const fault = proseFault(row);
274+
if (fault !== undefined) {
275+
return `${fault}, which the renderer refuses`;
276+
}
277+
return { section: { key, undeclaredDefault }, prose: row };
201278
}
202279

203280
/** The row every secret family shares: the value is write-only, so a wrong delete is a loss, not a drift. */
@@ -347,10 +424,6 @@ function blockShape(lines: string): RegExp {
347424
return new RegExp(String.raw`^\n(?:${lines}|\n)?$`);
348425
}
349426

350-
function tableShape(header: string, cells: string): RegExp {
351-
return blockShape(String.raw`${RegExp.escape(header)}\n(?:\| ${cells} \|\n)*`);
352-
}
353-
354427
/** The emitter is its own grammar: a body is admitted when re-rendering the declarations it parses to reproduces it
355428
* byte for byte, so no spelling the emitter does not write gets through. A freshly placed region holds "\n" and
356429
* renders next. */
@@ -386,15 +459,7 @@ function roundTrip<T extends { readonly description: string }>(
386459
if (typeof declared === "string") {
387460
return `it is not a set of ${key} declarations (${declared})`;
388461
}
389-
const rendered = `\n${render(declared)}\n`;
390-
if (rendered === body) {
391-
return undefined;
392-
}
393-
const authored = body.split("\n");
394-
const expected = rendered.split("\n");
395-
const differing = authored.findIndex((line, i) => line !== expected[i]);
396-
const at = differing === -1 ? authored.length : differing;
397-
return `line ${at} reads ${JSON.stringify(authored[at] ?? "")} where the generator writes ${JSON.stringify(expected[at] ?? "")}`;
462+
return renderedMismatch(body, `\n${render(declared)}\n`);
398463
};
399464
}
400465

@@ -434,10 +499,11 @@ export const GENERATED_REGIONS: Readonly<Record<string, readonly GeneratedRegion
434499
{
435500
name: "policy-defaults-table",
436501
placement: { kind: "under-heading", heading: "## Defaults per section" },
437-
// A cell holds no pipe and no line break: tableCell() refuses both, so a row carrying one is authored, not stale output.
438-
body: tableShape(
439-
DEFAULTS_TABLE_HEADER,
440-
String.raw`\x60[a-z_]+\x60 \| (?:delete|keep)(?: \([^\r\n|]+\))? \| \x60(?:delete|keep)\x60: [^\r\n|]+`,
502+
body: tableRoundTrip(DEFAULTS_TABLE_HEADER, policyRow, (rows) =>
503+
renderPolicyDefaultsTable(
504+
rows.map((row) => row.section),
505+
Object.fromEntries(rows.map((row) => [row.section.key, row.prose])),
506+
),
441507
),
442508
render: block(() => renderPolicyDefaultsTable(knobbedSections(), POLICY_ROW_PROSE)),
443509
},

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

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { readArchitecture, renderArchitectureMermaid } from "./arch-lint.js";
1616
import { COVERAGE_DATA, type CoverageData } from "./coverage-data.js";
1717
import { ENDPOINT_ANCHORS, type EndpointAnchors } from "./endpoint-docs.js";
1818
import { type GeneratedRegion, regenerateRegions, regionBounds } from "./lib/generated-regions.js";
19-
import { tableCell } from "./lib/markdown-table.js";
19+
import { renderTable, tableCell, tableRoundTrip, tableRow } from "./lib/markdown-table.js";
2020

2121
const ROOT = join(import.meta.dir, "..", "..");
2222
export const COVERAGE_PATH = "docs/reference/coverage.md";
@@ -86,27 +86,37 @@ export function renderPatCell(grant: string): string {
8686
return `${cell}; ${rendered}`;
8787
}
8888

89-
const TABLE_HEADER =
89+
export const SECTIONS_TABLE_HEADER =
9090
"| Section | Endpoints | PAT permission | Undeclared default | Notes |\n|---|---|---|---|---|";
9191

92-
export function renderSectionsTable(
92+
/**
93+
* The cells of each section's row, as the renderer writes them and the guard reads them back; the table rule
94+
* judges them.
95+
*/
96+
export function sectionsCells(
9397
sections: readonly SectionsTableRow[],
9498
docs: Readonly<Record<string, Pick<SectionDocs, "sections_table">>>,
95-
): string {
96-
const rows = sections.map((section) => {
99+
): ReadonlyArray<readonly string[]> {
100+
return sections.map((section) => {
97101
const doc = docs[section.key];
98102
if (doc === undefined) {
99103
throw new Error(`gen-docs: section "${section.key}" has no docs entry`);
100104
}
101105
return [
102106
`\`${section.key}\``,
103-
tableCell(doc.sections_table.endpoints, `the ${section.key} Endpoints cell`),
104-
tableCell(renderPatCell(sectionGrant(section)), `the ${section.key} PAT permission cell`),
107+
doc.sections_table.endpoints,
108+
renderPatCell(sectionGrant(section)),
105109
UNDECLARED_DEFAULT_DISPLAY[section.undeclaredDefault],
106-
tableCell(doc.sections_table.notes, `the ${section.key} Notes cell`),
110+
doc.sections_table.notes,
107111
];
108112
});
109-
return [TABLE_HEADER, ...rows.map((cells) => `| ${cells.join(" | ")} |`)].join("\n");
113+
}
114+
115+
export function renderSectionsTable(
116+
sections: readonly SectionsTableRow[],
117+
docs: Readonly<Record<string, Pick<SectionDocs, "sections_table">>>,
118+
): string {
119+
return renderTable(SECTIONS_TABLE_HEADER, sectionsCells(sections, docs));
110120
}
111121

112122
/** Text for inside a markdown code span: a cell, and a backtick would close the span early. */
@@ -244,7 +254,7 @@ function renderSection(
244254
`[\`${section.key}\`](${SECTIONS_PAGE})${keys}`,
245255
tableCell(renderCalls(section, row, claimed, carrier, anchors), `${where}'s Endpoints cell`),
246256
];
247-
return `| ${cells.join(" | ")} |`;
257+
return tableRow(cells);
248258
});
249259
const declared = [...Object.keys(section.endpoints), ...Object.keys(section.graphql ?? {})];
250260
const unclaimed = declared.filter((role) => !claimed.has(role));
@@ -310,7 +320,7 @@ export function renderCoverage(
310320
tableCell(row.endpoints.join(", "), `${where}'s Endpoints cell`),
311321
tableCell(row.why, `${where}'s Why cell`),
312322
];
313-
return `| ${cells.join(" | ")} |`;
323+
return tableRow(cells);
314324
}),
315325
];
316326
return [
@@ -407,14 +417,21 @@ function patFormUrl(): string {
407417
);
408418
}
409419

410-
// The body shape is this generator's own output (or an empty body), built from the renderer constants, so a marker
411-
// moved over authored prose or another table fails instead of erasing it.
420+
// The three prose cells are opaque; a row is this table's when its Undeclared default cell is one of the displays,
421+
// the one cell the table rule does not judge.
422+
function sectionsRow(cells: readonly string[]): readonly string[] | string {
423+
if (!Object.values(UNDECLARED_DEFAULT_DISPLAY).includes(cells[3] ?? "")) {
424+
return "shows an Undeclared default the renderer has no display for";
425+
}
426+
return cells;
427+
}
428+
412429
function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
413430
return {
414431
name,
415432
placement: { kind: "under-heading", heading },
416-
body: new RegExp(
417-
String.raw`^\n(?:${RegExp.escape(TABLE_HEADER)}\n(?:\| \x60[a-z_]+\x60 \| [^\n]* \|\n)*)?$`,
433+
body: tableRoundTrip(SECTIONS_TABLE_HEADER, sectionsRow, (rows) =>
434+
renderTable(SECTIONS_TABLE_HEADER, rows),
418435
),
419436
render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`,
420437
};

‎.github/scripts/lib/generated-regions.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,18 @@ export function bodyRefusal(shape: BodyShape, body: string): string | undefined
159159
return shape(body);
160160
}
161161

162+
/** A round-trip guard's verdict: undefined when `body` is `rendered` byte for byte, else the first differing line. */
163+
export function renderedMismatch(body: string, rendered: string): string | undefined {
164+
if (rendered === body) {
165+
return undefined;
166+
}
167+
const authored = body.split("\n");
168+
const expected = rendered.split("\n");
169+
const differing = authored.findIndex((line, i) => line !== expected[i]);
170+
const at = differing === -1 ? authored.length : differing;
171+
return `line ${at} reads ${JSON.stringify(authored[at] ?? "")} where the generator writes ${JSON.stringify(expected[at] ?? "")}`;
172+
}
173+
162174
interface MarkdownScan {
163175
/** Fenced code blocks, an unclosed one running to the end of the text. */
164176
readonly fenced: readonly MarkerSpan[];

0 commit comments

Comments
 (0)