Markdown cross-link validity linting
Markdown 交叉链接有效性检查
Docs in this repo link to each other by relative path — `[topic](../implemented/2026-…-….md)`, `[the cookbook](adding-a-tool.md)`, `[architecture.md](../../architecture.md)`. Nothing verified those targets exist. A rename or a move silently breaks every inbound link, and the break is invisible until a reader clicks it. [Doc-sync enforcement](../../archived/process/2026-06-11-doc-sync-enforcement.md) already mechanize
English
Problem
Docs in this repo link to each other by relative path — [topic](../implemented/2026-…-….md), [the cookbook](adding-a-tool.md), [architecture.md](../../architecture.md). Nothing verified those targets exist. A rename or a move silently breaks every inbound link, and the break is invisible until a reader clicks it. Doc-sync enforcement already mechanized two classes of doc drift (uncompilable code blocks, a stale event-taxonomy table) and verify-md-wrap a third (hard-wrapped prose) — but a dead cross-link is a fourth, equally mechanical class that was still verified by eyeball.
The motivating case is the Agent Note tree reorganization that introduced this gate: unifying docs/adr/ + .agents/notes/ into one .agents/notes/ with proposed//implemented//rejected/ subfolders renamed roughly forty inter-doc links by hand. A single fat-fingered path would have shipped a broken link with nothing to catch it.
Decision
A fourth doc-sync gate, verify-md-links (scripts/verify-md-links.ts), mirroring the verify-md-wrap style (tsx ESM, AST-based, verify-don't-generate):
- Parse each in-scope Markdown file with
mdast-util-from-markdown+ GFM and walk everylink,image, anddefinitionnode. - Check a target only when it is a relative path. Skip scheme-qualified URLs (
https:,mailto:, …), protocol-relative (//host), root-absolute (/path— no stable base in a checkout), and pure in-page anchors (#section). Strip any#fragment/?query, resolve the path against the linking file's directory, and assert it exists on disk. - Report and never rewrite; exit non-zero on the first broken link found.
Scope matches the other gates plus the AGENTS.md pair and the repo-authored agent-skill Markdown under .agents/skills/ (those skill files cross-link into the docs tree, so this reorg rewrote links in them too): README.md, docs/**/*.md, packages/*/README.md, AGENTS.md, packages/AGENTS.md, .agents/skills/**/*.md, deduped by real path (the CLAUDE.md symlinks resolve onto the AGENTS.md files). It is wired into doc-sync, so relevant documentation changes and CI exercise the same broken-link check.
The gate now also checks #fragment anchors on Markdown targets — same-file anchors included — against heading slugs and explicit <a id>; the fragment-anchor decision owns that mechanism and the slug rules.
Alternatives considered
Anchor-level validity checking — deferred here as heavier and lower-value (file-level dead links were the failure that had actually bit), leaving authors to verify #fragment anchors themselves. That manual rule did not hold; the fragment-anchor decision later added the check.
Consequences
- Renames and moves that orphan a cross-link fail
doc-syncand CI instead of waiting for a reader to click a dead link. This made the Agent Note reorganization that introduced the gate self-verifying: the check proves none of its own rewritten links dangle. - One more fast tsx script in the
doc-syncchain; no new dependency (the mdast/GFM stack is already in devDependencies forverify-md-wrap). - The convention this enforces — cross-reference docs by machine-checkable relative link, never by bare prose or a number — is documented in docs/AGENTS.md so authors know the gate exists and why.
中文
问题
本仓库的文档通过相对路径互相链接:[topic](../implemented/2026-…-….md)、[the cookbook](adding-a-tool.md)、[architecture.md](../../architecture.md)。此前没有任何机制验证这些目标是否存在。重命名或移动文件会静默破坏所有指向它的链接,且在读者点击之前不可见。doc-sync(文档同步门禁)强制执行已经将两类文档漂移的检查自动化(无法编译的代码块、陈旧的事件分类体系表),verify-md-wrap 覆盖了第三类(硬换行的段落),但死链是第四类同样可机械检查、却仍靠肉眼验证的问题。
引入这道门禁的直接动因是 Agent Note 目录树重组:将 docs/adr/ 与 .agents/notes/ 统一到同一个 .agents/notes/ 下,并设置 proposed/、implemented/、rejected/ 子目录,需要手工重命名约 40 条文档间链接。只要有一处路径输入错误,就会在没有任何检查拦截的情况下交付断链。
决策
新增第四道 doc-sync 门禁 verify-md-links(scripts/verify-md-links.ts),风格与 verify-md-wrap 一致(tsx ESM、基于 AST、只验证不生成):
- 使用
mdast-util-from-markdown+ GFM 解析每个范围内的 Markdown 文件,遍历所有link、image、definition节点。 - 仅当目标是相对路径时才检查。跳过带协议的 URL(
https:、mailto:等)、协议相对路径(//host)、根绝对路径(/path,在检出目录中没有稳定基准)以及纯页内锚点(#section)。剥除#fragment/?query,相对于链接所在文件的目录解析路径,并断言目标在磁盘上存在。 - 只报告、不改写;发现第一条死链即以非零状态退出。
检查范围与其他门禁一致,并额外包含 AGENTS.md 文件对以及 .agents/skills/ 下仓库自有的 agent skill(智能体技能) Markdown(这些 skill 文件会交叉链接到 docs 目录树,因此本次重组也改写了其中的链接):README.md、docs/**/*.md、packages/*/README.md、AGENTS.md、packages/AGENTS.md、.agents/skills/**/*.md。系统按真实路径去重(CLAUDE.md symlink 会解析到 AGENTS.md 文件)。该检查接入 doc-sync,因此相关文档变更与 CI 执行同一套断链检查。
本门禁现在也检查 Markdown 目标上的 #fragment 锚点——包括同文件锚点——对照标题 slug 与显式 <a id>;该机制与 slug 规则由 fragment 锚点决定规定。
曾考虑的替代方案
锚点级有效性检查:当时以更重且价值更低为由推迟(实际发生过的问题是文件级死链),把 #fragment 验证留给作者人工完成。该人工规则没有守住;fragment 锚点决定后来补上了这项检查。
后果
- 造成交叉链接失效的重命名与移动会直接使
doc-sync和 CI 失败,而不是等读者点击死链才暴露。由此,引入该门禁的 Agent Note 重组具备自校验能力:该检查证明其自身改写的链接均未悬空。 doc-sync链中多了一个快速 tsx 脚本;无新增依赖(mdast/GFM 技术栈已作为verify-md-wrap的 devDependencies 存在)。- 该门禁强制执行的约定是:文档交叉引用必须使用可机械检查的相对链接,绝不能只写纯文本或编号。docs/AGENTS.md记录了这项约定,使作者了解该门禁及其理由。