fix(scripts): check-doc-links resolves the #fragment, not just the file (objectui#7644) - #7657
Merged
Merged
Conversation
…le (objectui#7644) `scripts/check-doc-links.mjs` validated the DOCUMENT a link named and never the ANCHOR inside it: `hrefPath()` and `routeExists()` both cut the href at its `#`, and `EXTERNAL_HREF_RE` waved a pure `#anchor` through by shape. Renaming a heading therefore orphaned every cross-reference to it, silently, with the gate green — measured in objectui#7643, where `### KanbanSchema` became `### DeclarativeKanbanSchema` and the `[...](#kanbanschema)` one screen away had to be corrected by hand. Anchors are now resolved wherever the target is markdown in this tree: same page, another page, an absolute `/docs/...` route, an `objectui.org` URL, and this repo's own `blob/main/...` URL — the last retiring an objectui#3536 waiver that deferred anchors to "a different gate", which is this one. A `#L42` line reference, a site route outside the collection and a non-markdown target return no opinion rather than a pass dressed as one. The slug rule is the renderers' own, vendored rather than imported: this gate runs before `pnpm install`, so `scripts/github-slug.mjs` copies github-slugger's generated table instead of importing it. The obvious shortcut is wrong — a Unicode property escape disagrees with that table on the `No` digits and on every letter added since it was generated — so the pin test compares the copy against the real `github-slugger` over all 1,112,064 non-surrogate code points, and compares this gate's anchor sets against fumadocs' own `getTableOfContents` over all 273 files in the scan surface. Counted from the tree rather than assumed: 143 hrefs carry a fragment, 142 are decidable here, and exactly one was dead. It is repaired in this commit, so the check lands fully strict with no baseline row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
This was referenced Sep 4, 2026
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #7644
The defect
scripts/check-doc-links.mjsvalidated the document a link named and never theanchor inside it.
hrefPath()androuteExists()both opened by cutting the href atits
#, andEXTERNAL_HREF_REwaved a pure#anchorthrough by shape — so[label](#some-heading)was judged by nothing at all.Renaming a heading therefore orphaned every cross-reference to it, silently, with the gate
green. Not a projection: in PR #7643
### KanbanSchemabecame### DeclarativeKanbanSchemaand the
[...](#kanbanschema)one screen away had to be corrected by hand.Reproduced as a red first — the two-arm control
The card's control was re-run on this branch before any change, each arm proved on disk
by anchored count plus
git hash-objectmovement off the HEAD blob, each restored withgit checkout HEAD -- ABSOLUTE_PATHunder anEXIT INT TERMtrap and verified by blobequality plus an empty
git diff HEAD.#declarativekanbanschemadoes not exist onmain(PR #7643 has not landed), so the same link was mutated in its pre-rename spelling.
Links are valid across 17 scan roots.[anchor] ...:1193 -> #totally-not-a-heading-xyz[control probe](./no-such-page-xyz.md)appended[relative] ...:1285[relative] ...:1285unchangedThe control arm is what makes the zero a reading rather than a dead instrument: the same
invocation, on the same file, caught a broken file path and missed a broken fragment.
What the check now decides
A fragment is judged when, and only when, the document it names is markdown in this tree.
Every form was proved to fire by planting one break at a time on the real tree — each
with the disk proof and trap-guarded restore above, against a lit control (the unmutated
tree exits 0), and each producing exactly one finding:
docsrulecontent/docs/api/schema-reference.md#tableschema[anchor] ...:536/docs/...#fragcontent/docs/guide/objectos-integration.mdx[anchor] ...:662diskrulepackages/layout/README.md#registration[anchor] ...:193blob/main/...#fragcontent/docs/guide/data-source.md[anchor] ...:206The relative
./page.md#fragform has no live instance in the tree; it is covered byfixture tests (
reports a cross-file fragment naming a heading the target does not have).The blob-URL row retires a waiver written four header sections up: objectui#3536 said a
self-repo blob URL's
#fragmentwas out of scope because "resolving an anchor meansparsing the target, which is a different gate". This is that gate, so the bullet was
corrected in place rather than left standing.
Left undecidable, deliberately, and stated rather than implied: a fragment on a site
route outside the docs collection (
apps/site/appis TSX, not headings), on a non-markdowntarget, on any other origin, and a
#L42raw-view line reference. Those return noopinion — never a pass dressed as one.
The slug rule is the renderers' own, not this gate's
Both renderers use
github-slugger, one instance per document, walked over the headings inorder so a repeated heading's
-1/-2suffix lines up.content/docsis fumadocs, whichalso honours an explicit
## Heading [#custom-id]; everydisksurface is GitHub, whichhas no such syntax, so that branch is applied to the
docsrule only.The rule is vendored, not imported, and that is forced.
check-doc-links.mjsis one ofthe gates a workflow runs before
pnpm install—node scripts/check-pre-install-import-graph.mjs --listnames it — so its whole static import graph must be node builtins plus repo-relative
modules. A
github-sluggerimport would beERR_MODULE_NOT_FOUNDindocs-links.yml,turning a gate that fails loudly into one that stops running on exactly the markdown-only
PRs it exists for. That gate confirms the new module is clean:
The table is copied byte-for-byte, not re-derived. The tempting shortcut,
/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu, is not equivalent — compared over all 1,112,064non-surrogate code points it disagrees on the
Nodigits (²,¹,¼, whichgithub-slugger strips and
\p{N}keeps) and on every letter Unicode has added since thetable was generated (
ࡰ-Arabic Extended-B, ...). Either direction yields awrong anchor set: a false red on a correct link, or a false green on a dead one. Recorded
in
LICENSE-THIRD-PARTY.md(ISC, Dan Flettre).Proving the copy stays honest — the corpus pin
The equivalence is asserted by measurement, not by reading. The pin test compares:
github-slugger, character by character, over all1,112,064 non-surrogate code points, plus the duplicate-suffix counter;
getTableOfContents, over every file inthe scan surface — 273 files (184
docs, 89disk), zero disagreements.Both truth sources are resolved from the workspace package that declares them, so
neither is a phantom root dependency:
fumadocs-corefromapps/site(the docs rendereritself),
github-sluggerfrom@object-ui/plugin-markdown. One test-only root devDependencywas added,
remark-mdx: without it a JSX element followed by a heading with no blank linebetween them parses as a single HTML block, and four real files here have that shape —
the truth source would have been wrong, not the gate.
That corpus check is also why the flattener's one non-obvious rule is written the way it is:
a
*/_run is emphasis only when it is not intraword. The obvious way (strip them all)disagreed with mdast on two real headings here,
NON_GRID_ROW_CEILINGand anew_windowinROADMAP.md.Proving the new tests are not vacuous
A gate that passes because its loop body never runs is the #7070 failure mode, so each new
assertion was ablated — mutation proved on disk by anchored count plus hash movement off the
HEAD blob, restored under a trap and verified by blob equality plus an empty
git diff HEAD.No build step is involved and none was skipped: the test imports
../check-doc-links.mjsand../github-slug.mjsby relative source path, so there is nodist/for a mutation tofail to reach.
toLowerCase()from the vendoredslug()U+41(A); corpus reportedexpected [ …(273) ] to deeply equal []— every file disagreed-1suffix)*/_stripEvery leg proved its mutation on disk (anchored count plus
git hash-objectoff the HEADblob), restored with
git checkout HEAD -- ABSOLUTE_PATH, and verified the restore by blobequality plus
git diff HEADexiting 0. Final tree state after the battery: 0 modifiedfiles. Each leg's lock line read
VERDICT command-exit N— a real run, never a99"never acquired" mistaken for a result.
One prediction missed, reported as a miss. A first pass at the same-page leg predicted a
clean single-occurrence mutation of
](#tableschema); that anchor occurs twice,perlreplaced one, and the no-op guard correctly refused the reading. It also left the file
mutated, which contaminated the next leg — its gate output carried the previous leg's
finding first. Both legs were re-run isolated, with count-based expectations and a
restore between every leg; the table above and the per-form table earlier are from that
clean re-run.
The loops also carry their own lit-instrument assertions, so a zero can never come from an
empty loop: the code-point test asserts
compared === 1_112_064, and the corpus test assertsit saw more than 150
docsfiles and more than 50diskfiles.The backlog: counted from the tree, and it was one
The dispatch expected a shrink-only baseline. Counted rather than assumed: 143 hrefs carry
a non-empty fragment, 142 of them decidable here, and exactly one was dead —
#q3-the-5127-judgement-surfaceindocs/audits/2026-08-zod-to-json-schema-fidelity.md,whose heading reads
## Q3: the objectui#5127 judgement surfaceand therefore slugs toq3-the-objectui5127-judgement-surface. Repaired in the same commit.So this lands fully strict, with no baseline row. A baseline is a ratchet, not a prize,
and there was nothing to ratchet — the objectui#3572 shape, a check arriving with its
backlog already paid.
Gates run locally
Derived by hand from
package.jsonand.github/workflows/(this repo has noscripts/pm/dispatch-gates.mjs; that script lives inobjectstackand answers only aboutits own tree). All at
b780539d, each verdict quoted from the gate itself,exit codes captured by redirect before any pipe:
node scripts/check-doc-links.mjsLinks are valid across 17 scan roots.(exit 0)pnpm exec vitest run scripts/__tests__/check-doc-links.test.tsTests 121 passed (121)node scripts/check-pre-install-import-graph.mjsOK - 23 pre-install step(s) in 18 job(s) run 21 scripts/ gate(s); 24 module(s) walked, every non-relative leaf a node builtin.node scripts/check-control-bytes.mjsOK (scanned 6247 tracked text file(s); skipped 85 binary).node scripts/check-lint-coverage.mjslint coverage: 46/46 packages linted, 0 with outstanding errors (0 total).node scripts/check-type-check-coverage.mjstype-check coverage: 45/46 via type-check ... 1 not compiled.node scripts/check-phantom-dependencies.mjsEvery in-scope import is declared by the package that publishes it.node scripts/check-changeset-presence.mjsNo source or published contract of a released package changed in this range, so no changeset is owed.node scripts/check-changeset-fixed.mjsAll workspace packages are in the changeset fixed group.pnpm run type-check:scripts(tsc -p tsconfig.scripts.json)pnpm exec eslint --no-inline-configon the three changed script filesRepo-wide
pnpm lintwas not run — that is CI's, and this diff's only linted files arethe three named above, which
eslint --no-inline-configpasses.Changeset: none owed, and the gate says so rather than me —
No source or published contract of a released package changed in this range, so no changeset is owed.Nothing here is published source:scripts/,docs/, a rootdevDependency and the lockfile. Per AGENTS.md the
skip-changesetlabel reads nothing andexempts nothing in this repo, so it is not applied.
Clause-2 determination
No, independently reached and agreeing with the dispatching seat.
scripts/is not agoverned surface here, and this moves no schema key, no export, and nothing about what a
contract accepts or rejects. The one behavioural change is a repo-internal gate becoming
stricter. This can land through the merge queue.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
Generated by Claude Code