fix(review): close the gate's fail-open paths an independent review found
An adversarial read of d88245c reproduced 15 problems; all fixed here, each
with a test that fails on the old code.
- verdict-gate: a greener verdict must be backed by the LAST fence being a
`json` block of the right shape, with no BLOCKER in the JSON (any case),
in `## Findings (BLOCKER)`, or a differing finding-id list; two verdict
lines and more than one file argument are exit 2. ({}, null, lowercase
"blocker", a trailing ```jsonc and a later json fence all gave exit 0.)
- engine-findings: a transcribed "narrower" key can no longer lower an
engine tier; absolute paths under --repo-root match the scope; "12" lines
parse; an empty scope is an error; out-of-scope engine findings are
unverified (`engine:out-of-scope`) and forbid ALLOW.
- recurrence: the catch-all engine keys and keys normalised from unknown
ones are never proposed; the same unfixed finding in two iterations is not
a recurrence; proposals are validated before they are offered; \r/U+2028
and damaged markers are refused; writes go through a symlinked CLAUDE.md.
Prose points --repo at the session directory, not the git toplevel.
- Workflow substrate: engines are counted from what arrived, and the
verdict's floor is recomputed from the coordinator's findings; now tested
by running the script with stubbed Workflow globals. Two literal NUL bytes
written as \u0000 (git saw the file as binary).
- Trimming keeps Pass 1's "Also flagged by" provenance; `## Declined to
Judge` and remediation steps are required from review_version 1.1 only
(N-1); the zero-diff review now carries the JSON block the gate needs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d88245c06e
commit
40f6ae1177
17 changed files with 581 additions and 78 deletions
|
|
@ -57,28 +57,82 @@ export function decideVerdict(text) {
|
|||
return { exit: EXIT.BLOCK, verdict, reason: 'verdict BLOCK' };
|
||||
}
|
||||
|
||||
// ALLOW / WARN: the file must back the greener direction.
|
||||
const block = extractFindingsBlock(r.parsed.body ?? '');
|
||||
if (block === null) {
|
||||
// ALLOW / WARN: the file must back the greener direction, from every place a
|
||||
// BLOCKER can be written. A shape the gate cannot read is exit 2, never 0.
|
||||
const body = r.parsed.body ?? '';
|
||||
const fmText = text.slice(0, Math.max(0, text.indexOf('\n---', 3)));
|
||||
if ((fmText.match(/^verdict\s*:/gm) ?? []).length > 1) {
|
||||
return undecided('frontmatter carries more than one verdict line', verdict);
|
||||
}
|
||||
const lastFence = lastFenceLanguage(body);
|
||||
if (lastFence === null) {
|
||||
return undecided(`verdict ${verdict} has no trailing JSON block to back it`, verdict);
|
||||
}
|
||||
if (lastFence !== 'json') {
|
||||
return undecided(`the last fenced block is "${lastFence || '(no language)'}", not the trailing \`json\` block`, verdict);
|
||||
}
|
||||
const block = extractFindingsBlock(body);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(block);
|
||||
} catch (e) {
|
||||
return undecided(`trailing JSON block does not parse: ${e.message}`, verdict);
|
||||
}
|
||||
if (payload?.verdict !== undefined && payload.verdict !== verdict) {
|
||||
const shape = checkPayloadShape(payload);
|
||||
if (shape) return undecided(`trailing JSON block: ${shape}`, verdict);
|
||||
if (payload.verdict !== verdict) {
|
||||
return undecided(`frontmatter verdict ${verdict} disagrees with the JSON block verdict ${payload.verdict}`, verdict);
|
||||
}
|
||||
const blockerFindings = Array.isArray(payload?.findings)
|
||||
? payload.findings.filter((f) => f?.severity === 'BLOCKER').length
|
||||
: 0;
|
||||
const blockerCount = Number(payload?.counts?.BLOCKER ?? 0);
|
||||
if (blockerFindings > 0 || blockerCount > 0) {
|
||||
return undecided(`verdict ${verdict} over ${Math.max(blockerFindings, blockerCount)} BLOCKER finding(s)`, verdict);
|
||||
const blockerFindings = payload.findings.filter((f) => String(f.severity).toUpperCase() === 'BLOCKER').length;
|
||||
const blockerCount = Object.entries(payload.counts)
|
||||
.filter(([k]) => k.toUpperCase() === 'BLOCKER')
|
||||
.reduce((n, [, v]) => n + Number(v || 0), 0);
|
||||
const blockerSection = blockerSectionEntries(body);
|
||||
if (blockerFindings > 0 || blockerCount > 0 || blockerSection > 0) {
|
||||
return undecided(`verdict ${verdict} over BLOCKER finding(s) (json: ${blockerFindings}, counts: ${blockerCount}, ## Findings (BLOCKER): ${blockerSection})`, verdict);
|
||||
}
|
||||
return { exit: EXIT.PASS, verdict, reason: `verdict ${verdict}` };
|
||||
const fmIds = new Set(Array.isArray(fm.findings) ? fm.findings : []);
|
||||
const jsonIds = new Set(payload.findings.map((f) => f.id));
|
||||
if (fmIds.size !== jsonIds.size || [...fmIds].some((id) => !jsonIds.has(id))) {
|
||||
return undecided('frontmatter findings: list and the JSON block finding ids differ', verdict);
|
||||
}
|
||||
const warnReasons = Array.isArray(payload.allow_blocked_by) ? payload.allow_blocked_by : [];
|
||||
return { exit: EXIT.PASS, verdict, reason: `verdict ${verdict}`, warn_reasons: warnReasons };
|
||||
}
|
||||
|
||||
const FENCE_RE = /^```[ \t]*([A-Za-z0-9_+-]*)[^\n]*\n[\s\S]*?^```[ \t]*$/gm;
|
||||
|
||||
/** Language tag of the LAST fenced block in `body` (lower-cased), '' for none, null when no fence. */
|
||||
function lastFenceLanguage(body) {
|
||||
let last = null;
|
||||
let m;
|
||||
FENCE_RE.lastIndex = 0;
|
||||
while ((m = FENCE_RE.exec(body)) !== null) last = m[1].toLowerCase();
|
||||
return last;
|
||||
}
|
||||
|
||||
/** The JSON block shape the gate needs; returns an error string or null. */
|
||||
function checkPayloadShape(p) {
|
||||
if (p === null || typeof p !== 'object' || Array.isArray(p)) return 'not an object';
|
||||
if (!VERDICT_VALUES.includes(p.verdict)) return `verdict must be one of ${VERDICT_VALUES.join('|')}`;
|
||||
if (p.counts === null || typeof p.counts !== 'object' || Array.isArray(p.counts)) return 'counts must be an object';
|
||||
if (!Array.isArray(p.findings)) return 'findings must be an array';
|
||||
for (const [i, f] of p.findings.entries()) {
|
||||
if (f === null || typeof f !== 'object' || typeof f.severity !== 'string' || typeof f.id !== 'string') {
|
||||
return `findings[${i}] needs a string id and severity`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Number of `### ` entries under `## Findings (BLOCKER)`. */
|
||||
function blockerSectionEntries(body) {
|
||||
const start = body.search(/^##\s+Findings\s*\(BLOCKER\)/im);
|
||||
if (start === -1) return 0;
|
||||
const rest = body.slice(start).replace(/^[^\n]*\n/, '');
|
||||
const next = rest.search(/^##\s+/m);
|
||||
const section = next === -1 ? rest : rest.slice(0, next);
|
||||
return (section.match(/^###\s+\S/gm) ?? []).length;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -106,9 +160,11 @@ function isMain() {
|
|||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const filePath = args.find((a) => !a.startsWith('--'));
|
||||
if (!filePath) {
|
||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md>\n');
|
||||
const files = args.filter((a) => !a.startsWith('--'));
|
||||
const filePath = files[0];
|
||||
if (files.length !== 1) {
|
||||
// Exactly one file: a glob would otherwise gate on its first match only.
|
||||
process.stderr.write('Usage: verdict-gate.mjs [--json] <review.md> (exactly one file)\n');
|
||||
process.exit(EXIT.UNDECIDED);
|
||||
}
|
||||
let result;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue