mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
test(docs): recursive link + heading-anchor integrity check
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.)
This commit is contained in:
+111
-33
@@ -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
|
||||
* ``. 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<string> {
|
||||
const anchors = new Set<string>();
|
||||
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<string, Set<string>>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user