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>
This commit is contained in:
parent
74c27d0e8e
commit
4ecc6e2b6a
4 changed files with 260 additions and 2 deletions
|
|
@ -206,7 +206,7 @@ export function buildRegister({ repos }) {
|
||||||
// state-bearing directories — a fact, not a guarantee, hence the collision gate below.
|
// state-bearing directories — a fact, not a guarantee, hence the collision gate below.
|
||||||
//
|
//
|
||||||
// Not descending into a git repo also makes the known nested-name trap
|
// Not descending into a git repo also makes the known nested-name trap
|
||||||
// (claude-code-100x/claude-code-100x, both git repos) structurally unreachable rather than
|
// (e.g. example-repo/example-repo, both git repos) structurally unreachable rather than
|
||||||
// merely absent.
|
// merely absent.
|
||||||
//
|
//
|
||||||
// "Is a repo" tests `.git` for EXISTENCE, not directory-ness: a worktree or submodule has
|
// "Is a repo" tests `.git` for EXISTENCE, not directory-ness: a worktree or submodule has
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ test('D1: repos under a polyrepo container (depth 2) are discovered', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('D1: a git repo is NOT descended into (the nested same-name trap stays unreachable)', () => {
|
test('D1: a git repo is NOT descended into (the nested same-name trap stays unreachable)', () => {
|
||||||
// claude-code-100x/claude-code-100x: the outer IS a git repo, so board.sh's rule never
|
// example-repo/example-repo: the outer IS a git repo, so board.sh's rule never
|
||||||
// reaches the inner one. Matching that rule makes this collision structurally impossible
|
// reaches the inner one. Matching that rule makes this collision structurally impossible
|
||||||
// rather than merely absent today.
|
// rather than merely absent today.
|
||||||
const root = tree({ 'outer': MARKER, 'outer/outer': MARKER });
|
const root = tree({ 'outer': MARKER, 'outer/outer': MARKER });
|
||||||
|
|
|
||||||
112
scripts/check-retired-refs.mjs
Normal file
112
scripts/check-retired-refs.mjs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
#!/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)));
|
||||||
|
}
|
||||||
146
scripts/check-retired-refs.test.mjs
Normal file
146
scripts/check-retired-refs.test.mjs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
// 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}`), []);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue