// Tests for the retired-reference gate. The unit tests use synthetic terms; only the last // test reads the real, local-only term list, and it SKIPS loudly when that file is absent. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { DEFAULT_TERMS_FILE, EXEMPT_PATHS, loadTerms, buildPattern, scanEntries, runCheck, } from './check-retired-refs.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO = join(HERE, '..'); const CLI = join(HERE, 'check-retired-refs.mjs'); const TERMS = ['retired-alpha', 'Beta.Tool', 'gamma']; const PATTERN = buildPattern(TERMS); function tmpDir(prefix) { return mkdtempSync(join(tmpdir(), prefix)); } function termsFile(lines) { const file = join(tmpDir('retired-terms-'), 'terms.local.md'); writeFileSync(file, lines.join('\n')); return file; } test('loadTerms: one term per line, blanks and # lines ignored, missing file -> null', () => { const file = termsFile(['# comment', '', ' retired-alpha ', 'gamma', '']); assert.deepEqual(loadTerms(file), ['retired-alpha', 'gamma']); assert.equal(loadTerms(join(tmpDir('retired-none-'), 'absent.md')), null); }); test('buildPattern: case-insensitive plain substrings, regex metacharacters escaped', () => { assert.match('uses RETIRED-ALPHA here', PATTERN); assert.match('beta.tool', PATTERN); assert.doesNotMatch('betaXtool', PATTERN, '"." is literal, not a wildcard'); assert.doesNotMatch('repo-mailbox, check-versions', PATTERN); assert.throws(() => buildPattern([]), /empty term list/); }); test('scanEntries reports content hits with line numbers and path hits', () => { const findings = scanEntries( [ { path: 'clean.md', content: 'nothing here\n' }, { path: 'a.md', content: 'line one\nuses retired-alpha/retired-alpha\n' }, { path: 'docs/gamma.md', content: 'clean body\n' }, ], { pattern: PATTERN }, ); assert.deepEqual( findings.map((f) => [f.path, f.kind, f.line]), [ ['a.md', 'content', 2], ['docs/gamma.md', 'path', 0], ], ); }); test('scanEntries skips binary content but still checks its path', () => { const findings = scanEntries( [ { path: 'img.png', content: null }, { path: 'gamma.bin', content: null }, ], { pattern: PATTERN }, ); assert.deepEqual(findings.map((f) => f.path), ['gamma.bin']); }); test('scanEntries honours the named exemption list', () => { const entries = [{ path: 'keep.md', content: 'gamma' }]; assert.equal(scanEntries(entries, { pattern: PATTERN }).length, 1); assert.equal(scanEntries(entries, { pattern: PATTERN, exempt: ['keep.md'] }).length, 0); assert.deepEqual(EXEMPT_PATHS, [], 'the shipped exemption list is empty'); }); function tmpRepo(files) { const root = tmpDir('retired-refs-'); execFileSync('git', ['init', '-q', root]); for (const [path, body] of Object.entries(files)) { mkdirSync(dirname(join(root, path)), { recursive: true }); writeFileSync(join(root, path), body); } execFileSync('git', ['-C', root, 'add', '-A']); return root; } test('runCheck reads the git index: a staged hit is found, an untracked one is not', () => { const root = tmpRepo({ 'README.md': 'points at gamma\n' }); writeFileSync(join(root, 'untracked.md'), 'retired-alpha'); const { checked, findings } = runCheck(root, TERMS); assert.equal(checked, 1); assert.deepEqual(findings.map((f) => `${f.path}:${f.line}`), ['README.md:1']); }); test('CLI exits 1 on a hit and 0 on a clean tree, always printing the denominator', () => { const terms = termsFile(TERMS); const run = (root) => spawnSync(process.execPath, [CLI, root, '--terms', terms], { encoding: 'utf8' }); const dirty = run(tmpRepo({ 'x.md': 'retired-alpha' })); assert.equal(dirty.status, 1); assert.match(dirty.stdout, /\[ERROR\] retired reference — x\.md:1/); assert.match(dirty.stdout, /checked 1 tracked files against 3 terms — 1 hit/); const clean = run(tmpRepo({ 'x.md': 'clean\n' })); assert.equal(clean.status, 0); assert.match(clean.stdout, /checked 1 tracked files against 3 terms — 0 hit/); }); test('CLI fails on an empty index: 0 files checked verified nothing', () => { const empty = tmpDir('retired-refs-empty-'); execFileSync('git', ['init', '-q', empty]); const r = spawnSync(process.execPath, [CLI, empty, '--terms', termsFile(TERMS)], { encoding: 'utf8' }); assert.equal(r.status, 1); assert.match(r.stdout, /verified nothing/); }); test('CLI reports NOT CHECKED (exit 2) for a missing or empty term list, never a pass', () => { const root = tmpRepo({ 'x.md': 'retired-alpha' }); const missing = spawnSync(process.execPath, [CLI, root, '--terms', join(root, 'absent.md')], { encoding: 'utf8' }); assert.equal(missing.status, 2); assert.match(missing.stdout, /NOT CHECKED — term list missing/); const empty = spawnSync(process.execPath, [CLI, root, '--terms', termsFile(['# only a comment'])], { encoding: 'utf8' }); assert.equal(empty.status, 2); assert.match(empty.stdout, /NOT CHECKED — term list empty/); }); // The FERDIG criterion: this repository's own tracked files carry zero retired references. // The real term list is local-only by design; without it this test cannot measure anything, // so it skips with the reason instead of passing silently. test('this repository: 0 retired references across its tracked files', (t) => { const terms = loadTerms(); if (!terms || terms.length === 0) { t.skip(`NOT CHECKED — local term list missing or empty: ${DEFAULT_TERMS_FILE}`); return; } const { checked, findings } = runCheck(REPO, terms); assert.ok(checked > 0, 'checked 0 files — verified nothing'); assert.deepEqual(findings.map((f) => `${f.path}:${f.line}`), []); });