From dad596348419e15a3f93943d0ea89410b6fe9e03 Mon Sep 17 00:00:00 2001
From: Tianyi Cui <53024+tianyicui@users.noreply.github.com>
Date: Sun, 9 Aug 2026 10:39:11 +0800
Subject: [PATCH] =?UTF-8?q?fix(doc-gates):=20review=20findings=20=E2=80=94?=
=?UTF-8?q?=20GitHub-slugger=20parity=20and=20the=20surviving=20old-rule?=
=?UTF-8?q?=20prose?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ds-review-bot round: slugs now come from RENDERED heading text (links,
inline code, emphasis) with underscores kept and GitHub's occupied-set
repeat suffixes; explicit anchors register only from real HTML flow
(fences, inline code, and comments no longer produce phantoms); fragment
matching is exact-case since element ids are. anchorCache is exported and
the spec reuses it. The contradicting 'gate checks file existence, not
#anchor validity' sentence in docs/AGENTS.md is gone; the dsh-doc-standards
residual caveat names the real TS-string anchor homes; the 2026-06-18
cross-link note is updated to shipped behavior and cross-linked with the
fragment-gate note.
---
...6-06-18-markdown-cross-link-lint.i18n.yaml | 4 +-
.../2026-06-18-markdown-cross-link-lint.md | 4 +-
.../2026-06-18-markdown-cross-link-lint.zh.md | 4 +-
...26-08-09-md-fragment-anchor-gate.i18n.yaml | 4 +-
.../2026-08-09-md-fragment-anchor-gate.md | 12 ++--
.../2026-08-09-md-fragment-anchor-gate.zh.md | 12 ++--
.agents/skills/dsh-doc-standards/SKILL.md | 2 +-
docs/AGENTS.md | 2 -
scripts/verify-md-links.spec.ts | 49 ++++++++++----
scripts/verify-md-links.ts | 64 +++++++++++--------
10 files changed, 97 insertions(+), 60 deletions(-)
diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml
index bc6582cb20..e5112c08e1 100644
--- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md
-2026-06-18-markdown-cross-link-lint.md: b8b1337e9d758da6a4cc0bb46a6b37906357f877
-2026-06-18-markdown-cross-link-lint.zh.md: 9b627ebb17a0567424ca0caaeac8edd9b36917f2
+2026-06-18-markdown-cross-link-lint.md: 21c6884d3fd891794a11125a9aa51ac2bcb29059
+2026-06-18-markdown-cross-link-lint.zh.md: 444cf6eb97d95049d4e6b7bb5f44138051f6577a
diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md
index b8b1337e9d..21c6884d3f 100644
--- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md
+++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md
@@ -20,11 +20,11 @@ A fourth `doc-sync` gate, `verify-md-links` (`scripts/verify-md-links.ts`), mirr
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.
-This gate checks *existence*, not anchor validity: a link to a real file with a `#wrong-heading` fragment still passes (the file resolves; the fragment is stripped).
+The gate now also checks `#fragment` anchors on Markdown targets — same-file anchors included — against heading slugs and explicit ``; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) owns that mechanism and the slug rules.
## Alternatives considered
-**Anchor-level validity checking** — heavier and lower-value; file-level dead links are the failure that actually bit. The scope cut is deliberate: authors verify `#fragment` anchors themselves when linking to one.
+**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](2026-08-09-md-fragment-anchor-gate.md) later added the check.
## Consequences
diff --git a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md
index 9b627ebb17..444cf6eb97 100644
--- a/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md
+++ b/.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.zh.md
@@ -20,11 +20,11 @@ Status: implemented
检查范围与其他门禁一致,并额外包含 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 执行同一套断链检查。
-本门禁检查的是*文件存在性*,而非锚点有效性:指向一个真实文件但带有 `#wrong-heading` 片段的链接仍会通过(文件路径可解析;片段被剥除)。
+本门禁现在也检查 Markdown 目标上的 `#fragment` 锚点——包括同文件锚点——对照标题 slug 与显式 ``;该机制与 slug 规则归 [fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)所有。
## 曾考虑的替代方案
-**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。
+**锚点级有效性检查**:当时以更重且价值更低为由推迟(实际咬过人的是文件级死链),把 `#fragment` 验证留给作者人工完成。该人工规则没有守住;[fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)后来补上了这项检查。
## 后果
diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml
index 734da74005..842fd1f468 100644
--- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
-2026-08-09-md-fragment-anchor-gate.md: 190ecb0ed07152761377961ca6221c25908082b0
-2026-08-09-md-fragment-anchor-gate.zh.md: ab08609d8b1410ccf0f36a6ced68729ac20c7658
+2026-08-09-md-fragment-anchor-gate.md: e02a917bedd9649a2326e3fb1f53072ac05c88a8
+2026-08-09-md-fragment-anchor-gate.zh.md: dcc0644ae064c161ef5232654993f0e6c872064c
diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
index 190ecb0ed0..e02a917bed 100644
--- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
+++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.md
@@ -10,23 +10,23 @@ English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md)
## Decision
-`verify-md-links` now resolves fragments too. 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 (repeated headings get the renderer's `-1`, `-2`, … suffixes) or an explicit ``. 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, so links INTO archived notes and vendor documents are validated without making those files sources.
+`verify-md-links` now resolves fragments too (superseding the deferred scope cut in the [cross-link decision](2026-06-18-markdown-cross-link-lint.md)). 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 `` 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 is GitHub's algorithm, duplicated from `gen-cordis-catalog`'s region-anchor renderer rather than shared across the script/package boundary; the corpus passing this gate is what keeps the two in sync. 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 `` before the Chinese heading, so both language sides expose identical anchors.
+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 `` 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 `` 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; anchors cited from TypeScript strings still need one.
+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: slugging (backticks, punctuation, repeat suffixes, explicit ``), a resolving mixed-link document, a dead same-file fragment, a dead cross-file 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`, 1613 files) and passes only after the 15 fixes — the corpus itself is the red-to-green evidence for each decay mode.
+`scripts/verify-md-links.spec.ts` proves the acceptance paths: rendered-text slugging (backticks, punctuation, a linked heading, kept underscores), occupied-set repeat suffixes, `` 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 `` + English fragments (glossary, primer), which also survives renderers that strip non-ASCII; adopting a second convention would split the corpus.
-- **Share `githubSlug` with the typert generator.** A one-function import would couple a doc gate to a package build; the algorithm is three lines and the corpus gate itself detects divergence (a generator anchor the gate cannot resolve fails the build the moment a page links it).
+- **Share `githubSlug` with 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 ``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 string literals.
+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.
diff --git a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md
index ab08609d8b..dcc0644ae0 100644
--- a/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md
+++ b/.agents/notes/implemented/process/2026-08-09-md-fragment-anchor-gate.zh.md
@@ -10,23 +10,23 @@ Status: implemented
## Decision
-`verify-md-links` 现在也解析 fragment。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug(重复标题获得渲染器的 `-1`、`-2`……后缀)或显式 ``。指向非 Markdown 目标的 fragment(`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集,因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
+`verify-md-links` 现在也解析 fragment(取代[跨链接决定](2026-06-18-markdown-cross-link-lint.md)中被推迟的范围裁剪)。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug,或真实 HTML 流中的显式 ``(代码示例与注释掉的锚点不注册任何东西)。slug 由仓库自有的 `markdownHeadingLines` 从**渲染后**的标题文本计算,因此标题内的链接、行内代码与强调都按 GitHub 的渲染结果 slug;下划线保留(`#showcase-web_fetch`);重复 slug 获得 GitHub 的占用集 `-1`、`-2`……后缀;匹配区分大小写,因为元素 id 本就区分大小写。指向非 Markdown 目标的 fragment(`file.ts#L10`)语义归渲染器所有,不在范围内;外部与根绝对 URL 同样不检查。锚点集合对任意存在的目标惰性收集(`anchorCache`),因此链入归档 note 与 vendor 文档的链接照常校验,而这些文件不会因此成为扫描源。
-slug 函数是 GitHub 的算法,从 `gen-cordis-catalog` 的区块锚点渲染器复制而来,而非跨脚本/包边界共享;语料通过本 gate 即是两者保持同步的机制。中文侧沿用既有语料惯例(`docs/glossary.zh.md`、`docs/cordis-primer.zh.md`):链接保留英文 fragment,在中文标题前放置显式 ``,使两个语言侧暴露相同的锚点。
+slug 函数与 `gen-cordis-catalog` 的区块锚点 slugger 不同(后者丢弃下划线):生成器的标题总能通过其显式 `` 锚点到达,两者无需共享一条规则。中文侧沿用既有语料惯例(`docs/glossary.zh.md`、`docs/cordis-primer.zh.md`):链接保留英文 fragment,在中文标题前放置显式 ``,使两个语言侧暴露相同的锚点。
-15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;从 TypeScript 字符串引用的锚点仍需人工 grep。
+15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(当下三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。
## Verification
-`scripts/verify-md-links.spec.ts` 证明各验收路径:slug 化(反引号、标点、重复后缀、显式 ``)、全部可解析的混合链接文档、死的同文件 fragment、死的跨文件 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`,1613 个文件),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
+`scripts/verify-md-links.spec.ts` 证明各验收路径:渲染文本 slug 化(反引号、标点、含链接标题、保留下划线)、占用集重复后缀、围栏/行内代码/注释中的 `` 不注册、全部可解析的混合链接文档、死的同文件与跨文件 fragment、大小写变体 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
## Alternatives considered
- **保留人工 grep 规则。** 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `` + 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。
-- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得;算法只有三行,语料 gate 本身能探测分歧(生成器产出而 gate 无法解析的锚点,在任何页面链接它的那一刻即失败)。
+- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得,且两条规则确实不同(生成器剥离下划线;其锚点是 gate 直接读取的显式 ``),分歧是设计使然而非漂移。
- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
## Consequences
-重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对 TypeScript 字符串字面量中的锚点保留。
+重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留。
diff --git a/.agents/skills/dsh-doc-standards/SKILL.md b/.agents/skills/dsh-doc-standards/SKILL.md
index 49ab835752..2df8d3774f 100644
--- a/.agents/skills/dsh-doc-standards/SKILL.md
+++ b/.agents/skills/dsh-doc-standards/SKILL.md
@@ -29,7 +29,7 @@ Then check constraints that make placement expensive or wrong:
- Paired docs (`pnpm run verify-translation-pairing --list`) cost a zh counterpart update and a `--write` re-record on every edit — prefer an unpaired home for content that will churn.
- Generated catalogs are never hand-edited; if the fact belongs there, change the generator's source.
-- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit ``), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings (one lives in `scripts/gen-cordis-catalog.ts`) still need a manual grep.
+- Before renaming or moving any doc, grep for inbound references: `verify-md-links` catches Markdown link targets AND `#fragment` anchors onto Markdown files (heading slugs and explicit ``), and `verify-doc-refs` catches `docs/*.md` citations in TypeScript comments; anchors cited from TypeScript strings still need a manual grep when their output never reaches gate-scanned Markdown (today's three — `scripts/gen-doc-graphs.ts`, `scripts/gen-persistence-catalog.ts`, `packages/typert/generator/src/cordis-catalog.ts` — all render into scanned pages, so the gate catches them via the committed output).
- A move is atomic: remove from the old home, add to the new home, and fix every inbound link in the same change.
## Audit the corpus
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 8d019fce08..672f6ba6f6 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -73,5 +73,3 @@ Hunt these in any doc; the [dsh-doc-standards](../.agents/skills/dsh-doc-standar
## Cross-reference with machine-checkable links, never free prose
Link repository references with relative Markdown paths, never bare filenames or Agent Note numbers. `verify-md-links` rejects missing targets and dead `#fragment` anchors ([rationale](../.agents/notes/implemented/process/2026-06-18-markdown-cross-link-lint.md)).
-
-The gate checks file existence, not `#anchor` validity — verify anchors yourself when linking to one.
diff --git a/scripts/verify-md-links.spec.ts b/scripts/verify-md-links.spec.ts
index 4578583472..5be9231a3e 100644
--- a/scripts/verify-md-links.spec.ts
+++ b/scripts/verify-md-links.spec.ts
@@ -5,11 +5,11 @@
* external targets stay out of scope.
*/
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
-import { documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
+import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
const roots: string[] = []
afterEach(() => {
@@ -27,19 +27,11 @@ function layout(files: Record): string {
}
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
- const cache = new Map>()
- const anchorsOf = (abs: string): Set => {
- const hit = cache.get(abs)
- if (hit) return hit
- const anchors = documentAnchors(readFileSync(abs, 'utf8'))
- cache.set(abs, anchors)
- return anchors
- }
- return findViolations(join(root, rel), anchorsOf, root).map(({ url, reason }) => ({ url, reason }))
+ return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
}
describe('documentAnchors', () => {
- it('slugs headings, suffixes repeats, and reads explicit anchors', () => {
+ it('slugs rendered heading text, suffixes repeats, and reads explicit anchors', () => {
const anchors = documentAnchors([
'# My Doc',
'## Live `events` — mode!',
@@ -51,6 +43,34 @@ describe('documentAnchors', () => {
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
})
+
+ it('keeps underscores the way GitHub does', () => {
+ expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
+ expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
+ })
+
+ it('slugs a heading containing a link from its rendered text', () => {
+ expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
+ })
+
+ it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
+ const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
+ expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
+ })
+
+ it('ignores inside code fences, inline code, and HTML comments', () => {
+ const anchors = documentAnchors([
+ '# Doc',
+ '```md',
+ '',
+ '```',
+ 'Inline `` sample.',
+ '',
+ '',
+ '',
+ ].join('\n'))
+ expect(anchors).toEqual(new Set(['doc', 'real']))
+ })
})
describe('findViolations fragments', () => {
@@ -68,6 +88,11 @@ describe('findViolations fragments', () => {
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
})
+ it('rejects a case-variant fragment: element ids are case-sensitive', () => {
+ const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
+ expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
+ })
+
it('rejects a cross-file fragment missing from the target document', () => {
const root = layout({
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts
index 0286f6d516..8d4b99fe5d 100644
--- a/scripts/verify-md-links.ts
+++ b/scripts/verify-md-links.ts
@@ -10,7 +10,7 @@
import { existsSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import type { Nodes } from 'mdast'
-import { parseMarkdown, visitMarkdown } from './markdown.ts'
+import { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -88,45 +88,59 @@ function fragmentPart(url: string): string | null {
/**
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
- * numbers, spaces, hyphens; spaces become hyphens) — the same rule
- * `gen-cordis-catalog`'s region anchors are built from, kept in sync by the
- * corpus passing this gate rather than by a shared import across the
- * script/package boundary.
- * @param heading - the rendered heading text.
+ * numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
+ * survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
+ * `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
+ * always reachable through its explicit `` anchors, so the two need not
+ * share one rule.
+ * @param heading - the RENDERED heading text (Markdown syntax already gone).
* @returns the anchor GitHub assigns the first occurrence of the heading.
*/
export function githubSlug(heading: string): string {
- return heading.toLowerCase().replace(/[^\p{L}\p{N} -]/gu, '').replaceAll(' ', '-')
+ return heading.toLowerCase().replace(/[^\p{L}\p{N}_ -]/gu, '').replaceAll(' ', '-')
}
/**
- * Every anchor one Markdown document exposes: each heading's GitHub slug
- * (repeated headings get the renderer's `-1`, `-2`, … suffixes) plus every
- * explicit ``. Lowercased for case-insensitive fragment matching.
+ * Every anchor one Markdown document exposes: each heading's GitHub slug —
+ * computed from the RENDERED heading text, so links, images, inline code, and
+ * emphasis inside a heading slug the way GitHub renders them — plus every
+ * explicit `` that appears in real HTML flow (a fenced or inline
+ * code sample and a commented-out anchor register nothing). Repeated slugs
+ * get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
+ * ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
+ * `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
+ * element ids are case-sensitive.
* @param source - the document's full Markdown text.
* @returns the set of valid fragments for links into this document.
*/
export function documentAnchors(source: string): Set {
const anchors = new Set()
- const seen = new Map()
- const tree = parseMarkdown(source)
- visitMarkdown(tree, (node: Nodes): void => {
- if (node.type === 'heading') {
- const text = source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0)
- .replace(/^#{1,6}\s+/, '')
- .replace(/[`*_]/g, '')
- const base = githubSlug(text)
- const bump = seen.get(base) ?? 0
- seen.set(base, bump + 1)
- anchors.add(bump === 0 ? base : `${base}-${bump}`)
+ const occurrences = new Map()
+ for (const heading of markdownHeadingLines(source)) {
+ const base = githubSlug(heading.text)
+ let result = base
+ let bump = occurrences.get(base) ?? 0
+ while (anchors.has(result)) {
+ bump += 1
+ result = `${base}-${bump}`
}
+ occurrences.set(base, bump)
+ anchors.add(result)
+ }
+ visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
+ if (node.type !== 'html') return
+ const html = node.value.replace(//g, '')
+ for (const match of html.matchAll(/ Set {
+/**
+ * Lazily collect and cache the anchor set of any existing Markdown file —
+ * shared across all scanned sources so a target parses once.
+ * @returns the memoized absolute-path → anchor-set lookup.
+ */
+export function anchorCache(): (absPath: string) => Set {
const cache = new Map>()
return (absPath) => {
const hit = cache.get(absPath)
@@ -169,7 +183,7 @@ export function findViolations(
}
const fragment = fragmentPart(url)
if (fragment === null || !resolved.endsWith('.md')) return
- if (!anchorsOf(resolved).has(fragment.toLowerCase())) {
+ if (!anchorsOf(resolved).has(fragment)) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
}
}