ktg-plugin-marketplace/scripts/check-retired-refs.mjs
Kjell Tore Guttormsen 4ecc6e2b6a
chore(scripts): remove dead references to a retired repository
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>
2026-09-23 13:47:32 +02:00

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)));
}