Skip to content

S192 — make the docs link gate actually validate heading anchors (it never did) - #96

Merged
detain merged 2 commits into
masterfrom
s192-anchor-gate
Aug 4, 2026
Merged

S192 — make the docs link gate actually validate heading anchors (it never did)#96
detain merged 2 commits into
masterfrom
s192-anchor-gate

Conversation

@detain

@detain detain commented Aug 4, 2026

Copy link
Copy Markdown
Owner

S192 — the docs dead-link gate validated page paths ONLY

.claude/rules/docs-authoring.md claimed ignoreDeadLinks: false fails the build on a bad
internal 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.

mutation (appended to docs/reference/cli.md) npm run docs:build on master
dead page /reference/s192-no-such-page exit 1Found dead link … · 1 dead link(s) found
bad cross-page anchor /reference/api#s192-no-such-anchor (page has 109 headings) exit 0 — PASSES
bad same-page anchor #s192-no-such-anchor exit 0 — PASSES
dead page + anchor /reference/s192-no-such-page#s192-no-such-anchor exit 1, report names the page only

The fourth row kills the competing explanation that a # suppresses checking of the whole
link — 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 0 results came from the same insertion point in the
same 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.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.

  • It reads the built HTML, not the markdown, so it never re-implements VitePress's slugify
    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).
  • It refuses to report a false zero: no dist, no HTML, or no fragment-bearing link at all
    is a non-zero exit saying the gate could not run, not a pass.
  • base comes from config.ts itself (Node 24 imports the TS directly), so it cannot disagree
    with the site.

It is wired inside the docs:build npm script, so the workflow is untouched: Build Docs
keeps its deliberate absence of an if:, Deploy Docs keeps its branch guard, and a bad anchor
now also blocks publishing. npm run check:anchors runs 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:

## 1. Overview                    ->  id="_1-overview"   (leading _ before a digit)  54 cases
## Fixing a single item's match   ->  id="fixing-a-single-item-s-match"
## Hub & Arr integration          ->  id="hub-arr-integration"   (not "hub--arr")
  • Where the generated id contained an em or en dash, the target heading got an explicit
    ASCII {#anchor} rather than encoding the dash into every inbound link — 4 headings, which
    corrected 13 links across 10 files without touching those files.
  • 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.

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:

mutation result
bad same-page anchor in docs/ docs:build exit 1Dead anchor #s192-no-such-anchor in docs/reference/cli.md:507
bad cross-page anchor in docs/ docs:build exit 1Dead anchor /phlix-docs/reference/api.html#s192-no-such-anchor
dead page path (the pre-existing half) docs:build exit 1 — still 1 dead link(s) found
restored docs:build exit 00 dead anchor(s) found
unwire the gate from docs:build npm test red, 2 failed
neuter the verdict (if (true) continue) npm test red, 4 failed
delete the script npm test red, 11 failed

Every mutation was restored by file copy + md5sum -c, never via git.

Notes

  • Enforcement is advisory. This repo has no branch protection, so a red Docs / Build Docs
    is visible on the PR but does not itself block a merge. No AC here claims otherwise.
  • Ownership of the rule file, which S192 flagged as blocking: .claude/ is gitignored in
    phlix-docs (.gitignore:7), so .claude/rules/docs-authoring.md is untracked and local-only
    — a correction there would be invisible to CI and to every clone. (phlix-server and
    phlix-contracts do track .claude/; phlix-docs, phlix-ui and phlix-hub do not.) The
    local rule file has been corrected in the working tree, but the committed statement of the
    gate's scope lives in README.md and 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

`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>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@codacy-production

codacy-production Bot commented Aug 4, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 2 critical · 13 high · 65 medium

Alerts:
⚠ 80 issues (≤ 0 issues of at least minor severity)

Results:
80 new issues

Category Results
UnusedCode 2 medium
ErrorProne 61 medium
Security 2 critical
13 high
Complexity 2 medium

View in Codacy

🟢 Metrics 81 complexity · 0 duplication

Metric Results
Complexity 81
Duplication 0

View in Codacy

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>
@detain
detain merged commit fd77141 into master Aug 4, 2026
2 of 3 checks passed
@detain
detain deleted the s192-anchor-gate branch August 4, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant