S192 — make the docs link gate actually validate heading anchors (it never did) - #96
Merged
Conversation
`ignoreDeadLinks: false` validates internal PAGE PATHS only. Measured with
four mutations, each built and each restored by file copy + md5sum:
| link | npm run docs:build |
|---------------------------------------------------------|----------------------|
| /reference/s192-no-such-page | exit 1, 1 dead link |
| /reference/api#s192-no-such-anchor (page has 109 h's) | exit 0 - PASSES |
| #s192-no-such-anchor | exit 0 - PASSES |
| /reference/s192-no-such-page#s192-no-such-anchor | exit 1, 1 dead link |
The fourth case rules out "a # suppresses checking of the whole link": the
page half IS validated and the fragment is discarded from the report. Both
zeros came from the same insertion point in the same file that produced the
reds, so they are not an artifact of the probe not being rendered.
`.claude/rules/docs-authoring.md` claimed a bad "heading anchor (including
cross-page page#heading-slug)" failed the build. It did not. Rather than
shrink the doc, this makes the gate honest.
scripts/check-anchor-links.mjs runs after `vitepress build` and compares every
on-site <a href> fragment in docs/.vitepress/dist against the id= attributes
actually rendered on the target page. Reading the built HTML instead of the
markdown means it never re-implements VitePress's slugify rules and cannot
drift from them, and makes the link side strictly broader than the rule it
enforces. It exits non-zero rather than reporting success if it scanned
nothing: no dist, no HTML, or no fragment-bearing link at all.
Enabling it flagged 78 pre-existing dead anchors across 22 pages, ALL FIXED
here - not baselined. Two slugify rules caused nearly all of them:
## 1. Overview -> id="_1-overview" (leading underscore)
## Fixing a single item's match -> id="fixing-a-single-item-s-match"
## Hub & Arr integration -> id="hub-arr-integration"
Where the generated id contained an em/en dash the target heading got an
explicit ASCII {#anchor} instead of encoding the dash into every inbound link
(4 headings, which fixed 13 links across 10 files in one edit each). Two links
pointed at sections that do not exist at all: admin/backup.md wanted an
admin-spa "backup page" section (that page has 1-8, 14, 16-18) and
plugins/install-from-url.md wanted a developer-guide FAQ section.
tests/anchor-gate.test.ts is the anti-neutering guard, since a green build is
not proof the gate ran. It asserts docs:build still invokes the script, and
runs the real script against synthetic fixture sites to prove both verdicts:
clean -> exit 0; one bad same-page anchor -> exit 1; one bad cross-page anchor
-> exit 1. It also pins the four cannot-run paths and the code-sample id case.
14 new tests, 70 -> 84.
The workflow is unchanged: the gate lives inside the `docs:build` script, so
both the `Build Docs` job (still deliberately without an `if:`) and the
`Deploy Docs` job get it, and a bad anchor now blocks publishing too.
Enforcement is ADVISORY - this repo has no branch protection, so a red check
is visible on the PR but does not itself block a merge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Not up to standards ⛔🔴 Issues
|
| Category | Results |
|---|---|
| UnusedCode | 2 medium |
| ErrorProne | 61 medium |
| Security | 2 critical 13 high |
| Complexity | 2 medium |
🟢 Metrics 81 complexity · 0 duplication
Metric Results Complexity 81 Duplication 0
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
…w-up)
`candidates.set(file + '<NUL>' + href, …)` was written with a literal NUL
control byte instead of an escape. One NUL is enough for git to classify the
whole file as binary: `git diff --numstat` reported `- -` and GitHub renders
"Binary file not shown".
For a gate script the entire point of which is to be reviewable, that makes
every future change to it invisible to review — including a change that
weakens it. That is precisely the failure class S192 exists to close, so it is
a real defect in the gate, not a cosmetic one.
The separator is now `JSON.stringify([file, href])` rather than any single
character. JSON escaping is injective, so two distinct (file, href) pairs
cannot collapse into one key whatever characters a path or an href contains —
collision-freedom by construction instead of by betting no path ever contains
the chosen separator. A colliding key would silently drop a link from the
check, which is the one failure this gate must never have; a comment says so,
because "simplify" is the obvious wrong edit here.
Verified the swap changed nothing else:
clean build EXIT=0, 159 page(s), 3591 unique #fragment link(s), 0 dead
(3591 is byte-identical to the pre-change count, so neither the
old NUL key nor the new JSON key was colliding)
same-page EXIT=1 Dead anchor #s192-no-such-anchor in
docs/reference/cli.md:507
cross-page EXIT=1 Dead anchor /phlix-docs/reference/api.html#s192-no-such-anchor
npm test 84/84
`git diff --numstat origin/master` now reports `271 0` instead of `- -`.
Both docs mutations were restored by file copy + md5sum, never via git.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
S192 — the docs dead-link gate validated page paths ONLY
.claude/rules/docs-authoring.mdclaimedignoreDeadLinks: falsefails the build on a badinternal link "or heading anchor (including cross-page
page#heading-slug)". It does not.Each half of that sentence was mutated separately, built, and restored by file copy +
md5sum.docs/reference/cli.md)npm run docs:buildon master/reference/s192-no-such-pageFound dead link … · 1 dead link(s) found/reference/api#s192-no-such-anchor(page has 109 headings)#s192-no-such-anchor/reference/s192-no-such-page#s192-no-such-anchorThe fourth row kills the competing explanation that a
#suppresses checking of the wholelink — it does not. The page half is validated and the fragment is discarded from the
report; the fragment half is never validated in either form.
Control on the two zeros: both
exit 0results came from the same insertion point in thesame file that produced the two reds. So the zero is not an artifact of the probe not being
rendered or the file not being scanned.
S192's premise is confirmed, not disproved.What changed
Rather than shrink the doc, this makes the gate honest.
scripts/check-anchor-links.mjsruns aftervitepress buildand compares every on-site<a href>fragment indocs/.vitepress/distagainst theid=attributes actually rendered onthe target page.
rules and cannot drift from them — and the link side is strictly broader than the rule it
enforces (it sees every
<a href>that ships, not only what a markdown regex would find).dist, no HTML, or no fragment-bearing link at allis a non-zero exit saying the gate could not run, not a pass.
basecomes fromconfig.tsitself (Node 24 imports the TS directly), so it cannot disagreewith the site.
It is wired inside the
docs:buildnpm script, so the workflow is untouched:Build Docskeeps its deliberate absence of an
if:,Deploy Docskeeps its branch guard, and a bad anchornow also blocks publishing.
npm run check:anchorsruns it standalone.Pre-existing backlog: 78 dead anchors, 22 pages — all FIXED, none baselined
Enabling the gate flagged 78 already-broken anchors. A baseline of 78 would be a gate that
proves nothing, so they are fixed. Three slugify rules caused nearly all of them:
ASCII
{#anchor}rather than encoding the dash into every inbound link — 4 headings, whichcorrected 13 links across 10 files without touching those files.
admin/backup.mdwanted anadmin-spa"backup page" section (that page has 1–8, 14, 16–18) andplugins/install-from-url.mdwanted adeveloper-guideFAQ section.Provably failable, and it detects its own removal
A green build is not proof the gate ran, so
tests/anchor-gate.test.ts(14 tests, 70 → 84)runs the real script against synthetic fixture sites. Verified by mutation on the committed tree:
docs/docs:buildexit 1 —Dead anchor #s192-no-such-anchor in docs/reference/cli.md:507docs/docs:buildexit 1 —Dead anchor /phlix-docs/reference/api.html#s192-no-such-anchordocs:buildexit 1 — still1 dead link(s) founddocs:buildexit 0 —0 dead anchor(s) founddocs:buildnpm testred, 2 failedif (true) continue)npm testred, 4 failednpm testred, 11 failedEvery mutation was restored by file copy +
md5sum -c, never via git.Notes
Docs / Build Docsis visible on the PR but does not itself block a merge. No AC here claims otherwise.
.claude/is gitignored inphlix-docs(.gitignore:7), so.claude/rules/docs-authoring.mdis untracked and local-only— a correction there would be invisible to CI and to every clone. (
phlix-serverandphlix-contractsdo track.claude/;phlix-docs,phlix-uiandphlix-hubdo not.) Thelocal rule file has been corrected in the working tree, but the committed statement of the
gate's scope lives in
README.mdand in the script's header, which are version-controlled.Whether to start tracking
.claude/in this repo is a separate decision and is not made here.🤖 Generated with Claude Code