Two comments in build-rollup-register.{mjs,test.mjs} used a since-deleted
repository as the example of a nested same-name git repo. Replaced with the
invented `example-repo/example-repo`; the test fixture (`outer/outer`) and
what it proves are unchanged.
Adds scripts/check-retired-refs.mjs (+ test): fails when a tracked text
file names a retired term in its content or path. The term list is
local-only (scripts/retired-terms.local.md, ignored via *.local.md) and
never tracked; a missing or empty list reports NOT CHECKED (exit 2), and
the repo-level test skips with that reason instead of passing silently.
Named exemption list, empty. Chosen *.local.md because the existing ignore
rule already covers it, so .gitignore is untouched.
Red on 69fb250 (112 files, 2 hits, exit 1) -> green (114 files, 0 hits).
Suite 217 -> 227 tests, 211 -> 221 pass; the same 6 check-okf-parity
failures before and after, caused by the worktree path breaking its
sibling-repo lookup (9/9 green in the main checkout). check-versions:
12/12 OK, 0 ERROR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.3 KiB
JavaScript
112 lines
4.3 KiB
JavaScript
#!/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 <file>]
|
|
// 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)));
|
|
}
|