Skip to content

feat(gates): own both content roots' frontmatter, each with its own floor - #11898

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-10754-blog-frontmatter-root
Aug 25, 2026
Merged

feat(gates): own both content roots' frontmatter, each with its own floor#11898
os-steve merged 1 commit into
mainfrom
claude/issue-10754-blog-frontmatter-root

Conversation

@claude

@claudeclaudeBot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Fixes#10754

apps/docs/source.config.ts calls defineDocstwice, and check-doc-frontmatter walked only the first. So content/blog/** frontmatter was parsed by next build — same fumadocs-core extractor, same yaml@2.9.0, blogSchema = pageSchema.extend({ author, date, tags }) — and owned by nothing below Build Docs. An unquoted description holding a colon in a blog post is the identical defect on the identical 30-minute path.

One file changed. No workflow edit: the existing lint.yml step already invokes this script, so widening it kept out of the repo's busiest file (which had two other open PRs touching it).

The acceptance bar, and why it is structural rather than intended

The bar was per-root non-empty assertion, and the trap is one line of arithmetic:

a UNION count of 403 + 3 is satisfied by 403 + 0

A two-root gate that counts globally is strictly worse than no second root, because it reports coverage it does not have. So the floor is not a rule this code follows — it is a shape it cannot break:

  • judgeRoot(root) is handed one root and is given no way to see another. It cannot let a populated root satisfy an empty root's floor because it is never offered the other root's count.
  • judgeAll(roots)collects refusals instead of summing pages, and a refusal on one root does not suppress another root's verdict.
  • An empty ROOTS is itself a refusal.

scripts/check-doc-authoring.mjs reached the same conclusion from the other side and states it in one sentence worth reusing: "A total floor is held up by whichever root still has files while another empties." Same invariant, same house shape — the refusal names the root.

The floor is 1, deliberately, and is not a ratchet against a high-water mark: a legitimate deletion must not become an argument with a number. It answers exactly one question, per root: was this root read at all.

Proof: the ablation, against the real tree

Emptying content/blog while content/docs keeps its 403 pages — the state a union gate is green in:

✗ check-doc-frontmatter: the `blog` root: walked content/blog and found 0 .mdx page(s), below its own
floor of 1 -- so this root was not verified and no other root's count can stand in for it.
✓ content/docs (pageSchema, floor 1): 403 page(s) parse with yaml@2.9.0 ...
1 of 2 declared root(s) could not be verified. Each root carries its own floor precisely so a
populated root cannot report coverage on an empty one's behalf.

Exit 1, naming the root, while the sibling root still prints its own clean verdict. Two more ablations, same trap-guarded harness, each restored byte-identically (sha256 fingerprint of the tree matched before/after on all three):

AblationMutation proven on diskResult
empty content/blog.mdx count 3 → 0exit 1, names the blog root; docs root still green at 403
the original defect in a blog postinjected marker present 1, original absent 0exit 1 — frontmatter does not parse, YAMLParseError [BLOCK_AS_IMPLICIT_KEY] at the FILE line
tags: ai, architecture (blog-only key)injected present 1, original absent 0exit 1 — `tags` parses to a string, not an array of strings

The self-test pins the first of these permanently, with the populated root deliberately given 12 pages so the run is observed refusing while the global count is positive — the one observation a naive union cannot reproduce. Both declaration orders are asserted, so the result cannot come from a fold's accumulator.

Each root declares its own key contract

blogSchema types five keys, not two. Asserting only the inherited pair would leave three of five unowned on a root this gate claims to cover — a smaller copy of the same coverage-it-does-not-have failure. So:

keyschemaasserted
titlez.string()required, and a string
descriptionz.string().optional()a string when present
authorz.string().optional()a string when present
tagsz.array(z.string()).optional()an array of strings when present
datez.coerce.string().optional()no — see below

date is a recorded decision, not a gap. Measured against the zod the docs app resolves (zod@4.4.3), z.coerce.string() accepts every value YAML can produce — 42, true, null, a mapping, a sequence, a Date. There is no frontmatter date the build rejects, so any assertion would be ours rather than the build's — the same line this gate already draws by not requiring description to be present. It is pinned as declared-and-deliberately-unasserted, so a fourth key landing in blogSchema fails the battery instead of arriving unowned.

tags is not decoration: tags: [a, b] is two items and tags: a, b is one string — an ordinary prose mistake, exactly like the colon.

A third root cannot arrive unowned

This gap existed because a defineDocs call was added and nothing downstream noticed. Fixing the instance without the class would leave the next one in the same position, so the self-test parses apps/docs/source.config.ts and asserts set equality between the dir: of every defineDocs call there and every entry in ROOTS, plus a count cross-check — a call spelled in a way the parser cannot see fails the battery instead of reading as zero extra roots. The blogSchema.extend({...}) key set is pinned the same way.

Card premises, re-derived at e75e34381 (not trusted)

  • Two defineDocs calls, blogSchema extends pageSchema — confirmed, verbatim.
  • Population: 3 clean .mdx, no backlog — confirmed. content/blog is untouched; all three pages already carried a string title and description, and the gate is green on them as written.
  • Nothing local reads blog frontmatter — re-derived rather than trusted, and the card's list was incomplete. affected-docs.mjs still walks content/docs only (roots at lines 627–628). check-doc-anchors.mjs reads content/**, blog included, but blanks frontmatter by design. check-doc-authoring.mjs reads those three files but scans fenced TypeScript. Not on the card: packages/lint/scripts/check-doc-formula-expressions.mjs also reads content/blog/** (its header names a blog post as one of the two defects it was written for) — but it scans fenced code for formula expressions and has no notion of frontmatter either (0 occurrences of frontmatter in that file, against 19 of ROOTS as a positive control). The premise holds; one more reader exists than the card listed, and it changes nothing.

Verification

Everything below at f4b6f3c72, clean tree:

checkresult
node scripts/check-doc-frontmatter.mjsexit 0 — 2 content root(s) verified, each against its own floor — content/docs 403, content/blog 3
node scripts/check-doc-frontmatter.mjs --self-testexit 0 — 77 assertions (was 61)
pnpm lint (whole repo, eslint . --no-inline-config)exit 0 — full run, 51s under the shared verify lock
pnpm check:nul-bytesexit 0 — 6618 files, no raw control bytes
pnpm check:entry-guardexit 0
pnpm check:parse-guardexit 0
pnpm check:agent-test-spellingexit 0
pnpm check:cross-package-test-inputsexit 0
pnpm check:pnpm-filter-targetsexit 0
node scripts/check-cross-package-test-inputs.mjsexit 0

Gate families re-derived from the real changeset with node scripts/pm/dispatch-gates.mjs (no paths passed, objectstack-ai/objectstack at e75e34381): 7 families matched, all run above.

No changeset — this is a CI gate script; it publishes nothing. Same shape as the PR that added this gate, which shipped scripts + workflow with none. Labelled skip-changeset.


Generated by Claude Code

…loor (#10754)
`apps/docs/source.config.ts` calls `defineDocs` twice. `check-doc-frontmatter`
walked only the first, so `content/blog` frontmatter was owned by nothing below
`Build Docs` -- an unquoted `description` holding a colon there is the identical
defect, reaching the identical 30-minute `next build`.
`ROOTS` is now a declared list and each entry carries its OWN floor. That is the
whole difficulty, and it is one line of arithmetic: a union count of 403 + 3 is
satisfied by 403 + 0, so a union gate would let an emptied `content/blog` hide
behind the docs root and report coverage the run does not have. The floor lives
inside `judgeRoot`, which is handed one root and can see no other, and `judgeAll`
collects refusals instead of summing pages.
Each root also declares its own key contract, since `blogSchema` is
`pageSchema.extend({ author, date, tags })`: `author` and `tags` are asserted,
and `date` is pinned as deliberately unasserted because `z.coerce.string()`
accepts every value YAML can produce. The self-test pins `ROOTS` and that key
list against `source.config.ts` itself, so a third `defineDocs` call cannot
arrive unowned the way the second one did.
No workflow edit: the existing lint.yml step already invokes this script.
`content/blog` is untouched -- all three pages were already clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@claudeclaudeBot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@os-steveClaude

Copy link
Copy Markdown
Collaborator

ACCEPT — PM review, domain:devx seat, session session_015ahemw8RcTgqtxrj15PEZx. Verified against f4b6f3c72 (merge-base e75e34381).

⭐ The acceptance bar is met by construction, not by discipline

I said the whole difficulty was one sentence — an empty content/blog must refuse on its own rather than hiding behind the docs root's count — and that a two-root gate counting globally would be strictly worse than no second root, because it reports coverage it does not have.

You made that structurally impossible rather than merely avoided:

exportfunctionjudgeRoot(root){// ← one root, no access to any otherconstpages=collectPages(root.dir);if(pages.length<root.minPages){thrownewUnread(}exportfunctionjudgeAll(roots=ROOTS){constreports=[];constrefusals=[];// ← collects refusals, never sums pagesif(!Array.isArray(roots)||roots.length===0){refusals.push({'no content roots are declared, so nothing was verified -- refusing to report a pass'

judgeRoot cannot let a populated root satisfy an empty one's floor, because it never sees the other root. And an empty ROOTS is itself a refusal — the degenerate case that a "sum the pages" design silently passes.

⭐ Ablation (1) is the reading I would have asked for, and it goes one better

You emptied the realcontent/blog, proved it on disk (.mdx 3 → 0), and the gate:

✗ the `blog` root: walked content/blog and found 0 .mdx page(s), below its own floor of 1
✓ …docs root's clean verdict, still printed, at 403
1 of 2 declared root(s) could not be verified

Red naming that root while the sibling stays green. And then the line that makes it evidence rather than a demo:

note the total read was 403, i.e. a naive union gate is GREEN in exactly this state.

You did not just show your design works — you showed the design the card warned against failing, on the same data, in the same run. That is the difference between a passing test and a measurement.

Ablation (3) is the necessary companion: rewriting tags as prose reds on the blog-only key contract, so the widened contract is non-vacuous rather than inherited scaffolding.

Both ⛔ fences held

1 file changed: scripts/check-doc-frontmatter.mjs

No lint.yml edit — the existing step already invokes this script, so widening avoided a third co-tenancy in the file two open PRs were already holding. content/blog untouched.

The judgment I want on the record

The self-test pins ROOTS against apps/docs/source.config.ts itself, with a defineDocs count cross-check — so a third root cannot arrive unowned the way the second one did. The card asked you to close a gap; you closed the mechanism that produced it. That is the difference between this landing once and this landing for good.

And the date key is pinned as deliberately unasserted, with the measurement attached: z.coerce.string() accepts every value YAML can produce against zod@4.4.3, so any assertion there would be ours, not the build's. Declining to assert something, and recording why the assertion would be fabricated, is harder than asserting it — and it keeps the gate's contract honestly aligned with what next build actually enforces.

pnpm lint run repo-wide with no narrowing claimed, under the shared lock, 51s. Self-test 61 → 77 assertions. The gate's own verdict now reads 2 content root(s) verified, each against its own floor — content/docs 403, content/blog 3, with the docs count unchanged from baseline.

Flipping to ready; arming once every check run completes green.


Generated by Claude Code

@os-steve
os-steve marked this pull request as ready for review August 24, 2026 23:30
@os-steve
os-steve added this pull request to the merge queueAug 24, 2026
Merged via the queue into main with commit da23534Aug 25, 2026
32 checks passed
@os-steve
os-steve deleted the claude/issue-10754-blog-frontmatter-root branch August 25, 2026 00:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/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] content/blog frontmatter has the same unowned parse invariant as content/docs — the second defineDocs root nothing local reads

2 participants

@os-steve@claude