Dead link checker
No broken links in Markdown docs you ship — checked automatically at session end
Full-repo scan of all .md and .mdx files for broken relative links (file existence check). Covers pre-existing doc debt, not just files touched in the current session. Pure Node.js — no network requests, no external dependencies. Skips node_modules, .git, .next and .claude directories. Non-blocking warning that lists every broken reference so it can be fixed before pushing.
What does the Dead link checker hook do?
Dead link checker is a Claude Code Stop hook. It fires automatically at that lifecycle event — outside the model, so it can't be skipped or forgotten. No broken links in Markdown docs you ship — checked automatically at session end.
As a Stop hook it runs after the action, reacting to what just happened rather than blocking it. Because it is a deterministic Node.js script, it executes on every matching event without relying on the model to remember — the guarantee that makes agentic workflows safe to automate.
Use cases
- Catch dead links in README and documentation generated or updated by the agent before pushing
- Ensure internal anchor links (#section) still exist after refactoring headings
- Prevent broken external URLs in changelogs, guides, or API docs from reaching the main branch
Tags
settings.json fragment
{
"hooks": {
"Stop": [
{
"hooks": [
{
"command": "node $CLAUDE_PROJECT_DIR/.claude/hooks/stop-dead-link-checker.mjs",
"type": "command"
}
]
}
]
}
}Script · .claude/hooks/stop-dead-link-checker.mjs
#!/usr/bin/env node
// @hookstack stop-dead-link-checker
// Vérifie les liens relatifs cassés dans tous les fichiers Markdown du repo (Stop).
// Scan complet — couvre la dette existante, pas seulement les fichiers de la session.
// Purement Node.js (fs + path), sans réseau ni dépendance externe.
import { readFileSync, existsSync, readdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'out', '.claude']);
// Capture [text](href) — exclut les images  incluses dans la même syntaxe
const LINK_RE = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
function isRelative(href) {
return !href.startsWith('http') && !href.startsWith('#') && !href.startsWith('mailto:');
}
function stripAnchor(href) {
return href.split('#')[0].trim();
}
function walkMd(dir, { readdir = readdirSync, exists = existsSync } = {}) {
if (!exists(dir)) return [];
const results = [];
for (const entry of readdir(dir, { withFileTypes: true })) {
if (SKIP_DIRS.has(entry.name)) continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) results.push(...walkMd(full, { readdir, exists }));
else if (/\.mdx?$/.test(entry.name)) results.push(full);
}
return results;
}
export function run(_input, {
projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd(),
readFile = readFileSync,
exists = existsSync,
readdir = readdirSync,
} = {}) {
const mdFiles = walkMd(projectDir, { readdir, exists });
if (!mdFiles.length) return null;
const broken = [];
for (const file of mdFiles) {
let content;
try { content = readFile(file, 'utf8'); } catch { continue; }
for (const [, , href] of content.matchAll(LINK_RE)) {
if (!isRelative(href)) continue;
const target = stripAnchor(href);
if (!target) continue; // lien ancre pure (#section)
const abs = resolve(dirname(file), target);
if (!exists(abs)) {
broken.push(`${file.replace(projectDir + '/', '')} → ${href}`);
}
}
}
if (!broken.length) return null;
return {
message:
`[dead-link-checker] ${broken.length} broken relative link(s) across docs:\n` +
broken.map((b) => ` - ${b}`).join('\n') +
'\n',
};
}
/* v8 ignore next 5 */
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const input = JSON.parse(readFileSync(0, 'utf8'));
const result = run(input);
if (result) process.stderr.write(JSON.stringify(result));
}
Learn more
Related hooks
- Session summary generationAn automatic changelog of what the agent shipped
- Save compaction summary to logKeep a readable trail of every compaction
- Docs consistency reminderNo more READMEs telling two different stories
- Force implementation doc at stopNo code change ships without a doc/implementation/ trace