Skip to content

feat(ci): resolve docs #anchor links against real headings (#7484) - #7841

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7484-doc-anchor-gate
Aug 11, 2026
Merged

feat(ci): resolve docs #anchor links against real headings (#7484)#7841
huangyiirene merged 1 commit into
mainfrom
claude/issue-7484-doc-anchor-gate

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes #7484

Premise, re-verified live

lychee.toml:37 still reads include_fragments = "none". The card's measurement holds: Check Documentation Links resolves 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:

source files swept 399 (content/** = 397, plus README.md, ARCHITECTURE.md)
internal #fragment links 208
pointing at a heading that does not exist 4
fragment links whose page does not resolve 0

4 is small, so the fallout is cleared in this same change and the gate lands RED by default — no warn-not-red stage, 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

where was now
content/docs/automation/flows.mdx:240 #notify #node-typesnotify is a row in the Node Types table, not a heading
content/docs/concepts/metadata-lifecycle.mdx:77 #overlay-whitelist #overlay-whitelist-shared-db-tenancy-invariant
content/docs/permissions/authentication.mdx:75 /docs/deployment/cli#os-login--json-is-ndjson--the-one-exception …#os-login---json-is-ndjson--the-one-exception
content/docs/protocol/kernel/http-protocol.mdx:764 /docs/api/client-sdk#clientdata--crud-operations …#clientdata--crud--batch

Those 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_fragments flip

scripts/check-doc-anchors.mjs + pnpm check:doc-anchors, wired as a required step in Lint & 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:

  1. include_fragments cannot be scoped to internal links. It is a global enum in lychee.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 --offline lane 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.
  2. lychee does not know how Fumadocs slugs a heading. Our pages are MDX compiled by fumadocs-mdx, whose remarkHeading assigns ids with github-slugger and 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.
  3. It is not runnable locally without a from-source Rust build; this is node + one dependency-free package.

So include_fragments stays "none", and both lychee.toml and check-links.yml now 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-slugger is the single slug authority — and the equivalence is measured

github-slugger@2.0.0 is not a lookalike: it is literally the package fumadocs-core@16.14.0 imports in dist/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). One Slugger per file, fed in document order, so the duplicate-heading -1/-2 counters 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: flattenHeadingText was diffed against the real pipeline (remark-parse + remark-mdx + remark-gfm + remark-frontmatter + fumadocs-core's own remarkHeading) over the whole corpus.

397 files, 6955 headings, 0 disagreements.

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-test so the regression fails there rather than on someone else's PR.

What --self-test provokes

Slug 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

  • Placement. lint.yml, not check-links.yml: the check needs a workspace install (that job is deliberately a checkout plus two dependency-free node checks), and it is required where that lane is advisory.
  • File existence stays lychee's. A link with no # 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.
  • Changeset: none. No publishable package changes, and check-empty-changeset closed the empty-frontmatter route — this needs the skip-changeset label (PM).
  • Out of scope, report-don't-fix: the stale rule count 26 in packages/objectql/src/plugin.ts:76 and packages/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

`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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 8:14pm

Request Review

@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation ci/cd dependencies Pull requests that update a dependency file labels Aug 11, 2026
@huangyiirene huangyiirene added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 11, 2026 — with Claude
@huangyiirene
huangyiirene marked this pull request as ready for review August 11, 2026 20:39
@huangyiirene
huangyiirene added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 6fe580b Aug 11, 2026
29 of 30 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7484-doc-anchor-gate branch August 11, 2026 20:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants