feat(ci): resolve docs #anchor links against real headings (#7484) - #7841
Merged
Conversation
`Check Documentation Links` sets `include_fragments = "none"` in `lychee.toml`, so lychee resolves a link's FILE and never its `#anchor` — measured with the pinned 0.24.2 under the CI argv, a link to a heading that does not exist is reported `[200] OK`. A cross-file anchor was a two-sided invariant with no owner: rename the heading, leave the inbound link, ship green. `scripts/check-doc-anchors.mjs` owns the fragment half. It extracts every internal `#fragment` from `content/**`, `README.md` and `ARCHITECTURE.md` and resolves it against the destination page's heading ids, computed with `github-slugger` — the same package `fumadocs-core@16.14.0` renders the site with — so the gate and the renderer cannot disagree about a slug. The text handed to the slugger is a regex approximation of fumadocs' `flattenNode`, so the equivalence is measured, not asserted: diffed against the real pipeline (remark-parse + remark-mdx + remark-gfm + remark-frontmatter + fumadocs-core's own `remarkHeading`) over 397 files and 6955 headings, zero disagreements. The two mismatches that sweep found were the same bug — markup inside an inline code span read as markup — and both are pinned in `--self-test`. The fallout was 4 dead anchors out of 200 internal fragment links, all fixed here, so the gate lands with no baseline and no allowlist: content/docs/automation/flows.mdx:240 #notify -> #node-types content/docs/concepts/metadata-lifecycle.mdx:77 #overlay-whitelist -> #overlay-whitelist-shared-db-tenancy-invariant content/docs/permissions/authentication.mdx:75 cli#os-login--json-... -> cli#os-login---json-... content/docs/protocol/kernel/http-protocol.mdx:764 client-sdk#clientdata--crud-operations -> #clientdata--crud--batch `include_fragments` is deliberately NOT flipped: it is a global enum that cannot be scoped to internal links, so it would import external-anchor checking (which needs the remote page fetched — the one thing the `--offline` lane exists to never do), and it cannot know how Fumadocs slugs a heading. `lychee.toml` and `check-links.yml` now record that, and stop reading as "anchors are fine" to the next author. Refs #7484
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
huangyiirene
marked this pull request as ready for review
August 11, 2026 20:39
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 #7484
Premise, re-verified live
lychee.toml:37still readsinclude_fragments = "none". The card's measurement holds:Check Documentation Linksresolves a link's file and never its#anchor, so a link to a heading that does not exist ships[200] OK. A cross-file anchor was a two-sided invariant with no owner — rename the heading, leave the inbound link, ship green.Step 1 — the breakage count (measured first, as the card demanded)
The card is explicit that it proved the gate is blind, ⛔ not that the corpus is clean. Those are different claims, so the first thing this PR did was establish the second one, using the repo's own
github-slugger@2.0.0:content/**= 397, plusREADME.md,ARCHITECTURE.md)#fragmentlinks4 is small, so the fallout is cleared in this same change and the gate lands RED by default — no
warn-not-redstage, no baseline, no allowlist. (The triage promotion said to stage it only if the fallout is large.) A gate that ships with an allowlist teaches that the allowlist is where new failures go.The four, and what each became
content/docs/automation/flows.mdx:240#notify#node-types—notifyis a row in the Node Types table, not a headingcontent/docs/concepts/metadata-lifecycle.mdx:77#overlay-whitelist#overlay-whitelist-shared-db-tenancy-invariantcontent/docs/permissions/authentication.mdx:75/docs/deployment/cli#os-login--json-is-ndjson--the-one-exception…#os-login---json-is-ndjson--the-one-exceptioncontent/docs/protocol/kernel/http-protocol.mdx:764/docs/api/client-sdk#clientdata--crud-operations…#clientdata--crud--batchThose are the only four
content/**prose edits in this PR. Three of them are the exact shape the card predicted: an anchor written from the heading a reader sees, one dash off from the heading the slugger produces.Step 2 — route: the dedicated checker (card direction 2), not the
include_fragmentsflipscripts/check-doc-anchors.mjs+pnpm check:doc-anchors, wired as a required step inLint & Type Check.The card asked for the flip's rationale to be verified rather than guessed. It holds, and there are two further reasons the flip is the worse trade:
include_fragmentscannot be scoped to internal links. It is a global enum inlychee.toml; it does not distinguish an anchor on a page in this repo from an anchor on someone else's website. The second class needs the remote page fetched and parsed — the one thing the--offlinelane exists to never do. That asymmetry is almost certainly why the key reads"none"today, and turning it on to buy the internal check imports the external noise with it.fumadocs-mdx, whoseremarkHeadingassigns ids withgithub-sluggerand honours a[#custom-id]suffix. A checker that computes ids any other way can disagree with the site — the same class of defect as not checking at all, only louder.node+ one dependency-free package.So
include_fragmentsstays"none", and bothlychee.tomlandcheck-links.ymlnow say why and name the gate that owns fragments — so a green lychee run stops reading as "the anchors are fine" to the next author. (That misreading is not hypothetical: it is the falsified PM patrol criterion the card was filed over.)github-sluggeris the single slug authority — and the equivalence is measuredgithub-slugger@2.0.0is not a lookalike: it is literally the packagefumadocs-core@16.14.0imports indist/mdx-plugins/remark-heading.js, and the lockfile already pinned one copy workspace-wide (this PR adds a root devDependency at that exact version; the lockfile diff is 3 lines and resolves to the same copy). OneSluggerper file, fed in document order, so the duplicate-heading-1/-2counters line up too.What the script does have to reproduce is the text handed to the slugger — fumadocs passes
flattenNode(heading), an inline-mdast walk, and this file is a regex over lines. So that equivalence was measured, not asserted:flattenHeadingTextwas diffed against the real pipeline (remark-parse+remark-mdx+remark-gfm+remark-frontmatter+ fumadocs-core's ownremarkHeading) over the whole corpus.That sweep found 2 mismatches on the first pass, both the same bug — markup inside an inline code span read as markup, in
`<ObjectChart>`and`OS_<NAMESPACE>_<KEY>`. Fixed by masking code spans first and restoring them last; both are pinned in--self-testso the regression fails there rather than on someone else's PR.What
--self-testprovokesSlug parity on the two real mismatches · code span + em dash · intraword
_staying literal (snake_case) · link text contributing, href not · ATX closing##· fumadocs[#custom-id], and a[#…]inside a code span not being one · duplicate-heading counters · headings inside fences and frontmatter ignored · extraction skipping code spans, fences and external links · and end-to-end on a synthetic corpus that a dead anchor is reported and an unresolvable page is its own class. Plus a census guard: a sweep that finds zero fragment links fails, because "checked nothing, reported green" is the disease this card is about.Live smoke test of the actual #7484 scenario: renaming
## Overlay whitelist (shared-DB tenancy invariant)and touching nothing else now turns the gate red on the inbound link.Scope notes
lint.yml, notcheck-links.yml: the check needs a workspace install (that job is deliberately a checkout plus two dependency-freenodechecks), and it is required where that lane is advisory.#is not this gate's business — two gates with two resolvers disagreeing about one link is worse than one gate. A link with a#whose page does not resolve is reported here, as its own class: skipping it would silently drop the anchor from coverage. 0 today.check-empty-changesetclosed the empty-frontmatter route — this needs theskip-changesetlabel (PM).26inpackages/objectql/src/plugin.ts:76andpackages/metadata-protocol/src/protocol.ts:2542(live registry holds 38) is carried by finding: two code comments still say 26 shared AUTHORING_RULES — the live registry has 38 #7491, untouched here.Draft on purpose: CI convergence, flipping to ready, and merge are the PM's.
Generated by Claude Code