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:
parent
40f6ae1177
commit
22e792ce19
7 changed files with 72 additions and 14 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue