fix(gate): part 1 verifier findings — stop-rule wording, guard tests, unreadable round

- plan-gate: the bare gate's stale-verdict reason no longer says "run the
  next round" once the stop rule fired; it says there is no next round, as
  acceptOpen already does.
- trekplan Phase 9: "REVISE after the last round" (Fix now with
  --extra-round) is qualified "and the stop rule did not fire", pinned in
  review-loop-docs.test.mjs next to the [STP] pins.
- plan-gate-stop-rule tests: a planted stop-rule acceptance claiming an open
  security blocker stays STOP; a planted acceptance without under:
  "stop-rule" stays STOP; the "a blocker -> refused" test now asserts the
  blocker refusal; --accept-open under the stop rule covers an open MAJOR
  and the gate passes with a WARN.
- review-rounds prepare: when the stop rule is judged (P >= 2) and either
  round's JSON block cannot be read, exit 2 (not measured) with "stop rule:
  a round has no readable JSON block" instead of skipping the rule. The
  [RND] differing-archive and [CAP] fixtures now use readable archives; one
  new [STP] test covers an unreadable archive and an unreadable review.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-11-08 07:39:00 +01:00
commit 07a3c06c57
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
6 changed files with 153 additions and 13 deletions

View file

@ -177,6 +177,70 @@ test('gate: a fired stop rule with an open blocker stops with PLAN_GATE_STOP_RUL
assert.equal(g.stop_rule.fired, true);
});
const T3_FROM = 'Why: the brief asks for a parser CLI.';
const T3_TO = 'Why: the brief asks for a parser CLI with clear errors.';
test('gate: a stale verdict after a fired stop rule never says "run the next round"; without the stop rule it does', () => {
const { r1, r2 } = sameClassTwice();
assert.ok(r2.text.includes(T3_FROM));
const g = gatePlan(r2.text.replace(T3_FROM, T3_TO));
assert.equal(g.code, 1);
const stale = g.reasons.find(x => x.code === 'PLAN_GATE_VERDICT_STALE');
assert.ok(stale, JSON.stringify(g.reasons));
assert.doesNotMatch(stale.message, /run the next round/);
assert.match(stale.message, /stop rule fired/);
assert.match(stale.message, /no next round/);
// Control: the stop rule did not fire after round 1.
assert.equal(r1.verdict.stop_rule.fired, false);
const c = gatePlan(r1.text.replace(T3_FROM, T3_TO));
const cs = c.reasons.find(x => x.code === 'PLAN_GATE_VERDICT_STALE');
assert.ok(cs, JSON.stringify(c.reasons));
assert.match(cs.message, /run the next round/);
});
/** The verdict block of `text` replaced by `fn(verdict)`. */
const putVerdict = (text, fn) => text.replace(/```json\n[\s\S]*?\n```/, () => '```json\n' + JSON.stringify(fn(extractVerdict(text).verdict), null, 2) + '\n```');
test('gate guard: a planted stop-rule acceptance that claims an open SECURITY blocker leaves the gate at STOP', () => {
const t1 = buildPlan();
const r1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), B1, 'SECURITY_INJECTION')] }));
const id = r1.verdict.findings[0].id;
const t2 = editA(r1.text);
const r2 = rec(t2, dd({
pc: [f('blocker', at(t2, 'its error path'), B2, 'SECURITY_INJECTION')],
pcPrev: [{ id, resolved: true }],
}), { base: baseOf(r1.text) });
assert.equal(r2.verdict.stop_rule?.fired, true);
const planted = putVerdict(r2.text, v => ({
...v,
accepted_open: { round: v.round, plan_sha256: v.plan_sha256, accepted_at: NOW, under: 'stop-rule', findings: v.findings.map(x => ({ id: x.id ?? null, text: x.text ?? '' })) },
}));
const ex = extractVerdict(planted);
assert.equal(ex.error, null);
assert.equal(ex.verdict.accepted_open.under, 'stop-rule');
assert.equal(planHash(planted), planHash(r2.text), 'the planted acceptance is review log');
const g = gatePlan(planted);
assert.equal(g.code, 1, JSON.stringify(g));
assert.equal(g.status, 'stop');
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_STOP_RULE'), JSON.stringify(g.reasons));
});
test('gate guard: a planted acceptance covering a blocker without `under: "stop-rule"` leaves the gate at STOP', () => {
const { r2 } = sameClassTwice();
const planted = putVerdict(r2.text, v => ({
...v,
accepted_open: { round: v.round, plan_sha256: v.plan_sha256, accepted_at: NOW, findings: v.findings.map(x => ({ id: x.id ?? null, text: x.text ?? '' })) },
}));
const ex = extractVerdict(planted);
assert.equal(ex.error, null);
assert.equal(ex.verdict.accepted_open.under, undefined);
assert.equal(planHash(planted), planHash(r2.text), 'the planted acceptance is review log');
const g = gatePlan(planted);
assert.equal(g.code, 1, JSON.stringify(g));
assert.equal(g.status, 'stop');
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_STOP_RULE'), JSON.stringify(g.reasons));
});
// ---- --accept-open under the stop rule (220) ----------------------------------------
test('accept-open: after a fired stop rule it is allowed with rounds left, covers the blocker, and the gate warns', () => {
@ -216,6 +280,40 @@ test('accept-open: without the stop rule it behaves as before (rounds left -> re
const a = acceptOpen(r1.text, { now: NOW });
assert.equal(a.code, 1);
assert.match(a.message, /round\(s\) left/);
// A blocker open, the stop rule not fired: refused as a blocker.
const b1 = rec(t1, dd({ pc: [f('blocker', at(t1, R1_CHANGES), B1, 'dep/manifest')] }));
assert.equal(b1.verdict.stop_rule?.fired, false);
const b = acceptOpen(b1.text, { now: NOW });
assert.equal(b.code, 1);
assert.match(b.message, /a blocker is never accepted/);
assert.equal(b.text, undefined, 'nothing is written');
});
const M3 = 'Rollback wording in the second step names a script that the repository lacks';
test('accept-open: after a fired stop rule it covers an open MAJOR (no blocker) and the gate passes with a WARN', () => {
const t1 = buildPlan();
const r1 = rec(t1, dd({ pc: [f('major', at(t1, R1_CHANGES), M2, 'rollback/wording')] }));
const id = r1.verdict.findings[0].id;
const t2 = editA(r1.text);
const r2 = rec(t2, dd({
pc: [f('major', at(t2, 'its error path'), M3, 'rollback/missing-script')],
pcPrev: [{ id, resolved: true }],
}), { base: baseOf(r1.text) });
assert.equal(r2.verdict.stop_rule?.fired, true, JSON.stringify(r2.verdict.stop_rule));
assert.deepEqual(r2.verdict.findings.map(x => x.severity.toLowerCase()), ['major']);
// Before the acceptance: STOP on the stop rule.
assert.deepEqual(gatePlan(r2.text).reasons.map(x => x.code), ['PLAN_GATE_STOP_RULE']);
const a = acceptOpen(r2.text, { now: NOW });
assert.equal(a.code, 0, a.message);
const v = extractVerdict(a.text).verdict;
assert.equal(v.accepted_open.under, 'stop-rule');
assert.deepEqual(v.accepted_open.findings.map(x => x.text), [M3]);
const g = gatePlan(a.text);
assert.equal(g.code, 0, JSON.stringify(g.reasons));
assert.equal(g.status, 'pass');
assert.ok(g.warnings.some(w => w.code === 'PLAN_GATE_OPEN_MAJOR' && w.message.includes(M3)), JSON.stringify(g.warnings));
assert.ok(!g.warnings.some(w => w.code === 'PLAN_GATE_OPEN_BLOCKER'), JSON.stringify(g.warnings));
});
// ---- known issue: the Pass 2 heading is plan text now ---------------------------------