475 lines
22 KiB
JavaScript
475 lines
22 KiB
JavaScript
// 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, chmodSync, readdirSync,
|
||
} from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join, dirname, delimiter } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { childEnv } from '../helpers/child-env.mjs';
|
||
|
||
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;
|
||
}
|
||
|
||
/** The same reviews written as `<claudeDir>/projects/...` (a .claude directory kept elsewhere). */
|
||
function reviewsInto(claudeDir, reviews) {
|
||
reviews.forEach((r, i) => {
|
||
const p = join(claudeDir, 'projects', `2026-10-0${i + 1}-${r.slug}`);
|
||
mkdirSync(p, { recursive: true });
|
||
writeFileSync(join(p, 'review.md'), reviewText(r));
|
||
});
|
||
}
|
||
|
||
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: `<!-- ${long}`, file: `lib/${long}.mjs`, recommended_action: long })] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST', { title: long, recommended_action: `${long} -->` })] },
|
||
]);
|
||
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 }); }
|
||
});
|
||
|
||
// DJ4 (4.5b): the pointer is no longer written through a symlinked CLAUDE.md.
|
||
// Committing the link's target would change the R6 exception approved in 4.5a,
|
||
// so the link target stays untouched and the result says why.
|
||
test('[DJ4] a symlinked CLAUDE.md keeps its link and the link target is untouched', () => {
|
||
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.equal(readFileSync(join(repo, 'AGENTS.md'), 'utf-8'), '# Agents\n',
|
||
'the link target is untouched: no pointer is written through the link');
|
||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||
});
|
||
|
||
test('[DJ4] a symlinked CLAUDE.md gets no pointer and the result says symlink-skipped', () => {
|
||
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
|
||
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'));
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, true, JSON.stringify(r));
|
||
assert.equal(r.pointer, 'symlink-skipped', JSON.stringify(r));
|
||
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/,
|
||
'the lesson itself is still written');
|
||
assert.ok(!readFileSync(join(repo, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START), 'no pointer through the link');
|
||
} finally { rmSync(repo, { recursive: true, force: true }); }
|
||
});
|
||
|
||
/** A git repository holding the two recurring reviews and a plain CLAUDE.md. */
|
||
function gitRepoWithClaude() {
|
||
const repo = repoWith([
|
||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||
]);
|
||
const init = spawnSync('git', ['-C', repo, 'init', '-q'], { encoding: 'utf-8', env: childEnv() });
|
||
assert.equal(init.status, 0, init.stderr);
|
||
writeFileSync(join(repo, 'CLAUDE.md'), '# Project\n');
|
||
return repo;
|
||
}
|
||
|
||
test('[PTR] an ignored lessons file gets no pointer and the result says ignored-skipped', () => {
|
||
assert.equal(typeof mod.applyLesson, 'function', 'applyLesson must be exported');
|
||
const repo = gitRepoWithClaude();
|
||
const control = gitRepoWithClaude();
|
||
try {
|
||
writeFileSync(join(repo, '.gitignore'), '.claude/rules/\n');
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, true, JSON.stringify(r));
|
||
assert.equal(r.pointer, 'ignored-skipped', JSON.stringify(r));
|
||
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n',
|
||
'CLAUDE.md gets no pointer to a file git ignores');
|
||
|
||
// Control: the same repo shape without the ignore rule (check-ignore exits 1) gets its pointer.
|
||
const c = mod.applyLesson(control, 'MISSING_TEST');
|
||
assert.equal(c.pointer, 'written', JSON.stringify(c));
|
||
assert.ok(readFileSync(join(control, 'CLAUDE.md'), 'utf-8').includes(mod.POINTER_START));
|
||
} finally {
|
||
rmSync(repo, { recursive: true, force: true });
|
||
rmSync(control, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('[PTR] a git that fails check-ignore gives check-failed and no pointer', () => {
|
||
const repo = gitRepoWithClaude();
|
||
const bin = mkdtempSync(join(tmpdir(), 'recurrence-fake-git-'));
|
||
try {
|
||
// A fake git first on PATH: every call fails with exit 3 (not 0, 1 or 128).
|
||
writeFileSync(join(bin, 'git'), '#!/bin/sh\necho "fake git: refused" >&2\nexit 3\n');
|
||
chmodSync(join(bin, 'git'), 0o755);
|
||
const r = spawnSync(process.execPath, [CLI, 'apply', '--repo', repo, '--rule-key', 'MISSING_TEST'], {
|
||
encoding: 'utf-8',
|
||
env: childEnv({ PATH: `${bin}${delimiter}${process.env.PATH ?? ''}` }),
|
||
});
|
||
assert.equal(r.status, 0, `${r.stdout}\n${r.stderr}`);
|
||
const out = JSON.parse(r.stdout);
|
||
assert.equal(out.written, true, r.stdout);
|
||
assert.equal(out.pointer, 'check-failed', `the CLI prints the pointer result: ${r.stdout}`);
|
||
assert.equal(typeof out.pointer_note, 'string', `check-failed carries the git note: ${r.stdout}`);
|
||
assert.match(out.pointer_note, /check-ignore/, r.stdout);
|
||
assert.equal(readFileSync(join(repo, 'CLAUDE.md'), 'utf-8'), '# Project\n', 'no pointer when the check failed');
|
||
} finally {
|
||
rmSync(repo, { recursive: true, force: true });
|
||
rmSync(bin, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('[DJ4] sibling: a symlinked lessons file is not written through', () => {
|
||
const repo = repoWith([
|
||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||
]);
|
||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||
try {
|
||
writeFileSync(join(outside, 'target.md'), 'outside\n');
|
||
mkdirSync(join(repo, '.claude', 'rules'), { recursive: true });
|
||
const lessons = join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME);
|
||
symlinkSync(join(outside, 'target.md'), lessons);
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, false, JSON.stringify(r));
|
||
assert.match(String(r.reason), /symlink/, JSON.stringify(r));
|
||
assert.equal(readFileSync(join(outside, 'target.md'), 'utf-8'), 'outside\n', 'the link target is untouched');
|
||
assert.ok(lstatSync(lessons).isSymbolicLink(), 'the link itself is left as it was');
|
||
} finally {
|
||
rmSync(repo, { recursive: true, force: true });
|
||
rmSync(outside, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('[DJ4] sibling: a symlinked .claude/rules directory pointing outside the repo is not written through', () => {
|
||
const repo = repoWith([
|
||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||
]);
|
||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||
try {
|
||
symlinkSync(outside, join(repo, '.claude', 'rules'));
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, false, JSON.stringify(r));
|
||
assert.match(String(r.reason), /\.claude\/rules/, `the reason names the directory: ${JSON.stringify(r)}`);
|
||
assert.deepEqual(readdirSync(outside), [], 'nothing is written outside the repo');
|
||
} finally {
|
||
rmSync(repo, { recursive: true, force: true });
|
||
rmSync(outside, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('[DJ4] sibling: a symlinked .claude directory pointing outside the repo gets no directory made outside', () => {
|
||
const outside = mkdtempSync(join(tmpdir(), 'recurrence-outside-'));
|
||
const repo = mkdtempSync(join(tmpdir(), 'recurrence-'));
|
||
try {
|
||
// The reviews live outside; the repo's .claude is a link to them.
|
||
reviewsInto(outside, [
|
||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||
]);
|
||
symlinkSync(outside, join(repo, '.claude'));
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, false, JSON.stringify(r));
|
||
assert.match(String(r.reason), /\.claude\b/, `the reason names the directory: ${JSON.stringify(r)}`);
|
||
assert.ok(!existsSync(join(outside, 'rules')), 'no directory is created through the link');
|
||
} finally {
|
||
rmSync(repo, { recursive: true, force: true });
|
||
rmSync(outside, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('[DJ4] sibling: the first lesson creates the missing lessons directory, one level after its parent, and is written', () => {
|
||
const repo = repoWith([
|
||
{ slug: 'one', sha: 'aaa', findings: [finding('a1', 'MISSING_TEST')] },
|
||
{ slug: 'two', sha: 'bbb', findings: [finding('b1', 'MISSING_TEST')] },
|
||
]);
|
||
try {
|
||
assert.ok(!existsSync(join(repo, '.claude', 'rules')), 'fixture: no .claude/rules yet');
|
||
const r = mod.applyLesson(repo, 'MISSING_TEST');
|
||
assert.equal(r.written, true, JSON.stringify(r));
|
||
assert.ok(lstatSync(join(repo, '.claude', 'rules')).isDirectory(), '.claude/rules is created as a directory');
|
||
assert.match(readFileSync(join(repo, '.claude', 'rules', mod.LESSONS_FILE_NAME), 'utf-8'), /MISSING_TEST/);
|
||
} 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 }); }
|
||
});
|