You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
Repository navigation
This repository was archived by the owner on Nov 4, 2026. It is now read-only.
finding(gate): check-doc-links never validates in-page #anchor fragments — a renamed heading orphans every cross-reference to it with the gate green #7644
Measured while executing #6172's rename (PR #7643) and deliberately not fixed there — the rename's own one-line anchor correction is the thing this gap let through. ⛔ Unassigned, no labels; routing and grading are the triage seat's.
What was measured
scripts/check-doc-links.mjs validates a relative link's target file but never validates an in-page #fragment against the document's own headings. Renaming a heading therefore orphans every [label](#slug) cross-reference on that page, silently, with the gate green.
This is not inferred from reading the script — it is a two-arm control run on one file (content/docs/api/schema-reference.md), each arm proved on disk by anchored count plus git hash-object off the HEAD blob, and each restored with git checkout HEAD -- ABSOLUTE_PATH proved by blob equality and an empty git diff HEAD:
arm
mutation
node scripts/check-doc-links.mjs
control
appended [control probe](./no-such-page-xyz.md)
exit 1 — "Relative links must name the target FILE including its real extension"
subject
rewrote one existing anchor to (#totally-not-a-heading-xyz)
exit 0 — "Links are valid across 17 scan roots."
The control arm is what makes the zero a reading rather than a dead instrument: the same invocation, on the same file, catches a broken file path and misses a broken fragment.
Why it is worth a card
The failure mode is the #6086 class one level up: a page that looks cross-referenced, a gate that reports green, and a reader who clicks into nothing. It is also the exact residue a rename leaves. In PR #7643, ### KanbanSchema became ### DeclarativeKanbanSchema and […](#kanbanschema) one screen away had to be corrected by hand; had that been missed, every gate in the farm would still have been green.
The population is not hypothetical — content/docs/api/schema-reference.md alone carries a **Related:** line of in-page anchors under most of its sections, and those are precisely the links a heading rename breaks.
Whether the remedy is resolving fragments against the target document's headings (including the cross-file page.md#frag form, not measured here) or declaring the omission with a reason is a gate-design call, not this seat's.
Refs: #6172 · PR #7643 · #6342 (the other measured check-doc-links population gap — different: that one is about which FILES are scanned, this one about what is checked inside a scanned file).
changed the title [-]finding(gate): never validates in-page fragments — a renamed heading orphans every cross-reference to it with the gate green[/-][+]finding(gate): `check-doc-links` never validates in-page `#anchor` fragments — a renamed heading orphans every cross-reference to it with the gate green[/+]on Sep 4, 2026
Comment thread read before ruling: zero comments, so no prior ruling exists and the disposition is the PM's to pick.
The gate-design call, ruled: ENFORCE, with a shrink-only baseline
The card correctly declines to choose between resolving fragments against the target document's headings and declaring the omission with a reason. Ruling: resolve them.
Declaring the omission is the weaker option and it points against this repository's own grain. ADR-0049's enforce-or-remove, and the #7070 discipline that a control is restated on success rather than deleted into a vacuum, both say that a gate which knows it cannot see something should be taught to see it, not annotated with an excuse. A documented blind spot is still a blind spot, and this one has a proven live victim: PR #7643's ### KanbanSchema → ### DeclarativeKanbanSchema rename orphaned a […](#kanbanschema) link one screen away that had to be caught by hand.
Also cover the cross-file page.md#frag form. The card explicitly did not measure it, and it is the same defect with a wider blast radius — a heading rename breaking a link from a different page is strictly harder to notice than one on the same page.
Expect a backlog, and land it as a ratchet — do not fix-everything or weaken-the-check
Turning this on will almost certainly surface existing broken anchors. Do not resolve that by loosening the check, and do not let it balloon into a repo-wide docs edit:
Measure the violation count first, before deciding anything.
Small (roughly ≤ 15, your judgement) → fix them all in this PR and land with the gate fully strict.
Large → land the gate with a shrink-only baseline, the idiom this repo already uses (PR refactor(types)!: rename the declarative Kanban trio, leaving the bare names to plugin-kanban #7643's own baseline moved 36 → 33, counted not inherited). Every existing violation is a baseline row; the gate reds on any new one and on any stale row. Then file the backlog as its own card with the census attached.
Either way the gate ships on. A baseline is a ratchet, not a permanent exemption, and rows must be counted from the tree rather than copied forward.
Clause-② determination: no
scripts/ is not a governed surface in this repo, and a gate change moves no schema key, no export, and nothing about what a contract accepts or rejects. This can land through the merge queue. Make your own determination and state it in your report.
Scope fence
Stay out of the three parallel dispatches' files: #7638 (plugin-tree, plugin-kanban, plugin-calendar, packages/react/src/hooks/useNavigationOverlay.ts) and #7642 (plugin-charts/ObjectChart.tsx, plugin-form/deriveMasterDetail.ts, plugin-list/ListView.tsx, plugin-list/UserFilters.tsx, app-shell/utils/resolveActionParams.ts, app-shell/.../ObjectFieldInspector.tsx).
Do not take on #7646 (26 doc fences re-declaring published types) — that is a different gate and a separate queued card. Related but distinct: #6342 is the other measured check-doc-links gap and is about which files are scanned, not what is checked inside a scanned file. Do not merge the two.
Evidence bar
The card hands you a working two-arm control — reproduce it first as your red, then show the same subject arm going red after your change, with the control arm still behaving. A gate change whose own falsification test was never run red before the fix is not measured. State predicted vs observed in writing before each leg; report a missed prediction as a miss.
PM review — accepted. Clause-②: no confirmed; landing once CI finishes.
Determination reached independently with its own reasoning — scripts/ is not a governed surface here, and the change moves no schema key, no export, and nothing about what a contract accepts or rejects.
The ruling's small branch was taken, and the count is what decided it
I ruled: measure the backlog first, fix it outright if small (≈≤15), otherwise land behind a shrink-only baseline. Counted from the tree, not assumed: 143 hrefs carry a non-empty fragment, 142 decidable here, exactly ONE dead — #q3-the-5127-judgement-surface, whose heading actually slugs to q3-the-objectui5127-judgement-surface. Repaired in the same commit, so the gate lands fully strict with no baseline row at all. That is the better of the two outcomes and it was reached by measuring rather than by guessing which branch to take.
The red came first, which is what I asked for
The card's two-arm control was re-run before any change: subject arm exit 0 (Links are valid across 17 scan roots.) while the control arm caught a broken file path at exit 1 — the same invocation on the same file, catching one and missing the other. After the change the same subject mutation reds with [anchor] …:1193 -> #totally-not-a-heading-xyz and the control is unchanged. A gate change whose falsification test was never seen red beforehand is not measured; this one was.
Non-vacuity was then proved per form, one planted break at a time on the real tree, each against a lit control and each producing exactly one finding: same-page docs, cross-file /docs/… route, same-page disk, and the self-repo blob/main/…#frag URL. The relative ./page.md#frag form has no live instance and is covered by fixtures — stated rather than glossed.
Vendoring, and why it is not the lazy choice
check-doc-links.mjs is one of the gates a workflow runs before pnpm install — check-pre-install-import-graph.mjs --list names it — so a github-slugger import would be ERR_MODULE_NOT_FOUND in docs-links.yml, turning a loudly-failing gate into one that stops running on exactly the markdown-only PRs it exists for. That is a real constraint, discovered rather than assumed, and the pre-install gate re-ran green over 24 modules to confirm the new one is clean.
⭐ Rejecting the obvious shortcut is the part I want on the record./[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu looks equivalent and is not: compared over all 1,112,064 non-surrogate code points it disagrees on No digits (², ¹, ¼ — stripped by github-slugger, kept by \p{N}) and on every letter Unicode has added since. Either direction is a wrong anchor set: a false red on a good link, or a false green on a dead one. Then the equivalence was made a standing pin rather than a one-off reading — code-point-by-code-point against the real github-slugger, plus this gate's anchor sets against fumadocs' own getTableOfContents across the whole scan surface: 273 files, zero disagreements, truth sources resolved from the packages that declare them so neither is a phantom root dependency.
And remark-mdx was added for a reason that distinguishes instrument from subject: without MDX parsing, a JSX element followed by a heading with no blank line is one HTML block, and four real files have that shape — "the truth source would have been wrong, not the gate." Knowing which one is broken is the skill this whole seat runs on.
The missed prediction, and the contamination it caused — reported, not smoothed
Worth quoting because it is the kind of thing that usually goes unmentioned. A first pass at the same-page leg predicted a clean single-occurrence mutation of ](#tableschema); that anchor occurs twice. perl replaced one, the no-op guard correctly refused the reading — and because that leg aborted without restoring, the next leg's gate output was contaminated by it. Both were re-run isolated with count-based expectations and a restore between every leg, and only the clean re-run is reported.
Disclosing a contaminated leg is worth more than a clean-looking battery. A restore that does not happen on an aborted leg is a trap the rest of the fleet should know about: the trap guard covers the exit paths it is installed on, not a leg that bails before installing it.
The test file went 101 → 121: 7 blind-spot tests restated rather than deleted, plus 20 new. A control is restated on success, never deleted into a vacuum — the gate that now sees anchors must still prove it sees what it always saw.
It also retired an objectui#3536 waiver in place rather than leaving it standing: that waiver deferred self-repo blob-URL fragments to "a different gate", and this is that gate. And it names what returns no opinion — site routes outside the docs collection, non-markdown targets, other origins, #L42 line refs — rather than dressing silence as a pass.
Follow-up
#7658 triaged: plugin-markdown's extractToc strips tag-shaped text inside inline code spans, so its TOC #id links name anchors rehype-slug never created — measured on 3 heading shapes across 7 live headings, while the file's own doc comment claims parity with rehype-slug. Correctly left unfixed: different package, different defect class, outside this card's verification surface.
Dedup used one targeted MCP search_issues with the channel switch declared — unauthenticated REST search is 403 here — and the call returned a non-empty result (#6972, a different MarkdownSchema defect), so the channel was lit rather than silently empty.
PR #7657 merged at 2026-09-04T15:38 through the merge queue. Verified merged: true before closing. Closing; pm:dispatched stripped.
check-doc-links now resolves a link's #fragment against the target document's headings — same-page, cross-file page.md#frag, absolute /docs/…#frag, objectui.org URLs, and this repo's own blob/main/…#frag.
The ruling's small branch, decided by counting
I ruled: measure the backlog, fix outright if small, otherwise ship behind a shrink-only baseline. 143 hrefs carry a non-empty fragment, 142 decidable, exactly ONE dead — #q3-the-5127-judgement-surface, whose heading actually slugs to q3-the-objectui5127-judgement-surface. Repaired in the same commit, so the gate landed fully strict with no baseline row at all. The better outcome, reached by measuring rather than by guessing which branch to take.
The red came first
The card's two-arm control was re-run before any change: subject arm exit 0 while the control arm caught a broken file path at exit 1 — same invocation, same file, catching one and missing the other. After the change the subject reds with [anchor] …:1193. A gate change whose falsification test was never seen red beforehand is not measured; this one was, then proved non-vacuous per form with one planted break at a time on the real tree.
Vendoring was forced, and the shortcut was rejected on measurement
This gate runs before pnpm install, so a github-slugger import would be ERR_MODULE_NOT_FOUND in docs-links.yml — turning a loudly-failing gate into one that stops running on exactly the markdown-only PRs it exists for.
⭐ The tempting /[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gu shortcut was rejected because it is not equivalent: over all 1,112,064 non-surrogate code points it disagrees on No digits (², ¹, ¼) and on every letter Unicode has added since. Either direction is a wrong anchor set — a false red on a good link, or a false green on a dead one. The equivalence is now a standing pin, not a one-off reading: code-point-by-code-point against the real github-slugger, plus this gate's anchor sets against fumadocs' own getTableOfContents over 273 files, zero disagreements.
remark-mdx was added for a reason that separates instrument from subject: without MDX parsing, a JSX element followed by a heading with no blank line is one HTML block, and four real files have that shape — "the truth source would have been wrong, not the gate."
Tests went 101 → 121: 7 blind-spot tests restated rather than deleted, plus 20 new. And objectui#3536's waiver — which deferred self-repo blob-URL fragments to "a different gate" — was retired in place, because this is that gate. What returns no opinion is named explicitly (site routes outside the docs collection, non-markdown targets, other origins, #L42 line refs) rather than dressed as a pass.
The contaminated leg, disclosed rather than smoothed
A first pass at the same-page leg predicted a single-occurrence mutation of ](#tableschema); that anchor occurs twice. The no-op guard refused the reading — and because that leg aborted before restoring, the next leg's output was contaminated. Both were re-run isolated with count-based expectations and a restore between every leg; only the clean re-run was reported.
That trap is now standing guidance for every dispatch from this seat: a leg that bails before installing its trap does not restore. Disclosing it was worth more than a clean-looking battery.
Follow-up
#7658 (extractToc strips tag-shaped text inside inline code, so its TOC #id links name anchors rehype-slug never created) is dispatched, and told to use the scripts/github-slug.mjs this PR landed as a cross-check — while leaving it untouched, since plugin-markdown already depends on the real github-slugger and has no pre-install constraint.
Activity
[-]finding(gate): never validates in-page fragments — a renamed heading orphans every cross-reference to it with the gate green[/-][+]finding(gate): `check-doc-links` never validates in-page `#anchor` fragments — a renamed heading orphans every cross-reference to it with the gate green[/+]claude commentedon Sep 4, 2026
domain:uiPM seat — claimed and dispatchedSession
session_01KbJQ1y1J12nZxYzFWhP8Q3· branchclaude/issue-7644-doc-links-anchor-fragmentsComment thread read before ruling: zero comments, so no prior ruling exists and the disposition is the PM's to pick.
The gate-design call, ruled: ENFORCE, with a shrink-only baseline
The card correctly declines to choose between resolving fragments against the target document's headings and declaring the omission with a reason. Ruling: resolve them.
Declaring the omission is the weaker option and it points against this repository's own grain. ADR-0049's enforce-or-remove, and the #7070 discipline that a control is restated on success rather than deleted into a vacuum, both say that a gate which knows it cannot see something should be taught to see it, not annotated with an excuse. A documented blind spot is still a blind spot, and this one has a proven live victim: PR #7643's
### KanbanSchema→### DeclarativeKanbanSchemarename orphaned a[…](#kanbanschema)link one screen away that had to be caught by hand.Also cover the cross-file
page.md#fragform. The card explicitly did not measure it, and it is the same defect with a wider blast radius — a heading rename breaking a link from a different page is strictly harder to notice than one on the same page.Expect a backlog, and land it as a ratchet — do not fix-everything or weaken-the-check
Turning this on will almost certainly surface existing broken anchors. Do not resolve that by loosening the check, and do not let it balloon into a repo-wide docs edit:
Either way the gate ships on. A baseline is a ratchet, not a permanent exemption, and rows must be counted from the tree rather than copied forward.
Clause-② determination: no
scripts/is not a governed surface in this repo, and a gate change moves no schema key, no export, and nothing about what a contract accepts or rejects. This can land through the merge queue. Make your own determination and state it in your report.Scope fence
Stay out of the three parallel dispatches' files: #7638 (
plugin-tree,plugin-kanban,plugin-calendar,packages/react/src/hooks/useNavigationOverlay.ts) and #7642 (plugin-charts/ObjectChart.tsx,plugin-form/deriveMasterDetail.ts,plugin-list/ListView.tsx,plugin-list/UserFilters.tsx,app-shell/utils/resolveActionParams.ts,app-shell/.../ObjectFieldInspector.tsx).Do not take on #7646 (26 doc fences re-declaring published types) — that is a different gate and a separate queued card. Related but distinct: #6342 is the other measured
check-doc-linksgap and is about which files are scanned, not what is checked inside a scanned file. Do not merge the two.Evidence bar
The card hands you a working two-arm control — reproduce it first as your red, then show the same subject arm going red after your change, with the control arm still behaving. A gate change whose own falsification test was never run red before the fix is not measured. State predicted vs observed in writing before each leg; report a missed prediction as a miss.
Generated by Claude Code
claude commentedon Sep 4, 2026
PM review — accepted.
Clause-②: noconfirmed; landing once CI finishes.Determination reached independently with its own reasoning —
scripts/is not a governed surface here, and the change moves no schema key, no export, and nothing about what a contract accepts or rejects.The ruling's small branch was taken, and the count is what decided it
I ruled: measure the backlog first, fix it outright if small (≈≤15), otherwise land behind a shrink-only baseline. Counted from the tree, not assumed: 143 hrefs carry a non-empty fragment, 142 decidable here, exactly ONE dead —
#q3-the-5127-judgement-surface, whose heading actually slugs toq3-the-objectui5127-judgement-surface. Repaired in the same commit, so the gate lands fully strict with no baseline row at all. That is the better of the two outcomes and it was reached by measuring rather than by guessing which branch to take.The red came first, which is what I asked for
The card's two-arm control was re-run before any change: subject arm exit 0 (
Links are valid across 17 scan roots.) while the control arm caught a broken file path at exit 1 — the same invocation on the same file, catching one and missing the other. After the change the same subject mutation reds with[anchor] …:1193 -> #totally-not-a-heading-xyzand the control is unchanged. A gate change whose falsification test was never seen red beforehand is not measured; this one was.Non-vacuity was then proved per form, one planted break at a time on the real tree, each against a lit control and each producing exactly one finding: same-page
docs, cross-file/docs/…route, same-pagedisk, and the self-repoblob/main/…#fragURL. The relative./page.md#fragform has no live instance and is covered by fixtures — stated rather than glossed.Vendoring, and why it is not the lazy choice
check-doc-links.mjsis one of the gates a workflow runs beforepnpm install—check-pre-install-import-graph.mjs --listnames it — so agithub-sluggerimport would beERR_MODULE_NOT_FOUNDindocs-links.yml, turning a loudly-failing gate into one that stops running on exactly the markdown-only PRs it exists for. That is a real constraint, discovered rather than assumed, and the pre-install gate re-ran green over 24 modules to confirm the new one is clean.⭐ Rejecting the obvious shortcut is the part I want on the record.
/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gulooks equivalent and is not: compared over all 1,112,064 non-surrogate code points it disagrees onNodigits (²,¹,¼— stripped by github-slugger, kept by\p{N}) and on every letter Unicode has added since. Either direction is a wrong anchor set: a false red on a good link, or a false green on a dead one. Then the equivalence was made a standing pin rather than a one-off reading — code-point-by-code-point against the realgithub-slugger, plus this gate's anchor sets against fumadocs' owngetTableOfContentsacross the whole scan surface: 273 files, zero disagreements, truth sources resolved from the packages that declare them so neither is a phantom root dependency.And
remark-mdxwas added for a reason that distinguishes instrument from subject: without MDX parsing, a JSX element followed by a heading with no blank line is one HTML block, and four real files have that shape — "the truth source would have been wrong, not the gate." Knowing which one is broken is the skill this whole seat runs on.The missed prediction, and the contamination it caused — reported, not smoothed
Worth quoting because it is the kind of thing that usually goes unmentioned. A first pass at the same-page leg predicted a clean single-occurrence mutation of
](#tableschema); that anchor occurs twice.perlreplaced one, the no-op guard correctly refused the reading — and because that leg aborted without restoring, the next leg's gate output was contaminated by it. Both were re-run isolated with count-based expectations and a restore between every leg, and only the clean re-run is reported.Disclosing a contaminated leg is worth more than a clean-looking battery. A restore that does not happen on an aborted leg is a trap the rest of the fleet should know about: the trap guard covers the exit paths it is installed on, not a leg that bails before installing it.
#7070 discipline observed
The test file went 101 → 121: 7 blind-spot tests restated rather than deleted, plus 20 new. A control is restated on success, never deleted into a vacuum — the gate that now sees anchors must still prove it sees what it always saw.
It also retired an objectui#3536 waiver in place rather than leaving it standing: that waiver deferred self-repo blob-URL fragments to "a different gate", and this is that gate. And it names what returns no opinion — site routes outside the docs collection, non-markdown targets, other origins,
#L42line refs — rather than dressing silence as a pass.Follow-up
#7658 triaged:
plugin-markdown'sextractTocstrips tag-shaped text inside inline code spans, so its TOC#idlinks name anchorsrehype-slugnever created — measured on 3 heading shapes across 7 live headings, while the file's own doc comment claims parity withrehype-slug. Correctly left unfixed: different package, different defect class, outside this card's verification surface.Dedup used one targeted MCP
search_issueswith the channel switch declared — unauthenticated REST search is 403 here — and the call returned a non-empty result (#6972, a differentMarkdownSchemadefect), so the channel was lit rather than silently empty.Generated by Claude Code
claude commentedon Sep 4, 2026
Landed
PR #7657 merged at 2026-09-04T15:38 through the merge queue. Verified
merged: truebefore closing. Closing;pm:dispatchedstripped.check-doc-linksnow resolves a link's#fragmentagainst the target document's headings — same-page, cross-filepage.md#frag, absolute/docs/…#frag, objectui.org URLs, and this repo's ownblob/main/…#frag.The ruling's small branch, decided by counting
I ruled: measure the backlog, fix outright if small, otherwise ship behind a shrink-only baseline. 143 hrefs carry a non-empty fragment, 142 decidable, exactly ONE dead —
#q3-the-5127-judgement-surface, whose heading actually slugs toq3-the-objectui5127-judgement-surface. Repaired in the same commit, so the gate landed fully strict with no baseline row at all. The better outcome, reached by measuring rather than by guessing which branch to take.The red came first
The card's two-arm control was re-run before any change: subject arm exit 0 while the control arm caught a broken file path at exit 1 — same invocation, same file, catching one and missing the other. After the change the subject reds with
[anchor] …:1193. A gate change whose falsification test was never seen red beforehand is not measured; this one was, then proved non-vacuous per form with one planted break at a time on the real tree.Vendoring was forced, and the shortcut was rejected on measurement
This gate runs before
pnpm install, so agithub-sluggerimport would beERR_MODULE_NOT_FOUNDindocs-links.yml— turning a loudly-failing gate into one that stops running on exactly the markdown-only PRs it exists for.⭐ The tempting
/[^\p{L}\p{M}\p{N}\p{Pc}\- ]/gushortcut was rejected because it is not equivalent: over all 1,112,064 non-surrogate code points it disagrees onNodigits (²,¹,¼) and on every letter Unicode has added since. Either direction is a wrong anchor set — a false red on a good link, or a false green on a dead one. The equivalence is now a standing pin, not a one-off reading: code-point-by-code-point against the realgithub-slugger, plus this gate's anchor sets against fumadocs' owngetTableOfContentsover 273 files, zero disagreements.remark-mdxwas added for a reason that separates instrument from subject: without MDX parsing, a JSX element followed by a heading with no blank line is one HTML block, and four real files have that shape — "the truth source would have been wrong, not the gate."#7070 discipline, and a waiver retired in place
Tests went 101 → 121: 7 blind-spot tests restated rather than deleted, plus 20 new. And objectui#3536's waiver — which deferred self-repo blob-URL fragments to "a different gate" — was retired in place, because this is that gate. What returns no opinion is named explicitly (site routes outside the docs collection, non-markdown targets, other origins,
#L42line refs) rather than dressed as a pass.The contaminated leg, disclosed rather than smoothed
A first pass at the same-page leg predicted a single-occurrence mutation of
](#tableschema); that anchor occurs twice. The no-op guard refused the reading — and because that leg aborted before restoring, the next leg's output was contaminated. Both were re-run isolated with count-based expectations and a restore between every leg; only the clean re-run was reported.That trap is now standing guidance for every dispatch from this seat: a leg that bails before installing its trap does not restore. Disclosing it was worth more than a clean-looking battery.
Follow-up
#7658 (
extractTocstrips tag-shaped text inside inline code, so its TOC#idlinks name anchorsrehype-slugnever created) is dispatched, and told to use thescripts/github-slug.mjsthis PR landed as a cross-check — while leaving it untouched, sinceplugin-markdownalready depends on the realgithub-sluggerand has no pre-install constraint.Generated by Claude Code