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' }) } }