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:
parent
33ef2c1b3a
commit
c7fdbf0d8b
6 changed files with 787 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 }); }
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue