#!/usr/bin/env node // Retired-reference gate: fails when a tracked text file names, in its content or in its // path, a repository or subject that has been removed from the org. A reference to a // deleted repository is a dead link the day it lands, and a public catalog must not point // readers at something that no longer exists. // // Usage: node scripts/check-retired-refs.mjs [repo-root] [--terms ] // exit 0 = checked, 0 hits · exit 1 = hit(s), or 0 files checked · exit 2 = NOT CHECKED // // The term list is local-only (default: scripts/retired-terms.local.md, gitignored via // *.local.md) and never tracked: one term per line, blank lines and lines starting with # // ignored, matched case-insensitively as plain substrings. A missing or empty list is // reported as NOT CHECKED — never as a pass. import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); export const DEFAULT_TERMS_FILE = join(HERE, 'retired-terms.local.md'); // Named exceptions: tracked paths allowed to match. Empty by design — add a path here only // with a comment saying why the reference must stay. export const EXEMPT_PATHS = []; // Returns the list of terms, or null when the file does not exist. export function loadTerms(file = DEFAULT_TERMS_FILE) { if (!existsSync(file)) return null; return readFileSync(file, 'utf8') .split('\n') .map((l) => l.trim()) .filter((l) => l && !l.startsWith('#')); } function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function buildPattern(terms) { if (!terms || terms.length === 0) throw new Error('buildPattern: empty term list'); return new RegExp(terms.map(escapeRegExp).join('|'), 'i'); } // entries: [{ path, content }] where content is a string, or null for a binary file. // Returns one finding per matching path and per matching content line. export function scanEntries(entries, { pattern, exempt = EXEMPT_PATHS }) { const findings = []; for (const { path, content } of entries) { if (exempt.includes(path)) continue; if (pattern.test(path)) findings.push({ path, kind: 'path', line: 0, text: path }); if (content === null) continue; content.split('\n').forEach((text, i) => { if (pattern.test(text)) findings.push({ path, kind: 'content', line: i + 1, text: text.trim() }); }); } return findings; } export function listTracked(root) { const out = execFileSync('git', ['-C', root, 'ls-files', '-z'], { encoding: 'utf8' }); return out.split('\0').filter(Boolean); } export function readEntries(root, paths) { const entries = []; for (const path of paths) { const abs = join(root, path); if (!existsSync(abs)) continue; // tracked but deleted in the working tree const buf = readFileSync(abs); entries.push({ path, content: buf.includes(0) ? null : buf.toString('utf8') }); } return entries; } export function runCheck(root, terms) { const entries = readEntries(root, listTracked(root)); const findings = scanEntries(entries, { pattern: buildPattern(terms) }); return { checked: entries.length, findings }; } function parseArgs(argv) { const args = { root: join(HERE, '..'), termsFile: DEFAULT_TERMS_FILE }; for (let i = 0; i < argv.length; i++) { if (argv[i] === '--terms') args.termsFile = argv[++i]; else args.root = argv[i]; } return args; } function main(argv) { const { root, termsFile } = parseArgs(argv); const terms = loadTerms(termsFile); if (!terms || terms.length === 0) { console.log(`check-retired-refs: NOT CHECKED — term list ${terms ? 'empty' : 'missing'}: ${termsFile}`); return 2; } const { checked, findings } = runCheck(root, terms); for (const f of findings) { const where = f.kind === 'path' ? `${f.path} (path)` : `${f.path}:${f.line}`; console.log(`[ERROR] retired reference — ${where}: ${f.text}`); } console.log(`check-retired-refs: checked ${checked} tracked files against ${terms.length} terms — ${findings.length} hit(s)`); if (checked === 0) { console.log('check-retired-refs: 0 files checked — verified nothing'); return 1; } return findings.length > 0 ? 1 : 0; } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { process.exit(main(process.argv.slice(2))); }