Read the script
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const DEFAULTS = {
enabled: true,
sourceGlobs: ['**/*.{js,jsx,ts,tsx,mjs,cjs,py,go,rs,java,rb,c,h,cpp,hpp}'],
docGlobs: ['README.md', '*.md', 'docs/**/*.md'],
ignore: [
'**/node_modules/**',
'**/.git/**',
'**/dist/**',
'**/build/**',
'**/vendor/**',
'**/target/**',
],
};
const MAX_DOC_FILES = 500;
const MAX_SOURCE_FILES = 2000;
const MAX_FILE_SIZE = 1024 * 1024;
const MAX_SYMBOLS_PER_FILE = 50;
const MAX_HOOK_FINDINGS = 20;
const AUDIT_LIMIT = 10;
const MIN_SYMBOL_LENGTH = 3;
// orphans outrank every reference tier: the scanner proved the target is gone
const ORPHAN_CONFIDENCE = 5;
const SOURCE_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|go|rs|java|rb|c|h|cpp|hpp)$/;
const BACKTICK_TOKEN = /`([^`\n]+)`/g;
const IDENT_TOKEN = /[A-Za-z_$][\w$]*/g;
// a doc this saturated with references is probably *about* the changed file
const REWRITE_MIN_LINES = 3;
const REWRITE_DENSITY = 0.3;
// identifiers too generic to prove a doc refers to *this* file
const STOP_WORDS = new Set([
'main', 'init', 'new', 'get', 'set', 'run', 'test', 'index', 'data',
'name', 'type', 'value', 'error', 'result', 'args', 'options', 'config',
'default', 'start', 'stop', 'update', 'create', 'delete', 'read', 'write',
'app', 'use', 'add', 'remove', 'list', 'item', 'key', 'state', 'props',
]);
const SYMBOL_PATTERNS = [
/\bexport\s+(?:default\s+)?(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/g,
/\bexport\s+(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g,
/\bexport\s+(?:const|let|var|type|interface|enum)\s+([A-Za-z_$][\w$]*)/g,
/\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/g,
/^def\s+([A-Za-z_]\w*)/gm,
/^(?:export\s+)?class\s+([A-Za-z_]\w*)/gm,
/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm,
/\bpub\s+(?:async\s+)?fn\s+([A-Za-z_]\w*)/g,
/\bpub\s+(?:struct|enum|trait)\s+([A-Za-z_]\w*)/g,
];
const FLAG_PATTERN = /["'`](--[a-z][a-z0-9-]{2,})["'`]/g;
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function globToRegExp(glob) {
let re = '';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
if (glob[i + 2] === '/') {
re += '(?:[^/]+/)*';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) {
re += '\\{';
i += 1;
} else {
const parts = glob.slice(i + 1, end).split(',').map(escapeRegExp);
re += '(?:' + parts.join('|') + ')';
i = end + 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else {
re += escapeRegExp(c);
i += 1;
}
}
return new RegExp('^' + re + '$');
}
function compileGlobs(globs) {
return globs.map(globToRegExp);
}
function matchesAny(relPath, regexps) {
return regexps.some((re) => re.test(relPath));
}
function loadConfig(root) {
try {
const raw = fs.readFileSync(path.join(root, '.stale-docs.json'), 'utf8');
const parsed = JSON.parse(raw);
return {
enabled: parsed.enabled !== false,
sourceGlobs: Array.isArray(parsed.sourceGlobs) ? parsed.sourceGlobs : DEFAULTS.sourceGlobs,
docGlobs: Array.isArray(parsed.docGlobs) ? parsed.docGlobs : DEFAULTS.docGlobs,
ignore: Array.isArray(parsed.ignore) ? parsed.ignore : DEFAULTS.ignore,
};
} catch {
return DEFAULTS;
}
}
function walk(root, ignoreRes, limit) {
const out = [];
const stack = ['.'];
while (stack.length && out.length < limit) {
const rel = stack.pop();
let entries;
try {
entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const childRel = rel === '.' ? entry.name : rel + '/' + entry.name;
if (matchesAny(entry.isDirectory() ? childRel + '/' : childRel, ignoreRes)) continue;
if (entry.isDirectory()) {
stack.push(childRel);
} else if (entry.isFile()) {
out.push(childRel);
if (out.length >= limit) break;
}
}
}
return out;
}
function readSmallFile(absPath) {
try {
const stat = fs.statSync(absPath);
if (!stat.isFile() || stat.size > MAX_FILE_SIZE) return null;
return fs.readFileSync(absPath, 'utf8');
} catch {
return null;
}
}
function extractSymbols(content) {
const symbols = new Set();
for (const pattern of SYMBOL_PATTERNS) {
pattern.lastIndex = 0;
let m;
while ((m = pattern.exec(content)) !== null) {
const name = m[1];
if (name.length >= MIN_SYMBOL_LENGTH && !STOP_WORDS.has(name.toLowerCase())) {
symbols.add(name);
}
if (symbols.size >= MAX_SYMBOLS_PER_FILE) return [...symbols];
}
}
FLAG_PATTERN.lastIndex = 0;
let m;
while ((m = FLAG_PATTERN.exec(content)) !== null) {
symbols.add(m[1]);
if (symbols.size >= MAX_SYMBOLS_PER_FILE) break;
}
return [...symbols];
}
function buildSymbolRegExp(symbols) {
if (!symbols.length) return null;
const alternatives = symbols
.slice()
.sort((a, b) => b.length - a.length)
.map(escapeRegExp)
.join('|');
return new RegExp('(?<![\\w$-])(' + alternatives + ')(?![\\w$])');
}
function collectBacktickTokens(line) {
BACKTICK_TOKEN.lastIndex = 0;
const tokens = [];
let m;
while ((m = BACKTICK_TOKEN.exec(line)) !== null) tokens.push(m[1]);
return tokens;
}
// path-like means a concrete repo-relative file name: not a glob, not a
// phrase, not an absolute path or slash command, and not a URL fragment or
// directory mention, which is why a file extension is required
function looksLikePath(token) {
if (/[\s*?{}]/.test(token) || token.startsWith('/')) return false;
if (!/\.[A-Za-z][A-Za-z0-9]{0,7}$/.test(token)) return false;
return token.in