fix(review): an append of review answers to the approval log keeps the review current
This commit is contained in:
parent
5709957e10
commit
ce64851a0d
3 changed files with 424 additions and 6 deletions
338
tests/lib/review-answers.test.mjs
Normal file
338
tests/lib/review-answers.test.mjs
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
// tests/lib/review-answers.test.mjs
|
||||
// [RAP] fix round 4.5b2 part B, Step 3 (R6): a commit that only appends review
|
||||
// answers (K2 `review` records) to the top-level approval log after the
|
||||
// reviewed commit keeps the review current. Any other change to the log stays
|
||||
// `review-stale:`. Tests run the verdict gate's CLI only, in throwaway git
|
||||
// repos with no hooks; every record a tagged test needs is written with
|
||||
// `lib/util/approval-log.mjs append`, never by hand.
|
||||
// `keep:` cases hold at the base already (today's R6 rejects any change to the
|
||||
// log) and stay untagged.
|
||||
|
||||
import { after, test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MARK_START, MARK_END, POINTER_START, POINTER_LINE, POINTER_END } from '../../lib/review/recurrence.mjs';
|
||||
import { GENESIS, LOG_RELPATH, recordHash } from '../../lib/util/approval-log.mjs';
|
||||
import { childEnv } from '../helpers/child-env.mjs';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
|
||||
const APPROVAL_LOG = join(ROOT, 'lib', 'util', 'approval-log.mjs');
|
||||
|
||||
const LESSONS = '.claude/rules/voyage-review-lessons.md';
|
||||
const POINTER = `${POINTER_START}\n${POINTER_LINE}\n${POINTER_END}\n`;
|
||||
const LESSONS_BEFORE = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${MARK_END}\n\nKept by hand.\n`;
|
||||
const LESSON = '- [MISSING_TEST] (check) Mistake: m. · Fix: f. · How to check: c.';
|
||||
const LESSONS_AFTER = `# Review lessons\n\n${MARK_START}\nEach line: a finding class.\n${LESSON}\n${MARK_END}\n\nKept by hand.\n`;
|
||||
const REVIEW_REL = '.claude/projects/2026-11-02-s/review.md';
|
||||
const NEW_REASON = /\.claude\/voyage\/approvals\.jsonl changed other than by appended review answers \(commit [0-9a-f]{8}\)/;
|
||||
|
||||
const hex = (c) => c.repeat(64);
|
||||
|
||||
/** A minimal ALLOW review of `sha` (shape copied from tests/lib/verdict-gate.test.mjs `review`). */
|
||||
function review(sha) {
|
||||
const id = '1'.padStart(40, 'a');
|
||||
const json = JSON.stringify({
|
||||
verdict: 'ALLOW',
|
||||
counts: { BLOCKER: 0, MAJOR: 0, MINOR: 1, SUGGESTION: 0 },
|
||||
allow_blocked_by: [],
|
||||
findings: [{ id, severity: 'MINOR', rule_key: 'MISSING_TEST', file: 'a.mjs', line: 1, title: 't', detail: 'd', recommended_action: 'Add a test.' }],
|
||||
}, null, 2);
|
||||
return `---
|
||||
type: trekreview
|
||||
review_version: "1.1"
|
||||
task: "t"
|
||||
slug: s
|
||||
project_dir: .claude/projects/2026-11-02-s/
|
||||
brief_path: .claude/projects/2026-11-02-s/brief.md
|
||||
scope_sha_end: ${sha}
|
||||
reviewed_files_count: 1
|
||||
verdict: ALLOW
|
||||
findings:
|
||||
- ${id}
|
||||
---
|
||||
|
||||
# Review: t
|
||||
|
||||
## Executive Summary
|
||||
|
||||
x
|
||||
|
||||
## Coverage
|
||||
|
||||
| File | Treatment | Reason |
|
||||
|------|-----------|--------|
|
||||
| a.mjs | summary-only | default |
|
||||
|
||||
## Remediation Summary
|
||||
|
||||
x
|
||||
|
||||
\`\`\`json
|
||||
${json}
|
||||
\`\`\`
|
||||
`;
|
||||
}
|
||||
|
||||
function g(cwd, ...args) {
|
||||
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
function putIn(dir, rel, text) {
|
||||
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
||||
writeFileSync(join(dir, rel), text);
|
||||
}
|
||||
|
||||
const made = [];
|
||||
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
|
||||
|
||||
/**
|
||||
* A throwaway repo with one reviewed commit: a.mjs, CLAUDE.md and the lessons
|
||||
* file in the project dir (`sub` puts the project in a subdirectory), and the
|
||||
* top-level approval log holding the records `seed` names (kinds, appended
|
||||
* with the CLI before the reviewed commit; none = no log).
|
||||
*/
|
||||
function fixture({ sub = '', seed = ['plan'] } = {}) {
|
||||
const base = mkdtempSync(join(tmpdir(), 'review-answers-rap-'));
|
||||
made.push(base);
|
||||
const repo = join(base, 'repo');
|
||||
const dir = sub ? join(repo, sub) : repo;
|
||||
mkdirSync(dir, { recursive: true });
|
||||
mkdirSync(join(base, 'hooks-none'));
|
||||
g(repo, 'init', '-q', '-b', 'main');
|
||||
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
|
||||
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
|
||||
putIn(dir, 'a.mjs', 'export const a = 1;\n');
|
||||
putIn(dir, 'CLAUDE.md', '# Project\n');
|
||||
putIn(dir, LESSONS, LESSONS_BEFORE);
|
||||
const append = (kind, subject, sha) => {
|
||||
const r = spawnSync(process.execPath, [APPROVAL_LOG, 'append', '--kind', kind, '--subject', subject, '--sha256', sha, '--repo', repo],
|
||||
{ cwd: repo, encoding: 'utf8', env: childEnv() });
|
||||
assert.equal(r.status, 0, `approval-log append ${kind}: ${r.stdout}${r.stderr}`);
|
||||
};
|
||||
seed.forEach((kind, i) => append(kind, `${kind}-${i}.md`, hex(String(i + 1))));
|
||||
g(repo, 'add', '-A');
|
||||
g(repo, 'commit', '-q', '-m', 'feat: the work');
|
||||
const reviewed = g(repo, 'rev-parse', 'HEAD');
|
||||
const logFile = join(repo, ...LOG_RELPATH.split('/'));
|
||||
const commitAll = (msg) => { g(repo, 'add', '-A'); g(repo, 'commit', '-q', '-m', msg); };
|
||||
return {
|
||||
repo, dir, reviewed, logFile,
|
||||
/** A review record for finding `n`, appended by the CLI. */
|
||||
answer: (n) => append('review', `${REVIEW_REL}#${String(n).padStart(40, 'a')}`, hex('c')),
|
||||
append,
|
||||
readLines: () => readFileSync(logFile, 'utf8').split('\n').filter(Boolean),
|
||||
writeLines: (lines) => writeFileSync(logFile, lines.map((l) => `${l}\n`).join('')),
|
||||
put: (rel, text) => putIn(dir, rel, text),
|
||||
commitAll,
|
||||
gate: () => {
|
||||
const p = join(base, 'review.md');
|
||||
writeFileSync(p, review(reviewed));
|
||||
return spawnSync(process.execPath, [GATE, '--json', p, '--repo', dir], { encoding: 'utf8', env: childEnv() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A record built by hand (keep: cases only), hashed as the K2 log hashes it. */
|
||||
function record(fields, { hash } = {}) {
|
||||
const rec = { v: 1, ts: '2026-11-02T10:00:00.000Z', kind: 'review', subject: `${REVIEW_REL}#x`, sha256: hex('d'), expires: null, ...fields };
|
||||
rec.hash = hash ?? recordHash(rec);
|
||||
return JSON.stringify(rec);
|
||||
}
|
||||
|
||||
function assertStale(r) {
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /review-stale/);
|
||||
}
|
||||
|
||||
// ---- tagged ----
|
||||
|
||||
test('[RAP] a commit that appends review records to the approval log after the reviewed commit is not stale', () => {
|
||||
// A log that already holds a plan approval, and a log that does not exist at the reviewed commit.
|
||||
for (const seed of [['plan'], []]) {
|
||||
const fx = fixture({ seed });
|
||||
fx.answer(1);
|
||||
fx.answer(2);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
// Control: the commit after the review really changes the log.
|
||||
assert.deepEqual(g(fx.repo, 'diff', '--name-only', `${fx.reviewed}..HEAD`).split('\n'), [LOG_RELPATH]);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, `seed ${JSON.stringify(seed)}: ${r.stdout}${r.stderr}`);
|
||||
// The lessons commit after the answers keeps it current too.
|
||||
fx.put(LESSONS, LESSONS_AFTER);
|
||||
fx.put('CLAUDE.md', `# Project\n\n${POINTER}`);
|
||||
fx.commitAll('docs(voyage): review lessons (round 1)');
|
||||
assert.equal(fx.gate().status, 0, `seed ${JSON.stringify(seed)} after lessons`);
|
||||
}
|
||||
// Any other change to the log is stale, with the reason that names the rule.
|
||||
const fx = fixture();
|
||||
const [first] = fx.readLines();
|
||||
const rewritten = record({ ...JSON.parse(first), hash: undefined, ts: '2026-11-02T11:00:00.000Z' });
|
||||
fx.writeLines([rewritten]);
|
||||
fx.commitAll('chore: rewrite the log');
|
||||
const r = fx.gate();
|
||||
assertStale(r);
|
||||
assert.match(r.stdout, NEW_REASON);
|
||||
});
|
||||
|
||||
test('[RAP] a project in a subdirectory: a commit appending review records to the top-level log stays current', () => {
|
||||
const fx = fixture({ sub: 'plugin' });
|
||||
fx.answer(1);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
// Control: the log changed is the top-level one, not one under the project prefix.
|
||||
assert.deepEqual(g(fx.repo, 'diff', '--name-only', `${fx.reviewed}..HEAD`).split('\n'), [LOG_RELPATH]);
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
|
||||
// ---- keep: (hold at the base) ----
|
||||
|
||||
test('keep: a commit that rewrites an earlier record (re-hashed) and appends a review record is stale', () => {
|
||||
const fx = fixture();
|
||||
const [first] = fx.readLines();
|
||||
const old = JSON.parse(first);
|
||||
delete old.hash;
|
||||
const rewritten = record({ ...old, sha256: hex('e') });
|
||||
const next = record({ seq: 2, prev: JSON.parse(rewritten).hash });
|
||||
fx.writeLines([rewritten, next]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a commit that removes a record and appends a review record is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.writeLines([record({ seq: 1, prev: GENESIS })]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a commit that reorders the records (re-chained) and appends a review record is stale', () => {
|
||||
const fx = fixture({ seed: ['intent', 'plan'] });
|
||||
const [a, b] = fx.readLines().map((l) => { const r = JSON.parse(l); delete r.hash; return r; });
|
||||
const one = record({ ...b, seq: 1, prev: GENESIS });
|
||||
const two = record({ ...a, seq: 2, prev: JSON.parse(one).hash });
|
||||
const three = record({ seq: 3, prev: JSON.parse(two).hash });
|
||||
fx.writeLines([one, two, three]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a commit that appends a plan record is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.append('plan', 'plan-new.md', hex('f'));
|
||||
fx.commitAll('docs(voyage): plan approval');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a commit that appends a merge record is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.answer(1);
|
||||
fx.append('merge', 'main', hex('f'));
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a review record whose prev does not link is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.writeLines([...fx.readLines(), record({ seq: 2, prev: GENESIS })]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a review record whose seq skips is stale', () => {
|
||||
const fx = fixture();
|
||||
const lines = fx.readLines();
|
||||
fx.writeLines([...lines, record({ seq: 3, prev: JSON.parse(lines[0]).hash })]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a review record whose hash does not match is stale', () => {
|
||||
const fx = fixture();
|
||||
const lines = fx.readLines();
|
||||
fx.writeLines([...lines, record({ seq: 2, prev: JSON.parse(lines[0]).hash }, { hash: hex('9') })]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a review record with a field beyond the record fields is stale', () => {
|
||||
const fx = fixture();
|
||||
const lines = fx.readLines();
|
||||
const rec = JSON.parse(record({ seq: 2, prev: JSON.parse(lines[0]).hash }));
|
||||
rec.note = 'merge it';
|
||||
fx.writeLines([...lines, JSON.stringify(rec)]);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a review record appended beside a changed source file is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.answer(1);
|
||||
fx.put('a.mjs', 'export const a = 2;\n');
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: the log replaced by a symlink is stale', () => {
|
||||
const fx = fixture();
|
||||
rmSync(fx.logFile);
|
||||
// The link points at a file tracked and unchanged since the reviewed commit,
|
||||
// so the log is the only changed path and the symlink rule is what decides.
|
||||
symlinkSync('../../a.mjs', fx.logFile);
|
||||
assert.ok(existsSync(fx.logFile));
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
// Control: the log is the only path the commit changes.
|
||||
assert.deepEqual(g(fx.repo, 'diff', '--name-only', `${fx.reviewed}..HEAD`).split('\n'), [LOG_RELPATH]);
|
||||
const r = fx.gate();
|
||||
assertStale(r);
|
||||
assert.match(r.stdout, /approvals\.jsonl is a symlink/);
|
||||
});
|
||||
|
||||
test('keep: a valid review append to an existing log that also makes it executable is stale', () => {
|
||||
const fx = fixture();
|
||||
fx.answer(1);
|
||||
chmodSync(fx.logFile, 0o755);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
// Control: git records the mode change.
|
||||
assert.match(g(fx.repo, 'ls-tree', 'HEAD', '--', LOG_RELPATH), /^100755 /);
|
||||
const r = fx.gate();
|
||||
assertStale(r);
|
||||
assert.match(r.stdout, NEW_REASON);
|
||||
});
|
||||
|
||||
test('keep: a log created after the review as an executable file is stale, even with a valid review record', () => {
|
||||
const fx = fixture({ seed: [] });
|
||||
fx.answer(1);
|
||||
chmodSync(fx.logFile, 0o755);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
// Control: the log did not exist at the reviewed commit, and is 100755 now.
|
||||
assert.equal(g(fx.repo, 'ls-tree', fx.reviewed, '--', LOG_RELPATH), '');
|
||||
assert.match(g(fx.repo, 'ls-tree', 'HEAD', '--', LOG_RELPATH), /^100755 /);
|
||||
const r = fx.gate();
|
||||
assertStale(r);
|
||||
assert.match(r.stdout, NEW_REASON);
|
||||
});
|
||||
|
||||
test('keep: a project in a subdirectory: a log under the project prefix is not the answer log', () => {
|
||||
const fx = fixture({ sub: 'plugin' });
|
||||
fx.put(LOG_RELPATH, `${record({ seq: 1, prev: GENESIS })}\n`);
|
||||
fx.commitAll('docs(voyage): review answers (round 1)');
|
||||
assertStale(fx.gate());
|
||||
});
|
||||
|
||||
test('keep: a lessons-only commit after the review still passes', () => {
|
||||
const fx = fixture();
|
||||
fx.put(LESSONS, LESSONS_AFTER);
|
||||
fx.put('CLAUDE.md', `# Project\n\n${POINTER}`);
|
||||
fx.commitAll('docs(voyage): review lessons (round 1)');
|
||||
const r = fx.gate();
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue