fix(review): a review with a control that did not run stops a merge

Operator decision 2026-10-06. Before, a WARN that existed only because a
reviewer or engine never reported (e.g. /security-review without an origin
remote) mapped to verdict-gate exit 0, so a review nobody performed in full
was green. Now `missing-reviewer:` / `unattributable-payload` in the JSON
block's allow_blocked_by is exit 2 (could not decide, never green); a WARN
for a MAJOR or a set-aside finding still passes with its reasons. A 1.1
review must carry allow_blocked_by.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-06 20:06:29 +02:00
commit 22e792ce19
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
7 changed files with 72 additions and 14 deletions

View file

@ -86,6 +86,11 @@ test('trekreview — Phase 8 decides with lib/review/verdict-gate.mjs and calls
assert.match(p, /never\s+green/);
});
test('gate — a control that did not run stops a merge (operator decision 2026-10-06) is written down', () => {
assert.match(section(CMD, '## Phase 8 —'), /a review not performed in full stops a\s+merge/);
assert.match(section(read('docs/HANDOVER-CONTRACTS.md'), '## Handover 6'), /did not run/);
});
test('trekreview — a BLOCKER is written as a numbered Step R<n> with Source finding (Handover 6)', () => {
assert.ok(CMD.includes('### Step R<n>'));
assert.ok(COORD.includes('**Source finding:**'));
@ -107,7 +112,8 @@ test('template — templates/trekreview-template.md validates strictly and the g
test('trekreview — the zero-diff review the prose prescribes passes the gate as ALLOW', async () => {
const { decideVerdict } = await import('../../lib/review/verdict-gate.mjs');
const p3 = section(CMD, '## Phase 3');
assert.ok(p3.includes('findings: []') && p3.includes('"findings": []'), 'Phase 3 prescribes both');
assert.ok(p3.includes('findings: []') && p3.includes('"findings": []') && p3.includes('"allow_blocked_by": []'),
'Phase 3 prescribes the empty list, the JSON block and allow_blocked_by');
const text = `---
type: trekreview
review_version: "1.1"
@ -140,7 +146,7 @@ Trimmed: 0
No diff.
\`\`\`json
{"verdict": "ALLOW", "counts": {"BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0}, "findings": []}
{"verdict": "ALLOW", "counts": {"BLOCKER": 0, "MAJOR": 0, "MINOR": 0, "SUGGESTION": 0}, "allow_blocked_by": [], "findings": []}
\`\`\`
`;
assert.equal(decideVerdict(text).exit, 0, decideVerdict(text).reason);

View file

@ -16,7 +16,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const GATE = join(ROOT, 'lib', 'review', 'verdict-gate.mjs');
const { decideVerdict } = existsSync(GATE) ? await import('../../lib/review/verdict-gate.mjs') : {};
function review({ verdict, jsonVerdict, severities = [] }) {
function review({ verdict, jsonVerdict, severities = [], allowBlockedBy = [] }) {
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
const findings = severities.map((s, i) => ({
@ -24,7 +24,7 @@ function review({ verdict, jsonVerdict, severities = [] }) {
}));
const counts = { BLOCKER: 0, MAJOR: 0, MINOR: 0, SUGGESTION: 0 };
for (const s of severities) counts[s] += 1;
const json = JSON.stringify({ verdict: jsonVerdict ?? verdict, counts, findings }, null, 2);
const json = JSON.stringify({ verdict: jsonVerdict ?? verdict, counts, allow_blocked_by: allowBlockedBy, findings }, null, 2);
return `---
type: trekreview
review_version: "1.1"
@ -182,6 +182,30 @@ test('verdict-gate — frontmatter finding ids that differ from the JSON block a
assert.equal(decideVerdict(t).exit, 2);
});
// ---- Operator decision 2026-10-06: a control that did not run stops a merge ----
test('verdict-gate — WARN because a reviewer or engine did not run is exit 2, not a passable warning', () => {
for (const reason of ['missing-reviewer:claude-security-review', 'unattributable-payload (1)']) {
const t = review({ verdict: 'WARN', severities: ['MINOR'], allowBlockedBy: [reason] });
const d = decideVerdict(t);
assert.equal(d.exit, 2, reason);
assert.match(d.reason, /did not run/);
}
});
test('verdict-gate — WARN for a set-aside finding or a MAJOR still passes (exit 0) with its reasons', () => {
const d = decideVerdict(review({ verdict: 'WARN', severities: ['MAJOR'], allowBlockedBy: ['unverified:actionability:empty (1)'] }));
assert.equal(d.exit, 0);
assert.deepEqual(d.warn_reasons, ['unverified:actionability:empty (1)']);
});
test('verdict-gate — a 1.1 review whose JSON block lacks allow_blocked_by cannot pass', () => {
const base = review({ verdict: 'WARN', severities: ['MAJOR'] });
const t = base.replace(/\s*"allow_blocked_by": \[\],?/, '');
assert.notEqual(t, base, 'fixture control');
assert.equal(decideVerdict(t).exit, 2);
});
test('verdict-gate — more than one file argument is refused (exit 2), never a first-match decision', () => {
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-'));
try {