fix(review): applying a lesson skips a symlinked CLAUDE.md and an ignored lessons file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 17:27:33 +01:00
commit c7fdbf0d8b
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 787 additions and 24 deletions

View file

@ -36,6 +36,8 @@
// E14 /trekexecute keeps `run_seen` in the progress file (template, field
// table, Phase 7) and Phase 8 reads it from there, so the resume and
// multi-session paths report it; Handover 5 lists it as optional.
// DJ4 /trekexecute's Phase 7.2 step 3 commits only the review's paths git
// does not ignore, and says an ignored one is left uncommitted.
// E17a The two /trekresearch wave 2 sentences on outbound queries are pinned
// exactly (red under the E17a mutation).
// E17b The deep-research adapter says where wave 2's contrarian findings merge.
@ -415,6 +417,21 @@ test('[E14] Handover 5 lists run_seen as optional', () => {
assert.match(rows[0], /Phase 7/, `the run_seen row does not say Phase 7 records it: ${rows[0]}`);
});
// --- DJ4 ---------------------------------------------------------------------------
test('[DJ4] Phase 7.2 step 3 commits only paths git does not ignore', () => {
const step3 = between('commands/trekexecute.md', '3. **`/trekreview`**', '4. **`verdict-gate.mjs`**');
const hits = sentencesWith(step3, 'git does not ignore');
assert.ok(hits.some((s) => /^Commit\b/.test(s) && /`git check-ignore -q -- \{path\}`/.test(s)
&& /`docs\(voyage\): review lessons \(round \{R\}\)`/.test(s)),
`Phase 7.2 step 3 does not say it commits only the changed paths git does not ignore, checked with git check-ignore: ${hits.join(' | ')}`);
const left = sentencesWith(step3, 'ignored');
assert.ok(left.some((s) => /left uncommitted/.test(s) && /`"ignored-skipped"`/.test(s) && /`"symlink-skipped"`/.test(s)),
`Phase 7.2 step 3 does not say an ignored path is left uncommitted and name the two skipped pointer results: ${left.join(' | ')}`);
assert.doesNotMatch(flat(step3), /Nothing the review wrote is left uncommitted/,
'Phase 7.2 step 3 still says nothing the review wrote is left uncommitted');
});
// --- E17a / E17b / Da / E18a / Db --------------------------------------------------
// E17a, E18a and Db are already right at be0b631; their tests are proven red
// under the E17a, E18a and Db entries of tests/fix-round-4-5b-mutations.mjs.

View file

@ -8,10 +8,13 @@
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 {
mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync, symlinkSync, lstatSync, chmodSync, readdirSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
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');
@ -75,6 +78,15 @@ function repoWith(reviews) {
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)');
});
@ -280,7 +292,10 @@ test('recurrence — damaged markers refuse the write instead of crashing', () =
} finally { rmSync(repo, { recursive: true, force: true }); }
});
test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets the pointer', () => {
// 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')] },
@ -290,7 +305,158 @@ test('recurrence — a symlinked CLAUDE.md keeps its link and the target gets th
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));
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 }); }
});