feat(review): a WARN opens the lock only when every counting MAJOR is answered

This commit is contained in:
Kjell Tore Guttormsen 2026-11-02 15:45:37 +01:00
commit ce7dbc27a9
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 1044 additions and 64 deletions

View file

@ -11,6 +11,7 @@
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync,
} from 'node:fs';
@ -336,3 +337,420 @@ test('keep: a lessons-only commit after the review still passes', () => {
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
});
// ---- [ANS] / [RLS]: fix round 4.5b2 part B, Step 4 ----
// The verdict gate and the release check keep a WARN closed until every
// counting MAJOR is answered. Every answer a tagged test needs is written by
// `review-answers.mjs record` through its CLI; the only hand-written rows are
// the ones a case is about. `keep:` cases hold at the base and stay untagged.
const ANSWERS = join(ROOT, 'lib', 'review', 'review-answers.mjs');
const RELEASE = join(ROOT, 'scripts', 'release-check.mjs');
const TRAILER = 'Co-Authored-By: Claude Test <noreply@example.invalid>';
const ANS_REL = '.claude/projects/2026-11-02-a/review.md';
const OTHER_REL = '.claude/projects/2026-11-02-b/review.md';
const ANSWERS_HEAD = '| Finding | Answer | Reason | Record | Carried from |\n|---|---|---|---|---|';
const fid = (n) => String(n).padStart(40, 'b');
const sha256 = (s) => createHash('sha256').update(s).digest('hex');
/**
* A review of `sha`. `findings` are [{severity, rule_key?, engine?}]; ids are
* fid(1), fid(2), … The trailing block's body is exactly `${json}\n`.
*/
function ansReview({ sha, verdict = 'WARN', version = '1.2', findings = [{ severity: 'MAJOR' }] }) {
const list = findings.map((f, i) => ({
id: fid(i + 1), severity: f.severity, rule_key: f.rule_key ?? 'MISSING_TEST', ...(f.engine ? { engine: f.engine } : {}),
file: 'a.mjs', line: i + 1, title: `t${i + 1}`, detail: `d${i + 1}`, recommended_action: 'Add a test.',
}));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const f of list) counts[f.severity] += 1;
const json = JSON.stringify({ verdict, counts, allow_blocked_by: [], findings: list }, null, 2);
const steps = list.filter((f) => f.severity === 'BLOCKER')
.map((f, i) => `### Step R${i + 1}: fix ${f.title}\n\nSource finding: ${f.id}\n`).join('\n');
return {
json,
text: `---
type: trekreview
review_version: "${version}"
task: "t"
slug: s
project_dir: .claude/projects/2026-11-02-a/
brief_path: .claude/projects/2026-11-02-a/brief.md
scope_sha_end: ${sha}
reviewed_files_count: 1
verdict: ${verdict}
findings:
${list.map((f) => ` - ${f.id}`).join('\n')}
---
# Review: t
## Executive Summary
x
## Coverage
| File | Treatment | Reason |
|------|-----------|--------|
| a.mjs | deep-review | default |
## Remediation Summary
x
## Remediation Steps
${steps || 'None.\n'}
## Declined to Judge
None.
\`\`\`json
${json}
\`\`\`
`,
};
}
/** Put hand-written `## Answers` rows just before the trailing JSON block. */
function withRows(text, rows) {
return text.replace(/```json\n(?=[\s\S]*```\n$)/, `## Answers\n\n${ANSWERS_HEAD}\n${rows.join('\n')}\n\n\`\`\`json\n`);
}
const parse = (r) => { try { return JSON.parse(r.stdout); } catch { return {}; } };
function newRepo(prefix) {
const base = mkdtempSync(join(tmpdir(), prefix));
made.push(base);
const repo = join(base, 'repo');
mkdirSync(repo, { 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`);
// The review files are local artifacts (artifact_store: local): never tracked, never shown.
appendFileSync(join(repo, '.git', 'info', 'exclude'), '.claude/projects/\n');
return { base, repo };
}
/**
* A repo with one reviewed commit and a review of it at ANS_REL in the project
* dir (`sub` puts the project in a subdirectory). `files` adds files to the
* reviewed commit.
*/
function ansFixture({ sub = '', files = {}, ...reviewOpts } = {}) {
const { base, repo } = newRepo('review-answers-ans-');
const dir = sub ? join(repo, sub) : repo;
putIn(dir, 'a.mjs', 'export const a = 1;\n');
for (const [rel, text] of Object.entries(files)) putIn(dir, rel, text);
g(repo, 'add', '-A');
g(repo, 'commit', '-q', '-m', 'feat: the work');
const reviewed = g(repo, 'rev-parse', 'HEAD');
const reviewPath = join(dir, ...ANS_REL.split('/'));
const built = ansReview({ sha: reviewed, ...reviewOpts });
putIn(dir, ANS_REL, built.text);
const logFile = join(repo, ...LOG_RELPATH.split('/'));
const readLog = () => (existsSync(logFile) ? readFileSync(logFile, 'utf8').split('\n').filter(Boolean) : []);
return {
base, repo, dir, reviewed, reviewPath, logFile, json: built.json, readLog,
read: (p = reviewPath) => readFileSync(p, 'utf8'),
write: (text, p = reviewPath) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, text); },
record: ({ finding = fid(1), answer = 'accepted', reason = 'the old behaviour is kept on purpose', trailer = TRAILER, repoArg = dir, review = reviewPath } = {}) => {
const args = [ANSWERS, 'record', '--review', review, '--finding', finding, '--answer', answer,
'--repo', repoArg, '--round', '1', '--store', 'local'];
if (trailer !== null) args.push('--trailer', trailer);
return spawnSync(process.execPath, args, { input: reason, encoding: 'utf8', env: childEnv() });
},
gate: (extra = ['--repo', dir], p = reviewPath) => spawnSync(process.execPath, [GATE, '--json', p, ...extra], { encoding: 'utf8', env: childEnv() }),
/** A K2 review record for `id`, appended by the approval-log CLI and committed (hand-written rows only). */
logAnswer: (id, sha) => {
const r = spawnSync(process.execPath, [APPROVAL_LOG, 'append', '--kind', 'review', '--subject', `${ANS_REL}#${id}`,
'--sha256', sha, '--repo', repo], { cwd: dir, encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `approval-log append: ${r.stdout}${r.stderr}`);
g(repo, 'add', '--', LOG_RELPATH);
g(repo, 'commit', '-q', '-m', 'docs(voyage): review answers (round 1)');
return JSON.parse(r.stdout).record.hash;
},
};
}
function assertUnanswered(r, re = /^review-unanswered:/) {
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(String(parse(r).reason), re, r.stdout);
}
test('[ANS] an unanswered MAJOR exits 2 with a reason starting review-unanswered: and the count', () => {
const fx = ansFixture({ findings: [{ severity: 'MAJOR' }, { severity: 'MINOR' }] });
const r = fx.gate();
assertUnanswered(r, /^review-unanswered: 1 MAJOR without an answer/);
assert.deepEqual(parse(r).unanswered, [fid(1)]);
assert.deepEqual(parse(r).fixed_pending, []);
});
test('[ANS] an accepted answer with a reason, bound to this review, exits 0', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const rec = fx.record({ reason: 'the old behaviour is kept on purpose' });
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
const last = JSON.parse(fx.readLog().at(-1));
assert.equal(last.kind, 'review');
assert.equal(last.subject, `${ANS_REL}#${fid(1)}`);
assert.ok(fx.read().includes(`| ${fid(1)} | accepted | the old behaviour is kept on purpose | ${last.hash} | - |`), fx.read());
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
});
test('[ANS] a fixed answer keeps exit 2 with fixed-pending', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const rec = fx.record({ answer: 'fixed', reason: 'fixed in step R1' });
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
const r = fx.gate();
assertUnanswered(r, /^review-unanswered: fixed-pending 1/);
assert.deepEqual(parse(r).fixed_pending, [fid(1)]);
});
test('[ANS] a hollow reason (sounds good) is refused by record with exit 1', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const before = fx.read();
for (const reason of ['sounds good', 'Looks good.', '', ' \n ']) {
const r = fx.record({ reason });
assert.equal(r.status, 1, `${JSON.stringify(reason)}: ${r.stdout}${r.stderr}`);
assert.equal(parse(r).recorded, false, r.stdout);
assert.deepEqual(fx.readLog(), [], `${JSON.stringify(reason)}: nothing appended`);
assert.equal(fx.read(), before, `${JSON.stringify(reason)}: review.md unchanged`);
}
});
test('[ANS] record without --trailer, or with an unfilled {model}, is refused with exit 1 and appends nothing', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const before = fx.read();
for (const trailer of [null, 'Co-Authored-By: Claude {model} <noreply@anthropic.com>']) {
const r = fx.record({ trailer });
assert.equal(r.status, 1, `${trailer}: ${r.stdout}${r.stderr}`);
assert.match(String(parse(r).reason), /--trailer/, r.stdout);
assert.deepEqual(fx.readLog(), [], `${trailer}: nothing appended`);
assert.equal(fx.read(), before, `${trailer}: review.md unchanged`);
}
});
test('[ANS] record commits the top-level log with the round message and the trailer', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const rec = fx.record();
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
assert.equal(parse(rec).committed, true, rec.stdout);
const msg = g(fx.repo, 'log', '-1', '--format=%B');
assert.match(msg, /^docs\(voyage\): review answers \(round 1\)/);
assert.ok(msg.includes(TRAILER), msg);
const status = g(fx.repo, 'status', '--porcelain');
assert.ok(!status.includes('approvals.jsonl'), `the log is committed: ${status}`);
});
test('[ANS] accepted on a security MAJOR (a MAJOR-tier key with engine security-review) is refused by record', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture({ findings: [{ severity: 'MAJOR', rule_key: 'PLACEHOLDER_IN_CODE', engine: 'security-review' }] });
const before = fx.read();
const r = fx.record({ reason: 'the risk is accepted for this release' });
assert.equal(r.status, 1, r.stdout + r.stderr);
assert.match(String(parse(r).reason), /security/, r.stdout);
assert.deepEqual(fx.readLog(), []);
assert.equal(fx.read(), before);
});
test('[ANS] a hand-written accepted row on a security MAJOR with a valid K2 record still exits 2', () => {
const fx = ansFixture({ findings: [{ severity: 'MAJOR', rule_key: 'PLACEHOLDER_IN_CODE', engine: 'security-review' }, { severity: 'MAJOR' }] });
// The binding, computed here from its definition: sha256 of the canonical JSON
// {blockSha, scopeShaEnd, findingId, answer, reason}, blockSha the sha256 of the trailing block's body.
const blockSha = sha256(`${fx.json}\n`);
const reason = 'the risk is accepted for this release';
const bind = (id) => sha256(JSON.stringify({ blockSha, scopeShaEnd: fx.reviewed, findingId: id, answer: 'accepted', reason }));
const rows = [fid(1), fid(2)].map((id) => `| ${id} | accepted | ${reason} | ${fx.logAnswer(id, bind(id))} | - |`);
fx.write(withRows(fx.read(), rows));
const r = fx.gate();
assertUnanswered(r);
// Control: the same hand-written binding answers the MAJOR that is not a security finding.
assert.deepEqual(parse(r).unanswered, [fid(1)], r.stdout);
});
test('[ANS] a reason holding |, a newline and a backtick fence is stored normalized, and the gate then exits 0', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const rec = fx.record({ reason: 'keeps the old API | callers rely on it\n```js\nfence\n```' });
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
const stored = 'keeps the old API / callers rely on it js fence';
assert.equal(parse(rec).reason, stored, rec.stdout);
assert.ok(fx.read().includes(`| ${fid(1)} | accepted | ${stored} | `), fx.read());
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
});
test('[ANS] an answer bound to another review exits 2', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
assert.equal(fx.record().status, 0);
assert.equal(fx.gate().status, 0, 'control: the answered review passes');
const other = join(fx.dir, ...OTHER_REL.split('/'));
fx.write(fx.read(), other);
assertUnanswered(fx.gate(['--repo', fx.dir], other));
});
test('[ANS] a review whose JSON block or scope_sha_end changed after the answer exits 2', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
assert.equal(fx.record().status, 0);
const answered = fx.read();
assert.equal(fx.gate().status, 0, 'control: the answered review passes');
fx.write(answered.replace('"detail": "d1"', '"detail": "d1, reworded"'));
assertUnanswered(fx.gate());
// The review now names the answer-log commit (HEAD), so R6 passes and the binding decides.
const head = g(fx.repo, 'rev-parse', 'HEAD');
assert.notEqual(head, fx.reviewed);
fx.write(answered.replace(`scope_sha_end: ${fx.reviewed}`, `scope_sha_end: ${head}`));
assertUnanswered(fx.gate());
});
test('[ANS] an R-step section added above the block after the answers keeps exit 0', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
assert.equal(fx.record().status, 0);
const text = fx.read();
assert.ok(text.includes('## Answers'), text);
fx.write(text.replace('## Answers', `### Step R1: fix t1\n\nSource finding: ${fid(1)}\n\n## Answers`));
const r = fx.gate();
assert.equal(r.status, 0, r.stdout + r.stderr);
});
test('[ANS] a row with no K2 record exits 2', () => {
const fx = ansFixture();
fx.write(withRows(fx.read(), [`| ${fid(1)} | accepted | the old behaviour is kept on purpose | ${hex('7')} | - |`]));
assertUnanswered(fx.gate());
});
test('[ANS] a project in a subdirectory: record --repo <subdir> writes the top-level log, and the gate exits 0 with --repo <subdir>, --repo <top level> and no --repo', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture({ sub: 'plugin' });
const rec = fx.record({ repoArg: fx.dir });
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
assert.ok(existsSync(fx.logFile), 'the top-level log is written');
assert.ok(!existsSync(join(fx.dir, ...LOG_RELPATH.split('/'))), 'no log under the project prefix');
assert.equal(JSON.parse(fx.readLog().at(-1)).subject, `plugin/${ANS_REL}#${fid(1)}`);
const status = g(fx.repo, 'status', '--porcelain');
assert.ok(!status.includes('approvals.jsonl'), `the log is committed: ${status}`);
for (const extra of [['--repo', fx.dir], ['--repo', fx.repo], []]) {
const r = fx.gate(extra);
assert.equal(r.status, 0, `${JSON.stringify(extra)}: ${r.stdout}${r.stderr}`);
}
});
test('[ANS] a 1.1 WARN review with a MAJOR exits 2', () => {
const fx = ansFixture({ version: '1.1' });
assertUnanswered(fx.gate());
});
test('[ANS] an ALLOW review over an unanswered MAJOR exits 2', () => {
const fx = ansFixture({ verdict: 'ALLOW' });
assertUnanswered(fx.gate());
});
test('[ANS] a review in a non-git directory with a MAJOR exits 2', () => {
const base = mkdtempSync(join(tmpdir(), 'review-answers-nogit-'));
made.push(base);
// Control: the directory is in no git work tree.
assert.notEqual(spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: base, encoding: 'utf8', env: childEnv() }).status, 0);
const p = join(base, 'review.md');
writeFileSync(p, ansReview({ sha: 'a'.repeat(40) }).text);
const r = spawnSync(process.execPath, [GATE, '--json', p], { encoding: 'utf8', env: childEnv() });
assertUnanswered(r);
});
test('keep: a BLOCKER exits 1 whatever the answers', () => {
const fx = ansFixture({ verdict: 'BLOCK', findings: [{ severity: 'BLOCKER' }, { severity: 'MAJOR' }] });
fx.write(withRows(fx.read(), [fid(1), fid(2)].map((id) => `| ${id} | accepted | kept on purpose | ${hex('7')} | - |`)));
const r = fx.gate();
assert.equal(r.status, 1, r.stdout + r.stderr);
});
test('keep: a review of an older commit reports review-stale (not review-unanswered)', () => {
const fx = ansFixture();
putIn(fx.dir, 'a.mjs', 'export const a = 2;\n');
g(fx.repo, 'add', '-A');
g(fx.repo, 'commit', '-q', '-m', 'fix: after the review');
const r = fx.gate();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.match(r.stdout, /review-stale/);
assert.doesNotMatch(r.stdout, /review-unanswered/);
});
test('[RLS] scripts/release-check.mjs reports not ready (exit 1) for the unanswered review and ready for the answered one, from the same K2 log', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture({
files: {
'package.json': `${JSON.stringify({ name: 'fx', version: '1.0.1', scripts: { test: 'node -e "process.exit(0)"' } }, null, 2)}\n`,
'.claude-plugin/plugin.json': `${JSON.stringify({ name: 'fx', version: '1.0.1' })}\n`,
'CHANGELOG.md': '# Changelog\n\n## v1.0.1 — 2026-11-02 — fixture\n\n### Doors\n\n- two-way: the fixture change — revert it.\n',
},
});
const other = join(fx.dir, ...OTHER_REL.split('/'));
fx.write(fx.read(), other);
const rec = fx.record();
assert.equal(rec.status, 0, rec.stdout + rec.stderr);
const check = (review) => spawnSync(process.execPath, [RELEASE, '--repo', fx.dir, '--review', review, '--json'],
{ encoding: 'utf8', env: childEnv() });
const open = check(other);
assert.equal(open.status, 1, open.stdout + open.stderr);
const rev = parse(open).checks.find((c) => c.id === 'review');
assert.equal(rev.status, 'fail', open.stdout);
assert.match(rev.detail, /review-unanswered:/);
const answered = check(fx.reviewPath);
assert.equal(answered.status, 0, answered.stdout + answered.stderr);
});
// ---- fix round 1 of Step 4 (controller findings 1 and 2) ----
// Untagged: they were red against the Step 4 build, not at the plan's base.
test('a review with no readable trailing JSON block is not measured: decideAnswers is not ok and decideOpenFindings exits 2', async () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const { decideAnswers } = await import(ANSWERS);
const { decideOpenFindings } = await import(GATE);
const { text, json } = ansReview({ sha: 'a'.repeat(40) });
const block = `\`\`\`json\n${json}\n\`\`\`\n`;
assert.ok(text.endsWith(block), 'control: the fixture ends with its JSON block');
const head = text.slice(0, -block.length);
const cases = {
'no block': head,
'an unclosed block': `${head}\`\`\`json\n${json}\n`,
'a non-json last block': `${text}\n\`\`\`yaml\nverdict: WARN\n\`\`\`\n`,
'an unparseable block': `${head}\`\`\`json\n{ "verdict": "WARN", \n\`\`\`\n`,
};
const base = mkdtempSync(join(tmpdir(), 'review-answers-unread-'));
made.push(base);
const reviewPath = join(base, 'review.md');
for (const [name, t] of Object.entries(cases)) {
const d = decideAnswers(t, { records: [], reviewSubject: 'review.md' });
assert.equal(d.ok, false, `${name}: decideAnswers must not be ok`);
assert.match(String(d.reason), /^review-not-measured:/, `${name}: ${d.reason}`);
assert.deepEqual(d.unanswered, [], name);
assert.deepEqual(d.fixedPending, [], name);
const o = decideOpenFindings(t, { reviewPath });
assert.equal(o.exit, 2, `${name}: decideOpenFindings must exit 2, got ${o.exit} (${o.reason})`);
assert.match(String(o.reason), /^review-not-measured:/, `${name}: ${o.reason}`);
}
});
test('record on a review with a malformed ## Answers table exits 2 and appends nothing to the approval log', () => {
assert.ok(existsSync(ANSWERS), 'lib/review/review-answers.mjs must exist');
const fx = ansFixture();
const malformed = fx.read().replace(/```json\n(?=[\s\S]*```\n$)/,
'## Answers\n\n| Finding | Answer |\n|---|---|\n\n```json\n');
assert.notEqual(malformed, fx.read(), 'control: the malformed section is in the review');
fx.write(malformed);
const r = fx.record();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(parse(r).recorded, false, r.stdout);
assert.deepEqual(fx.readLog(), [], 'the approval log gained no line');
assert.equal(fx.read(), malformed, 'review.md unchanged');
});

View file

@ -98,7 +98,9 @@ test('verdict-gate — ALLOW → exit 0', () => {
});
test('verdict-gate — WARN → exit 0', () => {
const r = runGate(review({ verdict: 'WARN', severities: ['MAJOR'] }));
// A WARN with no MAJOR (a MINOR and a brief-conflict reason): outside any git
// repo there is no approval log, so a MAJOR here would be unanswered (exit 2).
const r = runGate(review({ verdict: 'WARN', severities: ['MINOR'], allowBlockedBy: ['brief-conflict:Non-Goal 1 (1)'] }));
assert.equal(r.status, 0, r.stdout + r.stderr);
});