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>
214 lines
8.3 KiB
JavaScript
214 lines
8.3 KiB
JavaScript
// tests/lib/verdict-gate.test.mjs
|
|
// K7 — the review verdict gate (ordre 5, P11). One command decides on the
|
|
// verdict written in review.md: 0 = ALLOW/WARN, 1 = BLOCK, 2 = could not read.
|
|
// Exit 2 is never green: a missing file, a missing verdict, or a verdict the
|
|
// file contradicts all land there.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { mkdtempSync, writeFileSync, rmSync, existsSync, symlinkSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
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 = [] }) {
|
|
const ids = severities.map((_, i) => String(i + 1).padStart(40, 'a'));
|
|
const fmVerdict = verdict === undefined ? '' : `verdict: ${verdict}\n`;
|
|
const findings = severities.map((s, i) => ({
|
|
id: ids[i], severity: s, rule_key: 'MISSING_TEST', file: 'a.mjs', line: i + 1, title: 't', detail: 'd', recommended_action: 'Add a test.',
|
|
}));
|
|
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);
|
|
return `---
|
|
type: trekreview
|
|
review_version: "1.1"
|
|
task: "t"
|
|
slug: s
|
|
project_dir: .claude/projects/2026-10-06-s/
|
|
brief_path: .claude/projects/2026-10-06-s/brief.md
|
|
scope_sha_end: abc
|
|
reviewed_files_count: 1
|
|
${fmVerdict}findings:
|
|
${ids.map((id) => ` - ${id}`).join('\n')}
|
|
---
|
|
|
|
# Review: t
|
|
|
|
## Executive Summary
|
|
|
|
x
|
|
|
|
## Coverage
|
|
|
|
| File | Treatment | Reason |
|
|
|------|-----------|--------|
|
|
| a.mjs | summary-only | default |
|
|
|
|
## Remediation Summary
|
|
|
|
x
|
|
|
|
\`\`\`json
|
|
${json}
|
|
\`\`\`
|
|
`;
|
|
}
|
|
|
|
function runGate(text, { name = 'review.md', write = true } = {}) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-'));
|
|
try {
|
|
const p = join(dir, name);
|
|
if (write) writeFileSync(p, text);
|
|
const r = spawnSync(process.execPath, [GATE, p], { encoding: 'utf-8' });
|
|
return r;
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('verdict-gate — the CLI exists', () => {
|
|
assert.ok(existsSync(GATE), 'lib/review/verdict-gate.mjs must exist (K7)');
|
|
});
|
|
|
|
test('verdict-gate — BLOCK → exit 1', () => {
|
|
const r = runGate(review({ verdict: 'BLOCK', severities: ['BLOCKER'] }));
|
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — ALLOW → exit 0', () => {
|
|
const r = runGate(review({ verdict: 'ALLOW', severities: ['MINOR'] }));
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — WARN → exit 0', () => {
|
|
const r = runGate(review({ verdict: 'WARN', severities: ['MAJOR'] }));
|
|
assert.equal(r.status, 0, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — missing file → exit 2', () => {
|
|
const r = runGate('', { write: false });
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — a review.md without verdict → exit 2, never ALLOW', () => {
|
|
const r = runGate(review({ verdict: undefined, jsonVerdict: 'ALLOW', severities: ['MINOR'] }));
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — an unknown verdict value → exit 2', () => {
|
|
const r = runGate(review({ verdict: 'PASS', severities: ['MINOR'] }));
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — frontmatter ALLOW over a BLOCKER finding is a contradiction → exit 2', () => {
|
|
const r = runGate(review({ verdict: 'ALLOW', severities: ['BLOCKER'] }));
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — frontmatter and trailing JSON disagree → exit 2', () => {
|
|
const r = runGate(review({ verdict: 'WARN', jsonVerdict: 'BLOCK', severities: ['MAJOR'] }));
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — not a review (wrong type) → exit 2', () => {
|
|
const r = runGate(review({ verdict: 'ALLOW', severities: ['MINOR'] }).replace('type: trekreview', 'type: trekbrief'));
|
|
assert.equal(r.status, 2, r.stdout + r.stderr);
|
|
});
|
|
|
|
test('verdict-gate — --json prints the decision as JSON', () => {
|
|
const dir = mkdtempSync(join(tmpdir(), 'verdict-gate-'));
|
|
try {
|
|
const p = join(dir, 'review.md');
|
|
writeFileSync(p, review({ verdict: 'BLOCK', severities: ['BLOCKER'] }));
|
|
const r = spawnSync(process.execPath, [GATE, '--json', p], { encoding: 'utf-8' });
|
|
assert.equal(r.status, 1);
|
|
const out = JSON.parse(r.stdout);
|
|
assert.equal(out.verdict, 'BLOCK');
|
|
assert.equal(out.exit, 1);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ---- Adversarial inputs (independent review of d88245c): each was exit 0 ----
|
|
|
|
function swapJson(text, jsonSrc) {
|
|
return text.replace(/```json\n[\s\S]*?```\n$/, `\`\`\`json\n${jsonSrc}\n\`\`\`\n`);
|
|
}
|
|
|
|
test('verdict-gate — a greener verdict over a malformed or evasive JSON block is exit 2', () => {
|
|
const base = review({ verdict: 'WARN', severities: ['MAJOR'] });
|
|
assert.equal(decideVerdict(base).exit, 0, 'control: the untouched WARN review passes');
|
|
const id = String(1).padStart(40, 'a');
|
|
for (const json of [
|
|
'{}',
|
|
'null',
|
|
`{"verdict":"WARN","counts":{"BLOCKER":0},"findings":[{"id":"${id}","severity":"blocker"}]}`,
|
|
`{"verdict":"WARN","counts":{"blocker":1},"findings":[{"id":"${id}","severity":"MAJOR"}]}`,
|
|
`{"verdict":"WARN","counts":{},"findings":{"${id}":{"severity":"BLOCKER"}}}`,
|
|
]) {
|
|
assert.equal(decideVerdict(swapJson(base, json)).exit, 2, json);
|
|
}
|
|
});
|
|
|
|
test('verdict-gate — the trailing json block must be the last fence', () => {
|
|
const base = review({ verdict: 'WARN', severities: ['MAJOR'] });
|
|
assert.equal(decideVerdict(`${base}\n\`\`\`json\n{"verdict":"WARN","counts":{},"findings":[]}\n\`\`\`\n`).exit, 2,
|
|
'a later json fence cannot replace the real block');
|
|
assert.equal(decideVerdict(base.replace(/```json\n(?=[\s\S]*```\n$)/, '```jsonc\n')).exit, 2,
|
|
'a jsonc last fence is not the json block');
|
|
});
|
|
|
|
test('verdict-gate — a ## Findings (BLOCKER) entry under a WARN verdict is exit 2', () => {
|
|
const base = review({ verdict: 'WARN', severities: ['MAJOR'] });
|
|
const t = base.replace('## Coverage', '## Findings (BLOCKER)\n\n### deadbeef\n\n- file: a.mjs\n\n## Coverage');
|
|
assert.equal(decideVerdict(t).exit, 2);
|
|
});
|
|
|
|
test('verdict-gate — two verdict lines in the frontmatter are exit 2', () => {
|
|
const base = review({ verdict: 'ALLOW', severities: ['MINOR'] });
|
|
assert.equal(decideVerdict(base.replace('verdict: ALLOW\n', 'verdict: BLOCK\nverdict: ALLOW\n')).exit, 2);
|
|
});
|
|
|
|
test('verdict-gate — frontmatter finding ids that differ from the JSON block are exit 2', () => {
|
|
const base = review({ verdict: 'WARN', severities: ['MAJOR'] });
|
|
const t = base.replace(` - ${String(1).padStart(40, 'a')}`, ` - ${'b'.repeat(40)}`);
|
|
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 {
|
|
const a = join(dir, 'a.md');
|
|
const b = join(dir, 'b.md');
|
|
writeFileSync(a, review({ verdict: 'ALLOW', severities: ['MINOR'] }));
|
|
writeFileSync(b, review({ verdict: 'BLOCK', severities: ['BLOCKER'] }));
|
|
const r = spawnSync(process.execPath, [GATE, a, b], { encoding: 'utf-8' });
|
|
assert.equal(r.status, 2);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('verdict-gate — run through a symlink and a path with a space it still decides (realpath main guard)', () => {
|
|
// The old `file://${argv[1]}` guard is a no-op under a symlink or a space:
|
|
// the CLI would exit 0 without reading anything — a silent green.
|
|
const dir = mkdtempSync(join(tmpdir(), 'verdict gate '));
|
|
try {
|
|
const link = join(dir, 'gate link.mjs');
|
|
symlinkSync(GATE, link);
|
|
const p = join(dir, 'my review.md');
|
|
writeFileSync(p, review({ verdict: 'BLOCK', severities: ['BLOCKER'] }));
|
|
const r = spawnSync(process.execPath, [link, p], { encoding: 'utf-8' });
|
|
assert.equal(r.status, 1, r.stdout + r.stderr);
|
|
assert.match(r.stdout, /BLOCK/);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|