Skip to content

build(scripts): seal generated regions behind a parse-render round trip - #483

Merged
Vivswan merged 2 commits into
mainfrom
build/markdown-regions
Oct 6, 2026
Merged

Vivswan merged 2 commits into
mainfrom
build/markdown-regions

Conversation

@Vivswan

@Vivswan Vivswan commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

Before

function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
  return {
    name,
    placement: { kind: "under-heading", heading },
    body: tableRoundTrip(SECTIONS_TABLE_HEADER, "keyed", sectionsRow, (rows) =>
      renderTable(SECTIONS_TABLE_HEADER, rows),
    ),
    render: () => `\n${renderSectionsTable(SECTIONS, DOCS)}\n`,
  };
}

After

function sectionsTableRegion(name: string, heading: string): GeneratedRegion {
  return GeneratedRegion.of({
    name,
    placement: { kind: "under-heading", heading },
    data: () => sectionsCells(SECTIONS, DOCS),
    render: block(renderSectionsRows),
    parse: tableBody(SECTIONS_TABLE_HEADER, "keyed", sectionsRow),
  });
}

GeneratedRegion.of() is the one way to build a region, so its body check is always its own round trip.

How

  • Sealed constructor: private, so a body: regex no longer typechecks.
  • Round trip as the check: a body must parse and re-render byte for byte.
  • Seven regex bodies became parsers, and action.yml reads back through yamlDeclarations().
  • Composer tables render through renderTable(), bytes unchanged.
  • Line numbers count down from the BEGIN marker.
  • A count past the word table is refused at the parser naming line 1, never thrown by the renderer.

Proof

  • Gates: typecheck, biome, lint:arch, knip clean, both generators idempotent.
  • Tests: 190 pass in the four touched files. A planted hand edit in ten generated pages and a 21-key count sentence are refused naming the line, and dropping the composer's escaping fails 3.
  • Censuses: body: literals 12 -> 0, roundTrip definitions 2 -> 1, hand-built | rows in composer.ts 7 -> 0.
Technical details
  • Guarantee's scope: a region declared through this module cannot carry a body check other than its own parse-render round trip, and nothing stops a script from bypassing the module entirely, which is review's job.
  • Lines: scripts +550 -211, tests +252 -115, src +29 -56.
  • Why the scripts grew: eight read-back parsers stand where seven regexes and the local YAML round trip stood, one per renderer.
  • Censuses, where: grep -c '^\s*body: ' over the two generator scripts, grep -c 'function roundTrip' over markdown.ts and the scripts, grep -c '\| '` over composer.ts.
  • Deleted tests: "a region that renders a table carries a round trip, never a hand-written grammar" in both generator test files, since the type makes a RegExp body unrepresentable and the body: census stands in.
  • New test, numbering: the refusal's line number equals the edited line's distance from the BEGIN marker, pinning the message and each parse to one convention.
  • New tests, architecture map: an authored line inside the mermaid fence and one after it are refused, and a dotted layer id reads back as the renderer writes it.
  • New tests, prose lists: a malformed list is refused naming its line instead of throwing from the renderer, and the renderer refuses an item holding the word "and".
  • New test, count sentence: a page listing 21 keys under "Twenty-one sections ..." is refused with line 1 lists 21 section keys, past the 20 the count words name; on the prior code the test failed with countWord()'s throw.
  • Count bound: COUNT_WORD_MAX is the one exported bound beside the COUNT_WORDS table (.github/scripts/lib/count-word.ts), derived from its length, and the parser reads it. The registry path keeps its throw: a derived list outgrowing the table is a programming error, not a page a user edited.
  • Kept behavior: the Defaults table's delete-first order is still checked, by renderPolicyRows() sorting the parsed rows before the byte compare.
  • Kept behavior: policyRows() walks sections delete-first so a missing-prose error names the same section as before.
  • Coverage page: its prose slots stay opaque to the parse (earlier design), so a hand edit inside a paragraph regenerates rather than refuses, and a structural edit (a fourth cell in a Supported row) is refused naming its line.
  • YAML lines: yamlDeclarations() parses with prettyErrors: false and counts the error offset in the body, so the named line is the region's rather than the library's, which counts the prepended key line as 1.
  • Prose lists: proseItemFault() is the one rule (no comma, no standalone "and") that both the renderer and the read-back refuse by, for labels and route spans alike, so every ", " and " and " in output is a joiner and the split is exact.
  • Refused: a duplicate-tolerant node list for the architecture map. Two layer names differing only in "-" and "_" collide in renderArchitectureMermaid()'s id mapping (arch-lint.ts, outside this change), mermaid would merge them into a wrong diagram, and the region now refuses that page instead. An injective id belongs to arch-lint.ts.
  • Recorded, not built: the YAML-conversion and declaration-validation refusals in gen-action-docs.ts carry no numeric line (inherited behavior); architecture source-equality, where a swapped edge round-trips and regenerates rather than refusing; runtime sealing against Object.create or method replacement; empty registries the data thunks never supply; class-wide coverage that would catch a future parser admitting data its renderer throws on, beyond the prose-list and count guards built here.
  • Files: .github/scripts/{gen-docs.ts,gen-action-docs.ts,lib/generated-regions.ts,lib/count-word.ts}, src/report/{markdown.ts,composer.ts}, test/scripts/{generated-regions,gen-docs,gen-action-docs}.test.ts.

A generated region is built from a parse and a render through GeneratedRegion.of(); the body check is that round trip, so no region can carry a grammar written beside its renderer. The action.yml regions parse through yamlDeclarations() instead of a local roundTrip(), the composer's two tables render through renderTable(), and a refusal says its line counts down from the BEGIN marker.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 07:04
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

File size check

0 over a hard cap (fails), 1 warning(s).

File Size Tier Cap
test/e2e/generators.ts 2641 lines warn 2560

Split the file, wrap the line, shorten or exempt the comment, or list the path in .file-size-allow.local with a # reason.

5 managed file(s) skipped; repo-platform owns them.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A count sentence containing more than twenty parsed keys still throws from the renderer instead of producing a line-specific region refusal.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

This PR makes generated-region validation use sealed parse-render round trips, preventing generated content from relying on separate regex grammars.

Changes:

  • Introduces GeneratedRegion.of() and shared block/table parsing helpers.
  • Adds round-trip parsers for generated Markdown, Mermaid, prose, and YAML.
  • Reuses renderTable() in report composition and expands regression tests.
File Description
.github/​scripts/​lib/​generated-regions.ts Implements sealed round-trip regions.
.github/​scripts/​gen-docs.ts Adds generated-page parsers.
.github/​scripts/​gen-action-docs.ts Adds action and prose parsers.
src/​report/​markdown.ts Exposes table parsing separately.
src/​report/​composer.ts Uses the shared table renderer.
test/​scripts/​generated-regions.test.ts Tests sealed region behavior.
test/​scripts/​gen-docs.test.ts Tests documentation round trips.
test/​scripts/​gen-action-docs.test.ts Tests action documentation round trips.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/scripts/gen-action-docs.ts Resolved
…ndering it

parseCountSentence() admitted any number of section keys, so a hand-edited count sentence listing 21 reached the renderer, whose countWord(21) threw with no region or line named. The parser now refuses a count past COUNT_WORD_MAX, the one exported bound beside the COUNT_WORDS table, naming line 1 like its other refusals. The registry path keeps its throw: a derived list outgrowing the table is a programming error.

Also wraps the one test line over 256 characters and re-cuts the comment over 120 columns the PR added.
Copilot AI balanced review requested due to automatic review settings October 6, 2026 07:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The architecture parser rejects freshly rendered output for valid layer names containing whitespace.

Review effort: Balanced
Findings: None

Resolved since last review (1)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Whitespace layer names produce invalid Mermaid output

.github/​scripts/​gen-docs.ts:767

A valid layer name containing whitespace makes the generator reject its own freshly rendered architecture map.

For example, renaming main to main layer (and updating its edges) is accepted by Architecture and renders main layer["src/main.ts"], but NODE_LINE rejects that line because the ID is limited to \S+. renderArchitectureMermaid() only rewrites hyphens, so either parse every ID it can emit or validate/encode layer names before rendering; add a whitespace-ID round-trip case alongside the dot/digit case.

@Vivswan
Vivswan marked this pull request as ready for review October 6, 2026 07:50
@Vivswan
Vivswan merged commit a485333 into main Oct 6, 2026
30 checks passed
@Vivswan
Vivswan deleted the build/markdown-regions branch October 6, 2026 08:05
@Vivswan Vivswan added the merge-when-green Owner approved: merge once every gate is green label Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-when-green Owner approved: merge once every gate is green

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants