R4-R7 of the cleanup list (decision 188): the June trial docs and fixtures, the three orchestrator reference docs, the dormant synthesis-agent, code with no caller (project-discovery, bash-normalize, fidelity-diff, test-census, the next-session prompt validator, arm B and the --workflow flag) and the fix-round chain. Living docs repointed; the reasons for in-session orchestration and the all-opus pin are now written in docs/architecture.md and docs/profiles.md. New tests/lib/living-paths.test.mjs: living tracked text names only plugin paths that exist (seen red with 22 dead pointers before this commit). docs/T1-cc26-delegated-orchestration.md and lib/review/gold-scorer.mjs stay until R-b: the frozen end-state registry names them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
174 lines
7.9 KiB
JavaScript
174 lines
7.9 KiB
JavaScript
// tests/lib/living-paths.test.mjs
|
||
// Living tracked text names only plugin paths that exist. When a file is
|
||
// removed, every command, agent, skill, template and doc that still names it
|
||
// turns this test red, so a removal cannot leave a dead pointer behind.
|
||
//
|
||
// Scope: tracked *.md under agents/ commands/ skills/ templates/ docs/ and the
|
||
// root README.md, CLAUDE.md, CONTRIBUTING.md and SECURITY.md. Out of scope:
|
||
// CHANGELOG.md (history), *.local.* files (never tracked), and tests/,
|
||
// examples/ and evals/, whose text describes target repositories.
|
||
//
|
||
// A reference is (a) a whole backtick span that starts with a top-level plugin
|
||
// directory, with an optional ${CLAUDE_PLUGIN_ROOT}/ prefix and an optional
|
||
// :line suffix; (b) a ${CLAUDE_PLUGIN_ROOT}/<path> token anywhere; (c) a
|
||
// relative markdown link target. Spans with placeholder characters and spans
|
||
// on a credit line (an @<sha> or MIT on the line or the two lines above) are
|
||
// skipped: they name files in other repositories.
|
||
//
|
||
// When this test fails, fix the pointer or remove it; add an EXEMPT entry only
|
||
// for a path that is meant to be absent here, with the reason.
|
||
|
||
import { test } from 'node:test';
|
||
import { strict as assert } from 'node:assert';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
||
import { join, dirname, normalize } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const ROOT = join(HERE, '..', '..');
|
||
|
||
const TOP = '(?:agents|commands|docs|examples|hooks|lib|scripts|skills|templates|tests|evals)';
|
||
const PATH = `${TOP}/[A-Za-z0-9._/-]*[A-Za-z0-9_/-]`;
|
||
const LINES = '(?::\\d+(?:[-–]\\d+)?(?:, ?\\d+(?:[-–]\\d+)?)*)?';
|
||
const SPAN_RE = new RegExp(`^(?:\\$\\{CLAUDE_PLUGIN_ROOT\\}/)?(${PATH})${LINES}$`);
|
||
const ROOT_TOKEN_RE = new RegExp(`\\$\\{CLAUDE_PLUGIN_ROOT\\}/(${PATH})`, 'g');
|
||
const CREDIT_RE = /@[0-9a-f]{7,40}\b|\bMIT\b/;
|
||
const PLACEHOLDER_RE = /[<>{}*$…]|\.\.\./;
|
||
|
||
const SCOPE_DIRS = ['agents/', 'commands/', 'skills/', 'templates/', 'docs/'];
|
||
const SCOPE_ROOT = new Set(['README.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'SECURITY.md']);
|
||
|
||
// Living tracked text in scope (see the header).
|
||
export function inScope(rel) {
|
||
if (!rel.endsWith('.md') || /\.local\./.test(rel)) return false;
|
||
if (rel === 'CHANGELOG.md') return false;
|
||
return SCOPE_ROOT.has(rel) || SCOPE_DIRS.some(d => rel.startsWith(d));
|
||
}
|
||
|
||
// Every plugin path a text names, as { line, path } with path relative to the
|
||
// repository root. `rel` is the text's own path, for relative links.
|
||
export function extractPaths(text, rel) {
|
||
const out = [];
|
||
const lines = text.split('\n');
|
||
lines.forEach((line, i) => {
|
||
const context = lines.slice(Math.max(0, i - 2), i + 1).join('\n');
|
||
if (CREDIT_RE.test(context)) return;
|
||
const seen = new Set();
|
||
const add = p => { if (!seen.has(p)) { seen.add(p); out.push({ line: i + 1, path: p }); } };
|
||
for (const m of line.matchAll(/`([^`\n]+)`/g)) {
|
||
const span = m[1].trim();
|
||
const hit = SPAN_RE.exec(span);
|
||
if (hit && !PLACEHOLDER_RE.test(hit[1])) add(hit[1]);
|
||
}
|
||
for (const m of line.matchAll(ROOT_TOKEN_RE)) {
|
||
if (!PLACEHOLDER_RE.test(m[1])) add(m[1]);
|
||
}
|
||
for (const m of line.matchAll(/\]\(([^)\s]+)\)/g)) {
|
||
const target = m[1].split('#')[0];
|
||
if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('/')) continue;
|
||
if (PLACEHOLDER_RE.test(target)) continue;
|
||
const resolved = normalize(join(dirname(rel), target)).replace(/\\/g, '/');
|
||
if (resolved.startsWith('..')) continue;
|
||
add(resolved.replace(/\/$/, ''));
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// "file:line path" for every named path that does not exist under root.
|
||
export function deadPaths(root, files) {
|
||
const dead = [];
|
||
for (const rel of files) {
|
||
const text = readFileSync(join(root, rel), 'utf-8');
|
||
for (const { line, path } of extractPaths(text, rel)) {
|
||
if (!existsSync(join(root, path))) dead.push(`${rel}:${line} ${path}`);
|
||
}
|
||
}
|
||
return dead;
|
||
}
|
||
|
||
function trackedFiles(root) {
|
||
const out = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf-8' });
|
||
return out.split('\0').filter(Boolean);
|
||
}
|
||
|
||
// Named exceptions: "file path" -> reason. Each must still be dead (a stale
|
||
// exemption fails below).
|
||
const TARGET = 'target-repo example';
|
||
const RB = 'removed in R-b';
|
||
const EXEMPT = new Map([
|
||
['commands/trekplan.md tests/e2e.sh', TARGET],
|
||
['docs/HANDOVER-CONTRACTS.md docs/specs/api.md', TARGET],
|
||
['templates/trekreview-template.md lib/foo.mjs', TARGET],
|
||
['docs/release-runbook.md scripts/release-plugin.mjs', "the catalog repo's script"],
|
||
['docs/T1-cc26-delegated-orchestration.md scripts/q3-cache-prefix-experiment.mjs', RB],
|
||
['docs/T1-cc26-delegated-orchestration.md docs/cc-upgrade-2.1.181-decision-matrix.md', RB],
|
||
['docs/T1-cc26-delegated-orchestration.md docs/subagent-delegation-audit.md', RB],
|
||
['docs/T1-cc26-delegated-orchestration.md agents/planning-orchestrator.md', RB],
|
||
['docs/T1-cc26-delegated-orchestration.md docs/T1-synthesis-poc-results.md', RB],
|
||
['docs/T1-cc26-delegated-orchestration.md agents/synthesis-agent.md', RB],
|
||
['docs/deep-research-engine-research.md docs/cc-upgrade-2.1.181-decision-matrix.md', RB],
|
||
]);
|
||
|
||
const exemptKey = hit => hit.replace(/:\d+ /, ' ');
|
||
|
||
test('extractPaths reads spans, plugin-root tokens and relative links (known-positive)', () => {
|
||
const text = [
|
||
'See `lib/util/present.mjs:12-14` and `lib/util/missing.mjs`.',
|
||
'Run node ${CLAUDE_PLUGIN_ROOT}/scripts/gone.mjs --json.',
|
||
'Read [the doc](../docs/away.md#part) or [site](https://example.com).',
|
||
'Placeholder `tests/<name>.sh` and glob `tests/**/*.test.mjs` are skipped.',
|
||
'Credit: someone/repo @c55ee46, `skills/other/SKILL.md`, MIT.',
|
||
].join('\n');
|
||
const got = extractPaths(text, 'commands/x.md').map(h => `${h.line} ${h.path}`);
|
||
assert.deepEqual(got, [
|
||
'1 lib/util/present.mjs',
|
||
'1 lib/util/missing.mjs',
|
||
'2 scripts/gone.mjs',
|
||
'3 docs/away.md',
|
||
]);
|
||
});
|
||
|
||
test('deadPaths reports only the missing paths (known-positive)', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'living-paths-'));
|
||
try {
|
||
mkdirSync(join(dir, 'lib'), { recursive: true });
|
||
mkdirSync(join(dir, 'docs'), { recursive: true });
|
||
writeFileSync(join(dir, 'lib', 'present.mjs'), '\n');
|
||
writeFileSync(join(dir, 'docs', 'a.md'), 'Uses `lib/present.mjs` and `lib/absent.mjs`.\n');
|
||
assert.deepEqual(deadPaths(dir, ['docs/a.md']), ['docs/a.md:1 lib/absent.mjs']);
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('scope keeps living docs and drops history and target-repo text', () => {
|
||
assert.ok(inScope('commands/trekplan.md'));
|
||
assert.ok(inScope('docs/architecture.md'));
|
||
assert.ok(inScope('README.md'));
|
||
assert.ok(!inScope('CHANGELOG.md'));
|
||
assert.ok(!inScope('docs/notes.local.md'));
|
||
assert.ok(!inScope('tests/fixtures/x.md'));
|
||
assert.ok(!inScope('examples/02-real-cli/brief.md'));
|
||
assert.ok(!inScope('lib/util/frontmatter.mjs'));
|
||
});
|
||
|
||
test('living tracked text names only paths that exist', () => {
|
||
const files = trackedFiles(ROOT);
|
||
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
|
||
const hits = deadPaths(ROOT, files.filter(inScope)).filter(h => !EXEMPT.has(exemptKey(h)));
|
||
assert.deepEqual(hits, [], `${hits.length} dead path reference(s):\n${hits.join('\n')}`);
|
||
});
|
||
|
||
test('every EXEMPT entry is still a dead reference in a tracked file', () => {
|
||
const tracked = new Set(trackedFiles(ROOT));
|
||
for (const key of EXEMPT.keys()) {
|
||
const [rel, path] = key.split(' ');
|
||
assert.ok(tracked.has(rel), `EXEMPT names untracked ${rel}`);
|
||
const dead = deadPaths(ROOT, [rel]).map(exemptKey);
|
||
assert.ok(dead.includes(key), `EXEMPT entry "${key}" is no longer dead; remove it`);
|
||
assert.ok(!existsSync(join(ROOT, path)), `EXEMPT path ${path} exists`);
|
||
}
|
||
});
|