verify-md-links validates fragment anchors, closing the last dead-link class
verify-md-links 校验 fragment 锚点,消除最后一类死链
`verify-md-links` proved a relative link's target file exists but never looked at the `#fragment`, and the documentation standard compensated with a manual rule: grep anchors yourself before renaming a heading. A corpus sweep found 15 links whose fragments named no anchor in their target — three distinct decay modes: a heading reworded after the link was written (`#security-and-authority-are-explicit-non-goals` vs th
English
Problem
verify-md-links proved a relative link's target file exists but never looked at the #fragment, and the documentation standard compensated with a manual rule: grep anchors yourself before renaming a heading. A corpus sweep found 15 links whose fragments named no anchor in their target — three distinct decay modes: a heading reworded after the link was written (#security-and-authority-are-explicit-non-goals vs the note's current Security and authority are non-goals), a contract relocated to a different owning document (tool-fs linking the seam README for the no-timeout rule that now lives in the group README), and zh pair sides linking English slugs their Chinese headings never produce (#deferred-work against ## 推迟工作). None of these fail any gate, and each silently strands the reader at the top of the target page.
Decision
verify-md-links now resolves fragments too (superseding the deferred scope cut in the cross-link decision). For every relative link whose target is a Markdown file — same-file #anchor links included, which the old checker skipped entirely — the fragment must name a real anchor in the target: a heading's GitHub slug or an explicit <a id> in real HTML flow (code samples and commented-out anchors register nothing). Slugs are computed from the RENDERED heading text via the repository's own markdownHeadingLines, so links, inline code, and emphasis inside a heading slug as GitHub renders them; underscores survive (#showcase-web_fetch); repeated slugs get GitHub's occupied-set -1, -2, … suffixes; matching is exact-case, since element ids are case-sensitive. Fragments onto non-Markdown targets (file.ts#L10) carry renderer-owned semantics and stay out of scope, as do external and root-absolute URLs. Anchor sets are collected lazily for any existing target (anchorCache), so links INTO archived notes and vendor documents are validated without making those files sources.
The slug function differs from gen-cordis-catalog's region-anchor slugger (which drops underscores): the generator's headings are always reachable through its explicit <a id> anchors, so the two need not share one rule. Chinese pair sides follow the existing repository convention (docs/glossary.zh.md, docs/cordis-primer.zh.md): keep the English fragment in the link and place an explicit <a id> before the Chinese heading, so both language sides expose identical anchors.
The 15 broken fragments are fixed in the same change: stale slugs retargeted to the current headings, the relocated no-timeout contract now linked at its owning group README, and four zh documents given explicit anchors. docs/AGENTS.md and the dsh-doc-standards skill no longer prescribe the manual anchor grep for Markdown links; it survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown (today's three all render into scanned pages, so the gate covers them through the committed output).
Verification
scripts/verify-md-links.spec.ts proves the acceptance paths: rendered-text slugging (backticks, punctuation, a linked heading, kept underscores), occupied-set repeat suffixes, <a id> ignored inside fences/inline code/comments, a resolving mixed-link document, dead same-file and cross-file fragments, a case-variant fragment, and a missing target still reported as target rather than anchor. The gate runs over the full corpus in doc-sync (verify-md-links) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode.
Alternatives considered
- Keep the manual-grep rule. It demonstrably did not hold: the 15 fragments decayed under a gate-driven maintenance culture, because heading rewrites happen in PRs that never look at inbound links. A mechanical invariant belongs in an executed gate.
- Point zh links at Chinese-slug anchors. GitHub slugs CJK headings fine, but the corpus convention is already explicit
<a id>+ English fragments (glossary, primer), which also survives renderers that strip non-ASCII; adopting a second convention would split the corpus. - Share
githubSlugwith the typert generator. A one-function import would couple a doc gate to a package build, and the two rules genuinely differ (the generator strips underscores; its anchors are explicit<a id>s the gate reads directly), so divergence is by design, not drift. - Validate VitePress slugs as well. The published site's dead-link check already runs in
website:build; generated regions carry explicit anchors precisely so the two renderers agree, and hand headings that diverge would fail there.
Consequences
Renaming a heading now breaks the build wherever a Markdown link cites its anchor, instead of stranding readers; authors fix the inbound links in the same change, exactly as they already must for file renames. Same-file anchors are no longer a blind spot, so zh pages must anchor any English fragment they use. The manual pre-rename grep survives only for anchors cited from TypeScript strings whose output never reaches gate-scanned Markdown.
中文
问题
verify-md-links 只证明相对链接的目标文件存在,从不检查 #fragment,文档标准以一条人工规则补偿:重命名标题前自己 grep 锚点。一次语料扫描发现 15 条链接的 fragment 在目标中没有对应锚点——三种衰变模式:链接写下后标题被改写(#security-and-authority-are-explicit-non-goals 对 note 现在的 Security and authority are non-goals)、约定搬迁到另一份属主文档(tool-fs 链到 seam README,而无超时规则现居 group README)、zh 侧链接其中文标题永远不会生成的英文 slug(#deferred-work 对 ## 推迟工作)。这些都不触发任何 gate,且每条都把读者悄悄丢在目标页顶部。
决策
verify-md-links 现在也解析 fragment(取代跨链接决策中暂缓纳入该检查范围的决定)。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 #anchor 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug,或真实 HTML 流中的显式 <a id>(代码示例与注释掉的锚点不注册任何东西)。slug 由仓库自有的 markdownHeadingLines 从渲染后的标题文本计算,因此,标题内的链接、行内代码与强调都会按 GitHub 的渲染结果参与 slug 计算;下划线保留(#showcase-web_fetch);重复 slug 获得 GitHub 的占用集 -1、-2……后缀;匹配区分大小写,因为元素 id 本就区分大小写。指向非 Markdown 目标的 fragment(file.ts#L10)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集(anchorCache),因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
slug 函数与 gen-cordis-catalog 的区块锚点 slugger 不同(后者丢弃下划线):生成器的标题总能通过其显式 <a id> 锚点到达,两者无需共享一条规则。中文侧沿用既有语料惯例(docs/glossary.zh.md、docs/cordis-primer.zh.md):链接保留英文 fragment,在中文标题前放置显式 <a id>,使两个语言侧暴露相同的锚点。
15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时约定改链其属主 group README,四份中文文档补上显式锚点。docs/AGENTS.md 与 dsh-doc-standards skill 不再要求为 Markdown 链接手工 grep 锚点;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(当下三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。
验证
scripts/verify-md-links.spec.ts 证明各验收路径:渲染文本 slug 化(反引号、标点、含链接标题、保留下划线)、占用集重复后缀、围栏/行内代码/注释中的 <a id> 不注册、全部可解析的混合链接文档、死的同文件与跨文件 fragment、大小写变体 fragment、以及缺失目标仍报 target 而非 anchor。gate 在 doc-sync 中跑完整语料(verify-md-links),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
曾考虑的替代方案
- 保留人工 grep 规则。 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
- 让中文链接指向中文 slug 锚点。 GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式
<a id>+ 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。 - 与 typert 生成器共享
githubSlug。 为一个函数引入包构建耦合不值得,且两条规则确实不同(生成器剥离下划线;其锚点是 gate 直接读取的显式<a id>),分歧是设计使然而非漂移。 - 同时校验 VitePress slug。 发布站点的死链检查已在
website:build中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
后果
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留。