Skip to content

fix(spec-docs): a module header's headings start at the page's section level - #12551

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12249-single-h1-references
Aug 26, 2026
Merged

fix(spec-docs): a module header's headings start at the page's section level#12551
huangyiirene merged 1 commit into
mainfrom
claude/issue-12249-single-h1-references

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#12249

The 38 generated pages under content/docs/references/** rendered two <h1>. The second one was never in the MDX — it came from a JSDoc file header in packages/spec/src/** that packages/spec/scripts/build-docs.ts copied verbatim into the page. A .zod.ts header is written as if it were a standalone document, so its author opens a section with # ; embedded under a page whose <h1> is already its frontmatter title (DocsTitle), that compiles to a second one.

Option A per triage (comment 5413091398), not option B: the generator owns the page's heading levels, so the level rule belongs where the page is emitted, not in 41 comments that feed it. No packages/spec/src/** JSDoc header is touched.

What changed

packages/spec/scripts/lib/file-description.tsrenderFileDescription renumbers the fragment so its shallowest heading sits at ctx.sectionLevel, moving every other heading with it.

packages/spec/scripts/build-docs.ts — declares PAGE_SECTION_LEVEL = 2, uses it for its own ## TypeScript Usage emission, and hands it to renderFileDescription as context. The level is injected rather than hard-coded in the renderer for the reason fromCategory is (#6484): only the page knows it. A .zod.ts header cannot know what page it will be embedded in.

scripts/check-docs-single-h1.mjs — the content/docs/references/** carve-out is deleted, per the issue's acceptance. The gate's judged population grows 356 → 394 pages. Its self-test was reworked in the same edit: the dead-exclusion limb and the "violations are still counted" control now run against the remaining releases/** entry, the references fixture is kept as a positive control that the tree is back in scope, and scanned moves 9 → 10.

Why a renumbering and not ###

Three properties the blanket demotion does not have, each pinned:

  • It only ever demotes. 26 of the 64 described modules with headings already start at level 2; a blanket +1 would have moved 105 correct headings a level deeper and regenerated 26 pages that were never wrong.
  • It moves the block, not the level-1 lines.automation/control-flow writes ## with ### under it. Rewriting only # lines collides two source levels into one wherever a block mixes them.
  • It cannot silently corrupt code. The shift is scoped to prose lines, so a # Install pnpm globally inside a fenced or indented block is untouched. This is why it lives beside classifyLines rather than as a regex over the emitted string in build-docs.tsclassifyLines is already the one model in this pipeline that knows which lines are code, and a fence-blind pass rewrites working snippets to satisfy a rule about HTML those lines never produce (the same false positive check-docs-single-h1.mjs's own header measures at ten pages and calls more expensive than the defect).

A shift that would push a heading past level 6 throws rather than emitting a seven-hash line. Unreachable on the current corpus — measured: no level-1-bearing description goes deeper than level 2 — and a loud failure is the right answer if a source ever gets there.

Regeneration

pnpm --filter @objectstack/spec gen:schema && gen:docs (never hand-edited). Exactly 38 pages changed, as the card predicted. Every one of the 89 changed lines is an ATX heading and nothing else — 43 level-1 headings became level 2, and the 46 level-2 headings inside those same 38 blocks moved to level 3 with them:

$ git diff -U0 -- content/docs/references | grep -E '^[+-]' | grep -vE '^(\+\+\+|---)' \
| grep -vE '^[+-] {0,3}#{1,6}( |$)' | wc -l
0

Verification — all on the final commit c4c84fa

checkverdict line
check:docs-single-h1✓ check-docs-single-h1: 394 page(s) under content/docs/ carry no body-level # heading (1 subtree(s) excluded); self-test ✓ 17 cases pass
@objectstack/spec check:docs✅ 229 generated files in sync with packages/spec
@objectstack/spec check:scripts-typecheckEXIT=0 — and --listFiles confirms all three edited TS files are in the program
file-description.test.tsTests 70 passed (70)
pnpm lint (repo-wide eslint . --no-inline-config)LINT_EXIT=0 — full run, not narrowed
check:nul-bytes, check:cross-package-test-inputs, check:test-source-alias, check:doc-anchors, check:changeset-gate-self-testsEXIT=0 each

The full family derived by node scripts/pm/dispatch-gates.mjs (27 root gates + 5 filtered) ran green earlier in the branch; the table above is the re-run on the final tree.

Reverse verification (measured, ordinary direction). Replacing the withHeadingsAtSectionLevel call with the identity turns 9 of the 70 cases red — every level-1 case plus both corpus limbs, the first reporting exactly the 43 headings the issue measured on main. The two cases about what the shift must not touch (already start at level 2, #NoSpace) stay green, as do the 61 cases the file already had. The mutation was confirmed on disk before the run (anchor hit count 1 → 0, injected line 0 → 1, blob 2581ae65615e62fb) and the restore leg proved by git diff HEAD empty. No build is involved: the test imports ./lib/file-description by relative path, so vitest transforms the source directly — no dist/ can make this reading stale.

Disclosures

  • check:generated — one limb NOT MEASURED here, and it is not this diff's. It reports ✗ 1 of 14 artifact(s) stale: api-surface/, with its own reason: ❌ packages/spec/dist holds no .d.ts declarations — the package is not built. api-surface/ is derived from the built .d.ts (build-api-surface.ts: "the built dist — run after build"), and this worktree has no packages/spec/dist. The limb this change owns, ✓ check:docs content/docs/references/**, is green in that same run, and this commit's 42 files contain no packages/spec/src/**, no package.json and no export change, so nothing here can move api-surface/. CI builds and owns it.
  • check:doc-formula-expressions / check:doc-security-posture first read as PREREQUISITE NOT MET — an unbuilt @objectstack/formula / @objectstack/lint, which those gates label "Nothing was measured… NOT a finding". Discarded that reading, built both packages, re-ran: GATE1_EXIT=0, GATE2_EXIT=0.
  • Two FileDescriptionContext literals were missed by the first pass and caught by check:scripts-typecheck (TS2345), not by vitest — the runtime suite was green while the types were wrong. Fixed and re-run before the final commit.

Changeset

None — skip-changeset. This PR publishes nothing: packages/spec's files array does not include scripts/, and content/docs/** and root scripts/** ship in no tarball. No packages/spec/src/** was touched, so the published surface is byte-identical. Per AGENTS.md, pure bug fixes do not require a changeset, and the workflow's own prescription calls "this PR edits a CI-internal script" the textbook label case.

Epic

Part of the definition of done in #12243 — that epic's no page renders more than one <h1> count includes these 38 pages, which is why this stayed a sub-issue of it even though the fix lives outside the epic's declared apps/docs/** + content/docs/** territory (epic PM routing, comment 5412736502). #12250 remains open and is untouched here: content/docs/releases/** keeps its carve-out, and no file under it was edited.


Generated by Claude Code

…n level
The 38 generated pages under `content/docs/references/**` rendered two
`<h1>`. The second one was never in the MDX: it came from a JSDoc file
header in `packages/spec/src/**`, which `build-docs.ts` copied verbatim
into the page. A `.zod.ts` header is written as if it were a standalone
document, so its author opens a section with `# ` — embedded under a page
whose `<h1>` is already its frontmatter `title`, that compiles to a second
one. 43 headings across the 38 pages.
`renderFileDescription` now renumbers the fragment so its SHALLOWEST
heading sits at `ctx.sectionLevel`, which `build-docs.ts` supplies as the
level its own `## TypeScript Usage` and `## <SchemaName>` sections use.
Renumbering rather than rewriting every `# ` to `## `, for three reasons
the pins carry: 26 described modules already start at level 2 and a
blanket shift would regenerate 26 pages that were never wrong; a block
mixing `#` and `##` would collide two source levels into one; and — the
load-bearing one — the shift is scoped to `prose` lines, so a
`# Install pnpm globally` inside a fence is left alone. That last is why
this lives beside `classifyLines` instead of running as a regex over the
emitted string: a fence-blind pass corrupts working snippets to satisfy a
rule about HTML those lines never produce.
With the tree clean, `scripts/check-docs-single-h1.mjs` goes
`DEAD-EXCLUSION` on its own carve-out, so that entry is deleted here. The
gate's judged population grows from 356 to 394 pages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 1a685522618c7b6354ff3b53d5b423a833d32494packageMentionDocs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirenehuangyiirene added skip-changeset PR has no user-facing published change; bypasses the changeset gate and removed documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026 — with Claude
@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

PM review — ACCEPT (session_01E5LFCYBJ3q2s6yW6oMLxwy, domain:spec seat R7, contract review performed at the review tier as the compensating control for an opus-built PR).

Contract verdict: Clause-② re-checked against the real diff — no on both limbs. No packages/spec/src/** file is touched (option B correctly refused); the published surface is unmoved; what changes is how the generator renders JSDoc file headers into pages, plus the repo gate's scope.

On the implementation deviating from the card's suggested blanket ### demotion — upheld, and it is the better shape: the dev's own measurement (26 further modules already start at level 2, 105 headings) showed a blanket +1 would have wrongly demoted correctly-levelled headings and churned 26 extra pages. Renumbering the block relative to its shallowest heading only ever demotes, preserves authored nesting, and touches exactly the measured 38. Verified independently on the diff: all 178 changed lines across the 38 regenerated pages are ATX heading lines — zero prose or code lines moved.

Points that carried the review:

  • The shift is scoped by classifyLines' existing prose/fenced/indented model, so a # inside a code block is untouched — placing it in lib/file-description.ts beside the classifier, rather than as a fence-blind regex in build-docs.ts, is the load-bearing decision.
  • ATX_HEADING's (?=[ \t]|$) guard keeps #5059-style references and #NoSpace out; >6-level overflow throws loudly (unreachable on current corpus, correct if reached).
  • PAGE_SECTION_LEVEL injected as context for the same reason as fromCategory (docs-gen: 同目录裸源码路径(无分类段)从来不成链接 —— 9 处、4 张已发布参考页 #6484): only the page knows its own heading contract — and build-docs' own ## TypeScript Usage now derives from the same constant.
  • Carve-out deletion done to the acceptance letter: the self-test keeps the references fixture as a positive control (judged, 1 finding), re-anchors the dead-exclusion limb on releases/** (docs content: 4 pages under content/docs/releases/ still render two or three <h1> — three of them need a cascading demotion, not a mechanical one #12250), and the judged population grows 356 → 394. Reverse verification is load-bearing (identity ablation reds 9/70 including both corpus limbs, first reporting exactly the 43 headings measured on main).
  • Surface note for the record: lib/file-description.ts + file-description.test.ts extend the claim comment's named files but are the generator's own seam inside packages/spec/scripts/**; no in-flight overlap.

Instrument disclosures (5 — including the discarded PREREQUISITE-NOT-MET readings rebuilt and re-run, and the TS2345s caught by typecheck after a green-but-type-erased vitest) are the discipline we keep — noted.

Landing: waiting for every check green on c4c84fa, then ready-flip + auto-merge, queue-entry verified by ls-remote.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 10:41
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit dd4fc6cAug 26, 2026
47 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12249-single-h1-references branch August 26, 2026 10:56
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.

docs content: the 38 generated reference pages still render two <h1> — the heading comes from a spec JSDoc header via build-docs.ts

2 participants

@huangyiirene@claude