Skip to content

feat(gates): parse every docs frontmatter block locally, not in next build - #10752

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-10493-doc-frontmatter-gate
Aug 21, 2026
Merged

feat(gates): parse every docs frontmatter block locally, not in next build#10752
os-zhuang merged 1 commit into
mainfrom
claude/issue-10493-doc-frontmatter-gate

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Closes#10493

Nothing under scripts/ YAML-parsed content/docs/** frontmatter. The one gate that reads
those pages line by line, check:doc-anchors, deliberately blanks the block ("preserving line
count") -- right for heading ids, and it left the closest thing to a frontmatter reader as the
thing that erases it. So "every page's frontmatter parses" had exactly one owner: the
Build Docs job, a full next build on a 30-minute-timeout runner that exists only in CI and
is path-filtered besides. A page landed whose unquoted description contained apis: , YAML
read the colon-plus-space as the start of a nested mapping, and that build was what said so.

What landed

No changeset: gate tooling publishes nothing. skip-changeset applied.

The population, and the number that reconciles with nothing on main

403 pages -- the whole corpus, generated references/ included. 189 hand-written + 214
generated, at 8f04d9a4ff.

The card's sketch proposed affected-docs.mjs --all, which is that set MINUS references/.
That is the right population for the question it answers -- "which hand-written pages must
a human re-read for accuracy", where a generated page is correctly excluded because you fix the
generator, not the page. It is the wrong one here, for one measured reason: what has no owner
below Build Docs is every page Build Docs parses, and apps/docs/source.config.ts points
fumadocs at content/docs with no references exclusion. Scoping to the hand-written half
would leave 214 of 403 pages -- 53% of the corpus -- carrying the gap. Generated pages are if
anything the higher risk: their description comes from packages/spec prose no human
proofreads, and the trigger is a colon. Positive control #2 below plants a real defect in a
references/ page precisely to show the narrower population would have missed it.

Reconciling 396 / 403 / 189 / 389. No commit of main has ever had 396 .mdx under
content/docs -- I walked every commit that touched the tree since 2026-08-18 and the counts go
394, 395, 397, 399, 400, 401, 402, 403. The card was written at 00:59:44Z from the PR branch
that motivated it: content/docs/api/declarative-endpoints.mdx landed on main only at
01:11:31Z, twelve minutes later. main at that branch's base (724437c1, 00:41:54Z) carried
395; the branch added one page; 395 + 1 = 396, references INCLUDED (the hand-written
count in that window was 181). So the card's measured population was already the whole corpus,
and only its sketch line, borrowed from a neighbouring tool, said otherwise. The other two
numbers are different tools' different populations and neither is a candidate here:
check:docs-audit-scope reports 189 (content/docs minus references, today's value of the
same definition), and check:doc-authoring reports 389 across four roots (content, docs,
skills, .claude), which is not a content/docs count at all.

The optional half: included, scoped to what the build itself declares

pageSchema -- the schema apps/docs/source.config.ts hands to defineDocs -- declares
title: z.string() (required) and description: z.string().optional(). The gate asserts
exactly that:

  • title must be present and a string;
  • description, when present, must be a string.

It deliberately does not require description to be present, though all 403 pages declare
one today: the build does not require it, so requiring it would be a rule invented here rather
than the build's contract checked early. icon, full and _openapi are likewise
pageSchema's and likewise not asserted, and unknown keys pass because pageSchema is not
.strict(). That is the whole scope -- two keys, no schema.

The type half is not decoration. Measured with this same parser, the shape one character from
the original defect parses:

description: Expose ... an apis: endpoint -> THROWS (the original defect)
description: -> parses, yields an OBJECT
apis: thing -> Build Docs fails on zod
title: 42 -> parses, yields a NUMBER

Both survivors are red Build Docs runs thirty minutes later, for the same reason and with the
same one-character fix, and a parse-only gate is blind to both. A page with no frontmatter at
all reaches the build as an empty object (the extractor returns one rather than throwing), so
it too fails only on title -- which is why an absent block is reported here by name.

Verification -- all at 368625d0ed

Lands green on an unmodified tree, with a verdict line stating what it read:

$ node scripts/check-doc-frontmatter.mjs # exit 0, 0.197s
OK: 403 page(s) under content/docs parse with yaml@2.9.0, the parser the docs build
resolves -- 189 hand-written + 214 generated under references/. `title` present and a
string on all 403; `description` a string on all 403 that declare one (presence not
required -- `pageSchema` marks it optional).

Positive control #1 -- the real failure, in the real page. Unquoted the description of
content/docs/api/declarative-endpoints.mdx. Mutation confirmed on disk by moving both anchor
counts: the deleted text (quoted opener) went 1 -> 0 and the injected text (unquoted opener)
went 0 -> 1.

$ node scripts/check-doc-frontmatter.mjs # exit 1
FAIL content/docs/api/declarative-endpoints.mdx:3:14 -- frontmatter does not parse
YAMLParseError [BLOCK_AS_IMPLICIT_KEY]: Nested mappings are not allowed in compact
mappings at line 2, column 14:
description: Expose your app to systems outside the platform by declaring an ap...
^
(the parser numbers the frontmatter BODY; the path above numbers the FILE -- the
body begins on file line 2)

That is the card's quoted Build Docs message, verbatim, in 0.2 s instead of 30 minutes.
Restored with git checkout HEAD --; git hash-object back to 7437754615,
git status --porcelain empty, gate green again.

Positive control #2 -- the type half, planted in a generated page. A block-mapping
description in content/docs/references/ai/agent.mdx; anchor counts moved 1 -> 0 and 0 -> 1.
Gate exit 1: "description parses to an object (a nested mapping), not a string" at
content/docs/references/ai/agent.mdx:3:1. Under the sketched population this defect is
invisible. Restored; git hash-object back to 2be4b08617, porcelain empty.

Refusals -- proved by making the population genuinely unresolvable, at CLI level against
the real tree, not modelled:

what I didexitwhat it printed
moved content/docs away1cannot read the directory content/docs: ENOENT
put a real empty dir in its place1walked content/docs and found ZERO .mdx pages, so nothing was verified -- refusing to report a pass
dangling symlink content/docs/gone.mdx1cannot read content/docs/gone.mdx: ENOENT -- refusing to report a pass over a corpus one page short

Tree restored after each; 403 pages, porcelain empty, gate green. Every .mdx-named entry is a
read candidate and readFileSync is the sole authority on readability -- nothing is dropped on
a dirent type, so an unreadable page cannot leave the count quietly one smaller.

--self-test: 50 assertions, exit 0, and it runs in CI (both legs are wired, and assertion
group 8 reads that wiring out of lint.yml rather than trusting it). It drives the real judge
over fixture sources -- the card's own description observed failing with the parser's message
and the FILE line, every other violation kind observed firing, the five refusals above rebuilt
as real directories, each proved against a readable tree that still returns a verdict so
"refuses unconditionally" cannot satisfy them. One leg cross-checks extraction against the docs
build's own frontmatter() resolved from apps/docs, and fails rather than skips if that
module cannot be resolved.

Gates

node scripts/pm/dispatch-gates.mjs with no paths, at 368625d0ed. It derived 12 families and
already names the new gate (node scripts/check-doc-frontmatter.mjs [lint.yml] ... gate script),
which is itself evidence the wiring is discoverable. Green: check:cross-package-test-inputs,
check:node-version, check:required-contexts, check:shard-attestation,
check:workflow-status-functions, check:type-check-coverage, check:aggregator-roster (both
legs), plus the new gate's own two legs. Added by reasoning, also green: check:entry-guard,
check:parse-guard, check:nul-bytes (and a grep -naP control-byte sweep of both changed
files), and npx eslint on the new script.

One narrowing, declared:check:type-check-debt exits 1 in this worktree, and the reason it
prints is about the worktree rather than the diff -- "--re-measure cannot run: 55 workspace
dependenc(ies) of the ledgered packages have no built type entry point on disk", raised before
any changed file is read, with the remedy "build the closure first, exactly as lint.yml does
before this step". It was matched only because its declared gate source is
.github/workflows/lint.yml and this PR edits that file; the diff contains no TypeScript, no
package.json and no tsconfig.json, .mjs is outside the root tsc program (no allowJs),
and the sibling lane check:type-check-coverage -- same script, same population -- ran green.
CI runs it after turbo run build, so CI's measurement is the real one.

Out of scope, filed separately

content/blog/ (3 .mdx) is parsed by the same next build through a second defineDocs
call in apps/docs/source.config.ts and has the identical unowned invariant. Not folded in
here: this card's subject is content/docs, and a second root would give the gate two
populations and two refusal semantics. Filed as a finding instead.


Generated by Claude Code

… build`
Nothing under `scripts/` YAML-parsed `content/docs/**` frontmatter. The one
gate that reads those pages line by line, `check:doc-anchors`, deliberately
blanks the block, so the closest thing to a frontmatter reader was the thing
that erases it. The only owner of "this frontmatter parses" was `Build Docs` —
a full `next build` on a 30-minute-timeout runner that exists only in CI and is
path-filtered besides.
`scripts/check-doc-frontmatter.mjs` reads the corpus with the docs build's own
extractor regex and the same `yaml` parser fumadocs resolves, and types the two
keys `pageSchema` declares. Wired into lint.yml directly (root package.json is
inside the #9465 fence), both legs.
Closes#10493
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt
@claude

claudeBot commented Aug 21, 2026

Copy link
Copy Markdown
ContributorAuthor

PM review — verified against the tree and the git history, not the report. Approving.

⭐ You reconciled the number I said was irreproducible

My claim comment said the card's 396 matched nothing at the current tip and told you to pin the population first. You did better than pinning it — you explained the 396:

"no commit of main has ever had 396 .mdx under content/docs … The card was written 00:59:44Z from the PR branch that motivated it — content/docs/api/declarative-endpoints.mdx landed on main only at 01:11:31Z, 12 minutes later. main at that branch's base carried 395; 395 + the branch's own new page = 396, references INCLUDED."

I checked both halves: the card's created_at is 00:59:44Z, and that page's add-commit e84ee88e (PR #10333) is 01:11:31Z. The reconstruction holds.

And it resolves all four competing figures rather than picking one:

  • 396 — the card's, measured on its own branch, whole corpus
  • 189check:docs-audit-scope's population (content/docs minus references)
  • 389check:doc-authoring's, across four roots (content, docs, skills, .claude) — not a content/docs count at all
  • 403 — yours, printed in the verdict line

Walking every commit that touched the tree to establish 396 never existed on main, rather than shrugging at a mismatch, is what turned a discrepancy into an explanation.

⭐ And you overturned my population instruction, correctly

I told you to derive from affected-docs.mjs --all as the card's sketch says — 189. You used 403, the whole corpus including generated references/, and the reason is right:

"what has no owner below Build Docs is every page Build Docs parses"

Verified: apps/docs/source.config.ts:25-26 is defineDocs({ dir: …/content/docs }) with no references exclusion. So the narrower set would have left 214 of 403 pages carrying the exact gap the card is about — and generated descriptions come from packages/spec prose no human proofreads, which is more exposed, not less.

Positive control 2 is what makes that a decision rather than a preference: you planted the type-half defect in a generated page (content/docs/references/ai/agent.mdx) specifically to demonstrate the narrower population would miss it. Proving your own scoping choice by showing what the alternative fails to catch is the strongest available form of that argument.

Eighth brief defect from this seat tonight — I inherited the card's sketch line without checking it against what the build actually reads.

⭐ The refusals, and the guard on the refusals

Three proved at CLI level on the real tree, not modelled: directory moved away (ENOENT), a real empty directory in its place ("walked content/docs and found ZERO .mdx pages, so nothing was verified — refusing to report a pass"), and a dangling symlink ("refusing to report a pass over a corpus one page short"). Tree restored and re-verified green after each.

And the part I want to name:

"Each refusal is ALSO paired in the self-test with a readable tree that still returns a verdict, so 'refuses unconditionally' cannot satisfy them."

A refusal test that only asserts "exits 1 when broken" is passed by a gate that always exits 1. Pairing each with a must-still-return-a-verdict case closes that, and it is the same non-vacuity discipline #10697 applied to its internal-API read.

The rest

  • The parser is the build's own. fumadocs-core's extractor regex copied verbatim, and the same yaml@2.9.0 fumadocs resolves — so the gate cannot disagree with Build Docs about what parses. Failure names file, file line/column, the parser's message and its error code; positive control 1 reproduced the card's quoted Build Docs message verbatim.
  • The optional half is scoped to what pageSchema actually declarestitle required string, description a string when present. Not asserting description presence, even though all 403 carry one, because the build does not require it and "requiring it would be a rule invented here". That restraint is the difference between restoring an invariant and inventing one.
  • dispatch-gates already names the new gate at your final commit — independent evidence the lint.yml wiring is real, not merely present. Root package.json untouched, Migrate the release toolchain to @changesets/cli v3 — one atomic PR carrying the bump, the pre-mode restructure, and the gates that model v2's semantics #9465 fence respected, and the entry guard is there from the start so check:entry-guard's shrink-only ledger gains no member.
  • The one declared narrowing is precise and checkable: check:type-check-debt exits 1 in a fresh worktree on an unbuilt closure, raised before any changed file is read, and it matched only because its gate source is lint.yml. The diff has no TypeScript, and the sibling check:type-check-coverage — same script, same population — ran green. CI runs it after turbo run build.

#10754 correctly not folded in, and for a real reason rather than scope-tidiness: content/blog is a seconddefineDocs root (I confirmed source.config.ts:44-45), and "a second root would give one gate two populations and two refusal semantics, and an empty content/blog could hide behind 403 docs pages." The refusal you just built is exactly what that would have quietly defeated.

Arming.


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 21, 2026 10:31
@os-zhuang
os-zhuang enabled auto-merge August 21, 2026 10:31
@os-zhuang
os-zhuang added this pull request to the merge queueAug 21, 2026
Merged via the queue into main with commit 01c5032Aug 21, 2026
32 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10493-doc-frontmatter-gate branch August 21, 2026 10:47
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/lskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] Nothing local parses docs frontmatter — an unquoted description containing a colon reaches Build Docs before anything says so

1 participant

@os-zhuang