From ee94cb323bf9090dcff210b0074c3ec00b94fbd0 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Sat, 4 Jul 2026 14:17:43 +0700 Subject: [PATCH] test(docs): recursive link + heading-anchor integrity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Broaden the guard from README + top-level docs/*.md inline links to the full tracked doc set and more link forms: - scan README.md, CHANGELOG.md, FINDINGS.md, docs/**/*.md, and eval/**/README.md (eval run logs are skipped — only their READMEs are prose) - parse inline links, image links, reference-style definitions, and simple HTML href=/src= - validate the target file exists AND, when a link carries a #fragment into a markdown file, that a matching GitHub-style heading anchor exists Verified: passes on the current tree and fails on an injected broken link or broken #anchor. (A README public-API-example typecheck lands on the integration branch, where the README import fix is present.) --- tests/docs-integrity.test.ts | 144 +++++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 33 deletions(-) diff --git a/tests/docs-integrity.test.ts b/tests/docs-integrity.test.ts index 31ad82a..2236e59 100644 --- a/tests/docs-integrity.test.ts +++ b/tests/docs-integrity.test.ts @@ -3,50 +3,128 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; -// Guard against docs drift: README/docs relative links that rot when files move -// or get renamed (the class of bug a static audit catches once, then forgets). -// Dependency-light: pure fs + regex, runs inside the existing `pnpm test` / CI. +// Guard against docs drift: markdown links (and their #fragments) that rot when +// files move, get renamed, or lose a heading. Dependency-light: pure fs + regex, +// runs inside the existing `pnpm test` / CI. Covers inline links, image links, +// reference-style links, and simple HTML href/src across the tracked docs. const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -/** README.md plus every top-level docs/*.md. */ +/** README + top docs + every docs/** and eval/**\/README.md markdown file. */ function markdownFiles(): string[] { - const files = ['README.md']; - for (const name of fs.readdirSync(path.join(repoRoot, 'docs'))) { - if (name.endsWith('.md')) files.push(path.join('docs', name)); - } - return files; + const out = ['README.md', 'CHANGELOG.md', 'FINDINGS.md']; + const walk = (rel: string, opts: { onlyReadme?: boolean } = {}): void => { + const abs = path.join(repoRoot, rel); + if (!fs.existsSync(abs)) return; + for (const name of fs.readdirSync(abs, { withFileTypes: true })) { + const childRel = path.join(rel, name.name); + if (name.isDirectory()) { + if (name.name === 'node_modules' || name.name.startsWith('.')) continue; + walk(childRel, opts); + } else if (name.name.endsWith('.md')) { + // eval/ holds huge run logs; only its READMEs are prose worth link-checking. + if (opts.onlyReadme && name.name !== 'README.md') continue; + out.push(childRel); + } + } + }; + walk('docs'); + walk('eval', { onlyReadme: true }); + return [...new Set(out)].filter((f) => fs.existsSync(path.join(repoRoot, f))); } -/** Relative link/image targets in a markdown string — `[text](target)` and - * `![alt](target)`. Skips external (http/mailto/data) and in-page anchors, and - * strips any `#fragment`, `?query`, angle brackets, or trailing "title". */ -function relativeLinkTargets(md: string): string[] { - const out: string[] = []; - const re = /\]\(([^)]+)\)/g; - let m: RegExpExecArray | null; - while ((m = re.exec(md)) !== null) { - let link = m[1]!.trim(); - if (link.startsWith('<') && link.includes('>')) link = link.slice(1, link.indexOf('>')); - link = link.split(/\s+/)[0]!; // drop an optional `"title"` - if (/^(https?:|mailto:|data:|#)/.test(link)) continue; - link = link.split('#')[0]!.split('?')[0]!; - if (link) out.push(link); +/** GitHub-style heading anchor slug. */ +function slugify(heading: string): string { + return heading + .trim() + .replace(/`/g, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // link text only + .replace(/[*_~]/g, '') + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-'); +} + +/** Set of heading anchors defined in a markdown file (skips fenced code). */ +function headingAnchors(md: string): Set { + const anchors = new Set(); + let inFence = false; + for (const line of md.split('\n')) { + if (/^\s*```/.test(line)) { inFence = !inFence; continue; } + if (inFence) continue; + const m = /^#{1,6}\s+(.+?)\s*#*\s*$/.exec(line); + if (m) anchors.add(slugify(m[1]!)); } - return out; + return anchors; +} + +interface Link { + readonly target: string; // path portion (may be empty for same-page #frag) + readonly fragment: string | null; + readonly raw: string; +} + +const EXTERNAL = /^(https?:|mailto:|data:|tel:|ftp:)/i; + +/** Extract every local link target from a markdown file: inline `](x)` / images, + * reference definitions `[ref]: x`, and simple HTML `href=`/`src=`. */ +function localLinks(md: string): Link[] { + const links: Link[] = []; + const push = (rawTarget: string): void => { + let t = rawTarget.trim(); + if (t.startsWith('<') && t.endsWith('>')) t = t.slice(1, -1); + t = t.split(/\s+/)[0]!; // drop an optional "title" + if (!t || EXTERNAL.test(t)) return; + const hash = t.indexOf('#'); + const target = hash >= 0 ? t.slice(0, hash) : t; + const fragment = hash >= 0 ? t.slice(hash + 1) : null; + links.push({ target: target.split('?')[0]!, fragment, raw: rawTarget }); + }; + for (const m of md.matchAll(/\]\(([^)]+)\)/g)) push(m[1]!); // inline + image + for (const m of md.matchAll(/^\s*\[[^\]]+\]:\s*(\S.*)$/gm)) push(m[1]!); // reference defs + for (const m of md.matchAll(/(?:href|src)\s*=\s*"([^"]+)"/gi)) push(m[1]!); // simple HTML + return links; } describe('docs integrity', () => { - it('every relative link in README.md and docs/*.md points to an existing file', () => { + const files = markdownFiles(); + const anchorsByFile = new Map>(); + for (const f of files) anchorsByFile.set(f, headingAnchors(fs.readFileSync(path.join(repoRoot, f), 'utf8'))); + + it('scans README, CHANGELOG, FINDINGS, docs/**, and eval/**/README.md', () => { + expect(files).toContain('README.md'); + expect(files).toContain('docs/RENDER_SIZING.md'); + expect(files.length).toBeGreaterThan(5); + }); + + it('every relative link/image/href points to a file that exists', () => { const dead: string[] = []; - for (const rel of markdownFiles()) { - const abs = path.join(repoRoot, rel); - const md = fs.readFileSync(abs, 'utf8'); - for (const link of relativeLinkTargets(md)) { - if (!fs.existsSync(path.resolve(path.dirname(abs), link))) { - dead.push(`${rel} → ${link}`); - } + for (const rel of files) { + const dir = path.dirname(path.join(repoRoot, rel)); + for (const link of localLinks(fs.readFileSync(path.join(repoRoot, rel), 'utf8'))) { + if (!link.target) continue; // same-page #fragment, checked below + if (!fs.existsSync(path.resolve(dir, link.target))) dead.push(`${rel} → ${link.raw}`); } } - expect(dead, `dead relative links found:\n${dead.join('\n')}`).toEqual([]); + expect(dead, `dead relative links:\n${dead.join('\n')}`).toEqual([]); + }); + + it('every #fragment resolves to a heading in the target markdown file', () => { + const dead: string[] = []; + for (const rel of files) { + const dir = path.dirname(path.join(repoRoot, rel)); + for (const link of localLinks(fs.readFileSync(path.join(repoRoot, rel), 'utf8'))) { + if (!link.fragment) continue; + // Resolve which file the fragment lives in (same file if target empty). + const targetRel = link.target + ? path.relative(repoRoot, path.resolve(dir, link.target)).replace(/\\/g, '/') + : rel; + if (!targetRel.endsWith('.md')) continue; // only markdown has heading anchors + const anchors = anchorsByFile.get(targetRel); + if (anchors === undefined) continue; // target outside the scanned set + if (!anchors.has(link.fragment.toLowerCase())) dead.push(`${rel} → #${link.fragment}`); + } + } + expect(dead, `broken heading anchors:\n${dead.join('\n')}`).toEqual([]); }); });