// tests/lib/review-recurrence.test.mjs // P5 — the project's memory (ordre 5). A finding class (the same rule_key) that // shows up in two review.md files in the target repo becomes ONE proposal; one // sighting is no proposal. Proposals are read from the review.md files the repo // already has (no new store), and an approved one is written only between // Voyage's own markers, under a size budget, with CLAUDE.md getting a pointer. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); const CLI = join(ROOT, 'lib', 'review', 'recurrence.mjs'); const mod = existsSync(CLI) ? await import('../../lib/review/recurrence.mjs') : null; function reviewText({ slug, sha, findings }) { const json = JSON.stringify({ verdict: 'WARN', counts: { BLOCKER: 0, MAJOR: findings.length, MINOR: 0, SUGGESTION: 0 }, findings, }, null, 2); return `--- type: trekreview review_version: "1.1" task: "${slug}" slug: ${slug} project_dir: .claude/projects/2026-10-0${sha.length % 9}-${slug}/ brief_path: .claude/projects/x/brief.md scope_sha_end: ${sha} reviewed_files_count: 1 verdict: WARN findings: ${findings.map((f) => ` - ${f.id}`).join('\n')} --- # Review: ${slug} ## Executive Summary x ## Coverage x ## Remediation Summary x \`\`\`json ${json} \`\`\` `; } function finding(id, rule_key, extra = {}) { return { id: id.padEnd(40, '0'), severity: 'MAJOR', rule_key, file: 'lib/a.mjs', line: 3, title: `${rule_key} title ${id}`, detail: 'd', recommended_action: `Fix it (${id}).`, ...extra, }; } function repoWith(reviews) { const dir = mkdtempSync(join(tmpdir(), 'recurrence-')); reviews.forEach((r, i) => { const p = join(dir, '.claude', 'projects', `2026-10-0${i + 1}-${r.slug}`); mkdirSync(p, { recursive: true }); writeFileSync(join(p, 'review.md'), reviewText(r)); }); return dir; } test('recurrence — the module exists', () => { assert.ok(mod, 'lib/review/recurrence.mjs must exist (P5)'); }); test('recurrence — two review.md with the same finding class → exactly one proposal', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST', { file: 'lib/b.mjs' })] }, ]); try { const { proposals } = mod.proposeLessons(repo); assert.equal(proposals.length, 1); assert.equal(proposals[0].rule_key, 'MISSING_TEST'); assert.equal(proposals[0].evidence.length, 2, 'the proposal carries both findings as its evidence'); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — one review.md with the class → no proposal', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'PLACEHOLDER_IN_CODE')] }, ]); try { assert.deepEqual(mod.proposeLessons(repo).proposals, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — the same class twice inside ONE review is still one sighting → no proposal', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST'), finding('a2', 'MISSING_TEST', { line: 9 })] }, ]); try { assert.deepEqual(mod.proposeLessons(repo).proposals, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — routing: mechanical → a check, judgement → a .claude/rules/ file', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'PLACEHOLDER_IN_CODE'), finding('a2', 'PLAN_EXECUTE_DRIFT')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'PLACEHOLDER_IN_CODE'), finding('b2', 'PLAN_EXECUTE_DRIFT')] }, ]); try { const byKey = Object.fromEntries(mod.proposeLessons(repo).proposals.map((p) => [p.rule_key, p])); assert.equal(byKey.PLACEHOLDER_IN_CODE.route, 'check'); assert.equal(byKey.PLAN_EXECUTE_DRIFT.route, 'rule'); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — the proposed line has the three parts: mistake · fix · how to check', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { const [p] = mod.proposeLessons(repo).proposals; assert.match(p.line, /Mistake:/); assert.match(p.line, /Fix:/); assert.match(p.line, /How to check:/); assert.equal(mod.validateLessonLine(p.line).valid, true); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — review-hygiene classes never become target-repo lessons', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_BRIEF_REF'), finding('a2', 'COVERAGE_SILENT_SKIP')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_BRIEF_REF'), finding('b2', 'COVERAGE_SILENT_SKIP')] }, ]); try { assert.deepEqual(mod.proposeLessons(repo).proposals, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — apply writes only between Voyage markers and gives CLAUDE.md a pointer only', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n\nOperator text stays.\n'); mkdirSync(join(repo, '.claude', 'rules'), { recursive: true }); writeFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'Operator preface.\n'); const r = mod.applyLesson(repo, 'MISSING_TEST'); assert.equal(r.written, true, JSON.stringify(r)); const rules = readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'); assert.ok(rules.startsWith('Operator preface.\n'), 'text outside the markers is untouched'); const start = rules.indexOf(mod.MARK_START); const end = rules.indexOf(mod.MARK_END); assert.ok(start >= 0 && end > start); assert.match(rules.slice(start, end), /MISSING_TEST/); const claude = readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'); assert.ok(claude.startsWith('# Project\n\nOperator text stays.\n')); assert.ok(claude.includes(mod.LESSONS_FILE_NAME), 'CLAUDE.md gets a pointer to the rules file'); assert.ok(!claude.includes('Mistake:'), 'CLAUDE.md gets ONLY the pointer, never the lesson'); assert.deepEqual(mod.proposeLessons(repo).proposals, [], 'an applied class is not proposed again'); const again = mod.applyLesson(repo, 'MISSING_TEST'); assert.equal(again.written, false, 'applying twice writes nothing'); const claude2 = readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'); assert.equal(claude2, claude, 'the pointer is written once'); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — apply refuses past the size budget (the lesson belongs in a skill then)', () => { const keys = ['MISSING_TEST', 'PLACEHOLDER_IN_CODE', 'PLAN_EXECUTE_DRIFT', 'SCOPE_CREEP_BUILT']; const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: keys.map((k, i) => finding(`a${i}`, k)) }, { slug: 'two', sha: 'bbb', findings: keys.map((k, i) => finding(`b${i}`, k)) }, ]); try { const r = mod.applyLesson(repo, 'MISSING_TEST', { budgetEntries: 0 }); assert.equal(r.written, false); assert.equal(r.reason, 'over-budget'); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — an edited line without the three parts is refused', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { const r = mod.applyLesson(repo, 'MISSING_TEST', { line: 'just do better' }); assert.equal(r.written, false); assert.equal(r.reason, 'invalid-line'); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — an earlier committed version of the same review.md counts as history (git, no new store)', () => { // review.md is overwritten per iteration; git keeps the earlier one. const repo = repoWith([{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'PLAN_EXECUTE_DRIFT')] }]); const g = (...a) => spawnSync('git', ['-C', repo, ...a], { encoding: 'utf-8' }); try { assert.equal(g('init', '-q').status, 0); g('config', 'user.email', 't@example.invalid'); g('config', 'user.name', 't'); g('add', '.claude'); assert.equal(g('commit', '-q', '-m', 'review 1').status, 0); assert.deepEqual(mod.proposeLessons(repo).proposals, [], 'one iteration is one sighting'); const p = join(repo, '.claude', 'projects', '2026-10-01-one', 'review.md'); writeFileSync(p, reviewText({ slug: 'one', sha: 'bbbb', findings: [finding('b1', 'PLAN_EXECUTE_DRIFT', { line: 40 })] })); const { proposals } = mod.proposeLessons(repo); assert.equal(proposals.length, 1, 'the committed iteration plus the current one recur'); assert.equal(proposals[0].rule_key, 'PLAN_EXECUTE_DRIFT'); } finally { rmSync(repo, { recursive: true, force: true }); } }); // ---- Independent review of d88245c: each of these was wrong before ---------- test('recurrence — the SAME unfixed finding in two iterations is one defect, not a recurring class', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('cc', 'MISSING_TEST')] }, { slug: 'one', sha: 'bbbb', findings: [finding('cc', 'MISSING_TEST')] }, ]); try { assert.deepEqual(mod.proposeLessons(repo).proposals, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — catch-all engine keys and keys normalised from unknown ones are never proposed', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'CODE_REVIEW_DEFECT'), finding('a2', 'SECURITY_REVIEW_VULNERABILITY'), finding('a3', 'PLAN_EXECUTE_DRIFT', { original_rule_key: 'AD_HOC' })] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'CODE_REVIEW_DEFECT'), finding('b2', 'SECURITY_REVIEW_VULNERABILITY'), finding('b3', 'PLAN_EXECUTE_DRIFT', { original_rule_key: 'AD_HOC' })] }, ]); try { assert.deepEqual(mod.proposeLessons(repo).proposals, []); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — a proposal built from long titles, actions and marker text is still a line apply accepts', () => { const long = 'x'.repeat(300); const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST', { title: `` })] }, ]); try { const [p] = mod.proposeLessons(repo).proposals; assert.ok(p, 'the proposal is not lost'); assert.equal(mod.validateLessonLine(p.line).valid, true, p.line); assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — an edited line with \\r or U+2028 (a hidden second lesson) is refused', () => { for (const sep of ['\r', '
']) { const line = `Mistake: x${sep}- [SCOPE_CREEP_BUILT] (rule) Mistake: y Fix: z How to check: w`; assert.equal(mod.validateLessonLine(line).valid, false, JSON.stringify(sep)); } }); test('recurrence — damaged markers refuse the write instead of crashing', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { mkdirSync(join(repo, '.claude', 'rules'), { recursive: true }); writeFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), `${mod.MARK_END}\n${mod.MARK_START}\n`); const r = mod.applyLesson(repo, 'MISSING_TEST'); assert.deepEqual([r.written, r.reason], [false, 'markers-damaged']); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets the pointer', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { writeFileSync(join(repo, 'AGENTS.md'), '# Agents\n'); symlinkSync('AGENTS.md', join(repo, 'CLAUDE.md')); assert.equal(mod.applyLesson(repo, 'MISSING_TEST').written, true); assert.ok(lstatSync(join(repo, 'CLAUDE.md')).isSymbolicLink(), 'the link survives'); assert.ok(readFileSync(join(repo, 'AGENTS.md'), 'utf-8').includes(mod.LESSONS_FILE_NAME)); } finally { rmSync(repo, { recursive: true, force: true }); } }); test('recurrence — CLI propose prints JSON and exits 0; a missing repo exits 2', () => { const repo = repoWith([ { slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] }, { slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] }, ]); try { const r = spawnSync(process.execPath, [CLI, 'propose', '--repo', repo], { encoding: 'utf-8' }); assert.equal(r.status, 0, r.stderr); assert.equal(JSON.parse(r.stdout).proposals.length, 1); const missing = spawnSync(process.execPath, [CLI, 'propose', '--repo', join(repo, 'nope')], { encoding: 'utf-8' }); assert.equal(missing.status, 2); } finally { rmSync(repo, { recursive: true, force: true }); } });