mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(doc-gates): review findings — GitHub-slugger parity and the surviving old-rule prose
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 <a id> 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.
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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.md: 21c6884d3fd891794a11125a9aa51ac2bcb29059
|
||||||
2026-06-18-markdown-cross-link-lint.zh.md: 9b627ebb17a0567424ca0caaeac8edd9b36917f2
|
2026-06-18-markdown-cross-link-lint.zh.md: 444cf6eb97d95049d4e6b7bb5f44138051f6577a
|
||||||
|
|||||||
@@ -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.
|
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 `<a id>`; the [fragment-anchor decision](2026-08-09-md-fragment-anchor-gate.md) owns that mechanism and the slug rules.
|
||||||
|
|
||||||
## Alternatives considered
|
## 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
|
## Consequences
|
||||||
|
|
||||||
|
|||||||
@@ -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 执行同一套断链检查。
|
检查范围与其他门禁一致,并额外包含 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 与显式 `<a id>`;该机制与 slug 规则归 [fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)所有。
|
||||||
|
|
||||||
## 曾考虑的替代方案
|
## 曾考虑的替代方案
|
||||||
|
|
||||||
**锚点级有效性检查**:更重且价值更低;实际造成问题的是文件级死链。这一范围裁剪是有意为之:作者在链接到某个锚点时自行验证 `#fragment`。
|
**锚点级有效性检查**:当时以更重且价值更低为由推迟(实际咬过人的是文件级死链),把 `#fragment` 验证留给作者人工完成。该人工规则没有守住;[fragment 锚点决定](2026-08-09-md-fragment-anchor-gate.md)后来补上了这项检查。
|
||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# 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.md: e02a917bedd9649a2326e3fb1f53072ac05c88a8
|
||||||
2026-08-09-md-fragment-anchor-gate.zh.md: ab08609d8b1410ccf0f36a6ced68729ac20c7658
|
2026-08-09-md-fragment-anchor-gate.zh.md: dcc0644ae064c161ef5232654993f0e6c872064c
|
||||||
|
|||||||
@@ -10,23 +10,23 @@ English | [中文](2026-08-09-md-fragment-anchor-gate.zh.md)
|
|||||||
|
|
||||||
## Decision
|
## 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 `<a id>`. 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 `<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 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 `<a id>` 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 `<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; 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
|
## Verification
|
||||||
|
|
||||||
`scripts/verify-md-links.spec.ts` proves the acceptance paths: slugging (backticks, punctuation, repeat suffixes, explicit `<a id>`), 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, `<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
|
## 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.
|
- **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.
|
- **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 `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 `<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.
|
- **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
|
## 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.
|
||||||
|
|||||||
@@ -10,23 +10,23 @@ Status: implemented
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
`verify-md-links` 现在也解析 fragment。对每条目标为 Markdown 文件的相对链接——包括旧检查器完全跳过的同文件 `#anchor` 链接——fragment 必须命名目标中的真实锚点:标题的 GitHub slug(重复标题获得渲染器的 `-1`、`-2`……后缀)或显式 `<a id>`。指向非 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 流中的显式 `<a id>`(代码示例与注释掉的锚点不注册任何东西)。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,在中文标题前放置显式 `<a id>`,使两个语言侧暴露相同的锚点。
|
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 锚点的处方;从 TypeScript 字符串引用的锚点仍需人工 grep。
|
15 条坏 fragment 在同一变更中修复:陈旧 slug 重定向到当前标题,搬迁的无超时契约改链其属主 group README,四份中文文档补上显式锚点。`docs/AGENTS.md` 与 `dsh-doc-standards` skill 不再为 Markdown 链接开人工 grep 锚点的处方;人工 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留(当下三处全部渲染进受检页面,gate 经由提交的产物覆盖它们)。
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`scripts/verify-md-links.spec.ts` 证明各验收路径:slug 化(反引号、标点、重复后缀、显式 `<a id>`)、全部可解析的混合链接文档、死的同文件 fragment、死的跨文件 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`,1613 个文件),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
|
`scripts/verify-md-links.spec.ts` 证明各验收路径:渲染文本 slug 化(反引号、标点、含链接标题、保留下划线)、占用集重复后缀、围栏/行内代码/注释中的 `<a id>` 不注册、全部可解析的混合链接文档、死的同文件与跨文件 fragment、大小写变体 fragment、以及缺失目标仍报 `target` 而非 `anchor`。gate 在 doc-sync 中跑完整语料(`verify-md-links`),且只有在 15 条修复之后才通过——语料本身就是每种衰变模式由红转绿的证据。
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
- **保留人工 grep 规则。** 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
|
- **保留人工 grep 规则。** 它被证明守不住:15 条 fragment 在 gate 驱动的维护文化下仍然衰变,因为改写标题的 PR 从不会去看入链。可机械检查的不变式应进入被执行的 gate。
|
||||||
- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `<a id>` + 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。
|
- **让中文链接指向中文 slug 锚点。** GitHub 对 CJK 标题的 slug 没问题,但语料惯例已是显式 `<a id>` + 英文 fragment(glossary、primer),且它在剥离非 ASCII 的渲染器下也存活;引入第二种惯例会割裂语料。
|
||||||
- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得;算法只有三行,语料 gate 本身能探测分歧(生成器产出而 gate 无法解析的锚点,在任何页面链接它的那一刻即失败)。
|
- **与 typert 生成器共享 `githubSlug`。** 为一个函数引入包构建耦合不值得,且两条规则确实不同(生成器剥离下划线;其锚点是 gate 直接读取的显式 `<a id>`),分歧是设计使然而非漂移。
|
||||||
- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
|
- **同时校验 VitePress slug。** 发布站点的死链检查已在 `website:build` 中运行;生成区块正是为两种渲染器一致而携带显式锚点,手写标题若有分歧会在那里失败。
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对 TypeScript 字符串字面量中的锚点保留。
|
重命名标题现在会在任何 Markdown 链接引用其锚点处使构建失败,而非把读者丢在页顶;作者须在同一变更中修复入链,与文件重命名的既有义务完全一致。同文件锚点不再是盲区,中文页面使用英文 fragment 时必须补锚点。人工的重命名前 grep 只对输出从不进入受检 Markdown 的 TypeScript 字符串锚点保留。
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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 `<a id>`), 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 `<a id>`), 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.
|
- 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
|
## Audit the corpus
|
||||||
|
|||||||
@@ -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
|
## 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)).
|
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.
|
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
* external targets stay out of scope.
|
* 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 { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { afterEach, describe, expect, it } from 'vitest'
|
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[] = []
|
const roots: string[] = []
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -27,19 +27,11 @@ function layout(files: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
|
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
|
||||||
const cache = new Map<string, Set<string>>()
|
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
|
||||||
const anchorsOf = (abs: string): Set<string> => {
|
|
||||||
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 }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('documentAnchors', () => {
|
describe('documentAnchors', () => {
|
||||||
it('slugs headings, suffixes repeats, and reads explicit <a id> anchors', () => {
|
it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
|
||||||
const anchors = documentAnchors([
|
const anchors = documentAnchors([
|
||||||
'# My Doc',
|
'# My Doc',
|
||||||
'## Live `events` — mode!',
|
'## 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(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')
|
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 <a id> inside code fences, inline code, and HTML comments', () => {
|
||||||
|
const anchors = documentAnchors([
|
||||||
|
'# Doc',
|
||||||
|
'```md',
|
||||||
|
'<a id="fenced"></a>',
|
||||||
|
'```',
|
||||||
|
'Inline `<a id="inline"></a>` sample.',
|
||||||
|
'<!-- <a id="commented"></a> -->',
|
||||||
|
'<a id="real"></a>',
|
||||||
|
'',
|
||||||
|
].join('\n'))
|
||||||
|
expect(anchors).toEqual(new Set(['doc', 'real']))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('findViolations fragments', () => {
|
describe('findViolations fragments', () => {
|
||||||
@@ -68,6 +88,11 @@ describe('findViolations fragments', () => {
|
|||||||
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
|
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', () => {
|
it('rejects a cross-file fragment missing from the target document', () => {
|
||||||
const root = layout({
|
const root = layout({
|
||||||
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
|
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { dirname, relative, resolve } from 'node:path'
|
import { dirname, relative, resolve } from 'node:path'
|
||||||
import type { Nodes } from 'mdast'
|
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'
|
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
|
||||||
|
|
||||||
const root = resolve(import.meta.dirname, '..')
|
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,
|
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
|
||||||
* numbers, spaces, hyphens; spaces become hyphens) — the same rule
|
* numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
|
||||||
* `gen-cordis-catalog`'s region anchors are built from, kept in sync by the
|
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
|
||||||
* corpus passing this gate rather than by a shared import across the
|
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
|
||||||
* script/package boundary.
|
* always reachable through its explicit `<a id>` anchors, so the two need not
|
||||||
* @param heading - the rendered heading text.
|
* share one rule.
|
||||||
|
* @param heading - the RENDERED heading text (Markdown syntax already gone).
|
||||||
* @returns the anchor GitHub assigns the first occurrence of the heading.
|
* @returns the anchor GitHub assigns the first occurrence of the heading.
|
||||||
*/
|
*/
|
||||||
export function githubSlug(heading: string): string {
|
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
|
* Every anchor one Markdown document exposes: each heading's GitHub slug —
|
||||||
* (repeated headings get the renderer's `-1`, `-2`, … suffixes) plus every
|
* computed from the RENDERED heading text, so links, images, inline code, and
|
||||||
* explicit `<a id="…">`. Lowercased for case-insensitive fragment matching.
|
* emphasis inside a heading slug the way GitHub renders them — plus every
|
||||||
|
* explicit `<a id="…">` 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.
|
* @param source - the document's full Markdown text.
|
||||||
* @returns the set of valid fragments for links into this document.
|
* @returns the set of valid fragments for links into this document.
|
||||||
*/
|
*/
|
||||||
export function documentAnchors(source: string): Set<string> {
|
export function documentAnchors(source: string): Set<string> {
|
||||||
const anchors = new Set<string>()
|
const anchors = new Set<string>()
|
||||||
const seen = new Map<string, number>()
|
const occurrences = new Map<string, number>()
|
||||||
const tree = parseMarkdown(source)
|
for (const heading of markdownHeadingLines(source)) {
|
||||||
visitMarkdown(tree, (node: Nodes): void => {
|
const base = githubSlug(heading.text)
|
||||||
if (node.type === 'heading') {
|
let result = base
|
||||||
const text = source.slice(node.position?.start.offset ?? 0, node.position?.end.offset ?? 0)
|
let bump = occurrences.get(base) ?? 0
|
||||||
.replace(/^#{1,6}\s+/, '')
|
while (anchors.has(result)) {
|
||||||
.replace(/[`*_]/g, '')
|
bump += 1
|
||||||
const base = githubSlug(text)
|
result = `${base}-${bump}`
|
||||||
const bump = seen.get(base) ?? 0
|
|
||||||
seen.set(base, bump + 1)
|
|
||||||
anchors.add(bump === 0 ? base : `${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(/<!--[\s\S]*?-->/g, '')
|
||||||
|
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
|
||||||
})
|
})
|
||||||
for (const match of source.matchAll(/<a id="([^"]+)"/g)) anchors.add((match[1] ?? '').toLowerCase())
|
|
||||||
return anchors
|
return anchors
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lazily collect and cache the anchor set of any existing Markdown file. */
|
/**
|
||||||
function anchorCache(): (absPath: string) => Set<string> {
|
* 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<string> {
|
||||||
const cache = new Map<string, Set<string>>()
|
const cache = new Map<string, Set<string>>()
|
||||||
return (absPath) => {
|
return (absPath) => {
|
||||||
const hit = cache.get(absPath)
|
const hit = cache.get(absPath)
|
||||||
@@ -169,7 +183,7 @@ export function findViolations(
|
|||||||
}
|
}
|
||||||
const fragment = fragmentPart(url)
|
const fragment = fragmentPart(url)
|
||||||
if (fragment === null || !resolved.endsWith('.md')) return
|
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' })
|
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user