// 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}/ token anywhere; (c) a // relative markdown link target. Spans with placeholder characters and spans // on a credit line (an @ 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/.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`); } });