voyage/tests/lib/living-paths.test.mjs
Kjell Tore Guttormsen 32621dcb63
chore(cleanup): R-b R2 — STORM loop, cap hook and the --engine research choice removed
/trekresearch loses Phase 4.5, the bounded Phase 5 loop (Phase 5 keeps its
single pass of at most 2 follow-ups), the --engine flag, the deep-research
adapter and its output self-check. hooks.json loses the pre-agent-cap
PreToolUse entry (README: 9 hooks = 9 scripts). The STORM env vars leave the
docs; query-privacy-gate.mjs stays, called by no command, and says so.

Files removed: 13 (4 docs, 1 hook, 1 lib module, 1 script, 5 tests, 1 fixture).
Files edited: 17. Tests: 2600 (2598 pass / 0 fail / 2 skipped) before,
2467 (2466 pass / 0 fail / 1 skipped, R3 pending) after.
Durable check: group R2 green and no longer pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:32:38 +01:00

172 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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).
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.
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.
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;
}
let scan = null;
/** The tracked files and every dead hit in the in-scope ones (EXEMPT not applied), computed once. */
function repoScan() {
if (!scan) {
const files = execFileSync('git', ['ls-files', '-z'], { cwd: ROOT, encoding: 'utf-8' }).split('\0').filter(Boolean);
scan = { files, dead: deadPaths(ROOT, files.filter(inScope)) };
}
return scan;
}
// Named exceptions: "file path" -> reason. Each must still be dead (a stale
// exemption fails below).
const TARGET = 'target-repo example';
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"],
]);
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, dead } = repoScan();
assert.ok(files.length > 100, `git ls-files returned ${files.length} files; refusing to pass on an empty scan`);
const hits = dead.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 { files, dead } = repoScan();
const tracked = new Set(files);
const deadKeys = new Set(dead.map(exemptKey));
for (const key of EXEMPT.keys()) {
const rel = key.split(' ')[0];
assert.ok(tracked.has(rel), `EXEMPT names untracked ${rel}`);
assert.ok(deadKeys.has(key), `EXEMPT entry "${key}" is no longer dead; remove it`);
}
});