fix(review): an append of review answers to the approval log keeps the review current

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 15:01:58 +01:00
commit ce64851a0d
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
3 changed files with 424 additions and 6 deletions

View file

@ -432,7 +432,7 @@ missing `exit_code` (C15, `PROGRESS_FAILED_CRITERIA_KEY`).
**Consumer:** `/trekplan` Phase 1 when `--brief review.md` is supplied and the consumer detects `type: trekreview` in frontmatter. The plan command branches into a remediation-plan path: BLOCKER + MAJOR findings become plan goals, the produced `plan.md` carries a `source_findings: [<id>, ...]` frontmatter list as the audit trail back to the consumed findings. MINOR + SUGGESTION are skipped for plan-input. Since 1.1 every BLOCKER is also written as a numbered `### Step R<n>` under `## Remediation Steps` (Files · Source finding · Test first · Verify · On failure), so a consumer can append it as a plan step without rewording it.
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. WARN opens the merge only when the verdict gate finds every counting MAJOR answered. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block) and the `CLAUDE.md` pointer (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Second consumer — the verdict gate (K7, 1.1):** `lib/review/verdict-gate.mjs <review.md>` reads the frontmatter `verdict` and exits 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not decide. WARN opens the merge only when the verdict gate finds every counting MAJOR answered. Exit 2 is never green: a missing file, a missing or unknown verdict, two verdict lines, a trailing JSON block that is not the last fence or lacks `verdict`/`counts`/`findings`, and a file whose JSON block, `## Findings (BLOCKER)` section or finding-id list contradicts a greener verdict all land there — and so does a WARN that exists because a reviewer or engine did not run (`missing-reviewer:` / `unattributable-payload` in the JSON block's `allow_blocked_by`, which a 1.1 review must carry): a review not performed in full stops a merge (operator decision 2026-10-06). Exactly one file per call. With `--repo <dir>` (`verdict-gate.mjs --json <review.md> --repo <dir>`, R6) an ALLOW/WARN review must also be of the commit being merged in `<dir>`: its `scope_sha_end`, read only as a full 40- or 64-hex commit id (a short id or a ref name is stale), is HEAD, or an ancestor of HEAD whose later commits change only the review lessons (`.claude/rules/voyage-review-lessons.md`, inside its managed block), the `CLAUDE.md` pointer and the approval log at the git top level (`.claude/voyage/approvals.jsonl`), there only by append-only answer records: K2 `review` records added after the log's existing bytes, each with exactly the record fields, its own hash, the next `seq` and a `prev` that links (`lib/review/reviewed-commit.mjs`); otherwise exit 2 with a reason that starts `review-stale:`, which `/trekexecute`'s merge lock (Phase 7.2 step 4 and Phase 8, both with `--repo .`) maps to the decline reason `review-stale`. When the gate cannot look (the `--repo` path is missing, it is no git repository, or a git call failed), the exit is 2 with a reason that starts `review-not-measured:`, never `review-stale:`: the merge lock reports it as not measured, never green. A `--repo` with no value is exit 2 as well; BLOCK stays exit 1. Whatever stops a merge or a release on a review reads this exit code, never a summary. Borrowed: the verdict goes to a file and a command gates on it (Kiro `feature-pipeline`); the executor reads the verdict but never writes it (Spec Kit).
**Path conventions:**
- Project-dir mode (recommended): `{project_dir}/review.md` (one per review iteration; subsequent runs overwrite atomically).

View file

@ -9,13 +9,22 @@
// - the reviewed commit is HEAD, or an ancestor of HEAD;
// - every commit between it and HEAD (side branches included, each diffed
// against its first parent, `--no-relative --ignore-submodules=none`) changes only
// `{prefix}.claude/rules/voyage-review-lessons.md` and `{prefix}CLAUDE.md`,
// where {prefix} is the project's place in the repo (`git rev-parse
// --show-prefix`);
// `{prefix}.claude/rules/voyage-review-lessons.md`, `{prefix}CLAUDE.md`
// and the approval log `.claude/voyage/approvals.jsonl` (LOG_RELPATH, at
// the git top level, never under {prefix}), where {prefix} is the
// project's place in the repo (`git rev-parse --show-prefix`);
// - in each such commit the lessons file is unchanged outside its managed
// block, every line its block gains is a lesson line (`validateLessonLine`)
// or exactly `BLOCK_HEADER` (m4), and CLAUDE.md is unchanged or gains exactly the pointer block
// (`withPointer` in lib/review/recurrence.mjs);
// - in each such commit the approval log only gains appended review answers
// (R6, fix round 4.5b2 part B): neither side is a symlink, the log after
// is mode 100644 (also when the commit creates it), the log before
// (empty when absent) is a byte prefix of the log after, and every added
// line is a K2 record with exactly the record fields and kind `review`,
// whose `hash` is its `recordHash`, whose `seq` follows the last record
// before it and whose `prev` links to it (to GENESIS on an empty log);
// any other change to the log is stale;
// - a commit with no parent in that range fails closed, and so does a
// symlinked CLAUDE.md (the pointer write goes through the link, so its
// target shows as a changed path).
@ -36,6 +45,7 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { LESSONS_FILE_NAME, MARK_START, MARK_END, BLOCK_HEADER, withPointer, validateLessonLine } from './recurrence.mjs';
import { GENESIS, LOG_RELPATH, recordHash } from '../util/approval-log.mjs';
/**
* A full commit id: 40 hex (SHA-1) or 64 hex (SHA-256), nothing shorter (M11).
@ -46,6 +56,10 @@ export const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i;
const LESSONS_REL = `.claude/rules/${LESSONS_FILE_NAME}`; // posix, as `git diff` prints it
const CLAUDE_REL = 'CLAUDE.md';
const SYMLINK_MODE = '120000';
const LOG_MODE = '100644'; // the approval log after a commit: a regular, non-executable file
// The K2 record's keys (`FIELDS` plus `hash` in lib/util/approval-log.mjs, not exported there).
const RECORD_KEYS = ['expires', 'hash', 'kind', 'prev', 'seq', 'sha256', 'subject', 'ts', 'v'].join(',');
const HASH_RE = /^[0-9a-f]{64}$/;
/**
* The review's `scope_sha_end` as written. Read from the frontmatter text, not
@ -144,6 +158,61 @@ function blobAt(repo, rev, path) {
return { ok: true, mode, text: blob.stdout };
}
/**
* One file's bytes in one tree (`blob` is its blobAt result): empty when absent.
* Bytes, not text, so the prefix check below is a byte prefix.
*/
function bytesAt(repo, rev, path, blob) {
if (blob.mode === null) return { ok: true, bytes: Buffer.alloc(0) };
const r = spawnSync('git', ['cat-file', 'blob', `${rev}:${path}`], { cwd: repo, maxBuffer: 64 * 1024 * 1024 });
if (r.status !== 0) return { ok: false, fail: gitFailed(`cat-file ${rev}:${path}`, r) };
return { ok: true, bytes: r.stdout };
}
/**
* Does `after` hold `before` plus appended `review` records, each a valid link
* of the K2 chain? `before`/`after` are Buffers. Returns true or false.
*/
function onlyAppendedReviewRecords(before, after) {
if (after.length <= before.length || !after.subarray(0, before.length).equals(before)) return false;
let added;
try {
added = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(after.subarray(before.length));
} catch {
return false;
}
const beforeText = before.toString('utf8');
// appendApproval adds a newline first when the log does not end with one.
if (beforeText !== '' && !beforeText.endsWith('\n')) {
if (!added.startsWith('\n')) return false;
added = added.slice(1);
}
if (added === '' || !added.endsWith('\n')) return false;
let seq = 0;
let prev = GENESIS;
const beforeLines = beforeText.split('\n');
if (beforeLines[beforeLines.length - 1] === '') beforeLines.pop();
if (beforeLines.length > 0) {
let last;
try { last = JSON.parse(beforeLines[beforeLines.length - 1]); } catch { return false; }
if (!last || typeof last !== 'object' || !Number.isInteger(last.seq) || !HASH_RE.test(String(last.hash))) return false;
seq = last.seq;
prev = last.hash;
}
for (const line of added.slice(0, -1).split('\n')) {
let rec;
try { rec = JSON.parse(line); } catch { return false; }
if (!rec || typeof rec !== 'object' || Array.isArray(rec)) return false;
if (Object.keys(rec).sort().join(',') !== RECORD_KEYS) return false;
if (rec.kind !== 'review' || rec.seq !== seq + 1 || rec.prev !== prev) return false;
if (recordHash(rec) !== rec.hash) return false;
seq = rec.seq;
prev = rec.hash;
}
return true;
}
/**
* Is the review of `sha` a review of what HEAD holds in `repo`?
* @param {{ repo: string, sha: string }} args
@ -204,7 +273,8 @@ export function checkReviewedCommit({ repo, sha } = {}) {
const names = git(repo, ['diff', '--no-relative', '--ignore-submodules=none', '--name-only', '-z', '--no-renames', `${c}^1`, c]);
if (names.status !== 0) return gitFailed(`diff ${short(c)}`, names);
const paths = names.stdout.split('\0').filter(Boolean);
const other = paths.find((p) => p !== lessons && p !== claude);
// The approval log is the top-level one: `--no-relative` paths are from the top level.
const other = paths.find((p) => p !== lessons && p !== claude && p !== LOG_RELPATH);
if (other !== undefined) return stale(`${other} changed after the review (commit ${short(c)})`);
for (const path of paths) {
@ -215,7 +285,17 @@ export function checkReviewedCommit({ repo, sha } = {}) {
if (before.mode === SYMLINK_MODE || after.mode === SYMLINK_MODE) {
return stale(`${path} is a symlink (commit ${short(c)}) — re-run /trekreview on this commit`);
}
if (path === lessons) {
if (path === LOG_RELPATH) {
// 7. The approval log: only appended review answers (R6).
const was = bytesAt(repo, `${c}^1`, path, before);
if (!was.ok) return was.fail;
const now = bytesAt(repo, c, path, after);
if (!now.ok) return now.fail;
// A regular, non-executable file after the commit, on creation too.
if (after.mode !== LOG_MODE || !onlyAppendedReviewRecords(was.bytes, now.bytes)) {
return stale(`${path} changed other than by appended review answers (commit ${c.slice(0, 8)})`);
}
} else if (path === lessons) {
// 5. Only the managed block may change.
if (outsideBlock(before.text).trimEnd() !== outsideBlock(after.text).trimEnd()) {
return stale(`${path} changed outside the managed block (commit ${short(c)})`);

View 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);
});