voyage/tests/lib/review-loop-docs.test.mjs
Kjell Tore Guttormsen 08e8aa1c66
docs(review): stop rule in the plan loop, review loop and critic prompts (decision 217, 220)
/trekplan Phase 9: when --record or the gate reports the stop rule there
is no new round; AskUserQuestion with the class and counts, "Known issue"
(--accept-open, a security finding refused) or "New mechanism" (stop, the
plan stays unapproved); the round-3 blocker path never offers
--extra-round after the stop rule. Phase 10 prints round of
max_rounds + extra_rounds.

/trekexecute Phase 7.2 step 4: after a round-2 BLOCK or fixed-pending,
before any R-step, review-rounds.mjs prepare --dry-run; review-stop-rule:
gives the same question ("Known issue" = /trekreview --answers, a BLOCKER
keeps the merge locked; "New mechanism" = stop). /trekreview Phase 3 tells
the stop rule's exit 1 from the cap's. plan-critic and scope-guardian:
rule_key names the class, never the instance.

New pins in review-loop-docs, critic-severity-rules and review-prose-pins.

Part 1, step 1.4 of the E plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:16:05 +01:00

169 lines
8.5 KiB
JavaScript

// tests/lib/review-loop-docs.test.mjs
// Prose pins for the converging critic loop (decision 43): /trekplan Phase 9,
// Handover 4, CLAUDE.md and the agent files must state rule A and rule B in the
// exact words lib/review/critic-diff.mjs exports, name the optional verdict
// fields, and no longer say the loop ends on a round with no BLOCKER.
// Texts are compared with every run of whitespace read as one space, so a
// sentence wrapped over two lines still counts.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { RULE_A, RULE_B, DIFF_FIELDS } from '../../lib/review/critic-diff.mjs';
import { deliverySection } from '../helpers/changelog-section.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const read = rel => readFileSync(join(ROOT, rel), 'utf-8');
const flat = text => String(text).replace(/\s+/g, ' ');
function phase(text, n) {
const start = text.indexOf(`\n## Phase ${n} `);
assert.ok(start >= 0, `## Phase ${n} missing`);
const end = text.indexOf('\n## Phase ', start + 1);
return text.slice(start, end < 0 ? undefined : end);
}
function handover4() {
const contract = read('docs/HANDOVER-CONTRACTS.md');
const start = contract.indexOf('## Handover 4');
const end = contract.indexOf('## Handover 5');
assert.ok(start >= 0 && end > start, 'Handover 4 and Handover 5 headings found in order');
return contract.slice(start, end);
}
const P9 = flat(phase(read('commands/trekplan.md'), 9));
test('RULE_A stands verbatim in Phase 9, Handover 4 and CLAUDE.md', () => {
const rule = flat(RULE_A);
assert.ok(P9.includes(rule), 'Phase 9 of commands/trekplan.md states RULE_A');
assert.ok(flat(handover4()).includes(rule), 'Handover 4 of docs/HANDOVER-CONTRACTS.md states RULE_A');
assert.ok(flat(read('CLAUDE.md')).includes(rule), 'CLAUDE.md states RULE_A');
});
test('RULE_B stands verbatim in Phase 9 and in both reviewer agent files', () => {
const rule = flat(RULE_B);
assert.ok(P9.includes(rule), 'Phase 9 of commands/trekplan.md states RULE_B');
for (const rel of ['agents/plan-critic.md', 'agents/scope-guardian.md']) {
assert.ok(flat(read(rel)).includes(rule), `${rel} states RULE_B`);
}
});
test('Handover 4 names every DIFF_FIELDS entry and says absent means round-1 behaviour', () => {
const h4 = handover4();
assert.ok(DIFF_FIELDS.length > 0, 'DIFF_FIELDS lists the optional fields');
const missing = DIFF_FIELDS.filter(f => !h4.includes(`\`${f}\``));
assert.deepEqual(missing, [], `Handover 4 does not name (backticked): ${missing.join(', ')}`);
assert.ok(flat(h4).includes('absent means round-1 behaviour'), 'Handover 4 says "absent means round-1 behaviour"');
});
test('Phase 9 names plan-gate --diff and says round 1 is unchanged', () => {
assert.ok(P9.includes('plan-gate.mjs --diff'), 'Phase 9 runs plan-gate.mjs --diff');
assert.match(P9, /round 1 is unchanged/i, 'Phase 9 says "round 1 is unchanged"');
});
test('Phase 9 no longer says the loop ends on a round with no BLOCKER', () => {
assert.ok(!P9.includes('ends on a round with no BLOCKER'), 'the stale "ends on a round with no BLOCKER" is gone');
});
// The narrow round of the A/B review (operator decisions 48 and 50): the
// CHANGELOG's delivery section records it, Phase 9 says what a merge keeps, and
// Handover 4 states the matcher's overlap rule and the distinct id. The section
// is found by its title, before and after the release commit
// (tests/helpers/changelog-section.mjs). The release check is imported as a
// module namespace, so a missing parseDoors export fails an assertion, not the
// load.
const releaseCheck = await import('../../scripts/release-check.mjs');
// The delivery section's body, up to the next `## ` heading.
const unreleasedBody = () => deliverySection(read('CHANGELOG.md')).body;
// A `### <name>` body of the delivery section, up to the next heading.
function subsection(body, name) {
const lines = body.split('\n');
const start = lines.findIndex(l => l.replace(/\s+$/, '') === `### ${name}`);
assert.ok(start >= 0, `the delivery section has a "### ${name}" heading`);
let end = lines.length;
for (let i = start + 1; i < lines.length; i++) {
if (/^###? /.test(lines[i])) { end = i; break; }
}
return lines.slice(start + 1, end).join('\n');
}
// The top-level bullets of a slice: each `- ` line at column 0 with its
// wrapped and nested lines, whitespace read as one space.
function topBullets(slice) {
const out = [];
for (const line of slice.split('\n')) {
if (/^- /.test(line)) out.push(line);
else if (out.length && line.trim() !== '') out[out.length - 1] += `\n${line}`;
}
return out.map(flat);
}
test('CHANGELOG: the narrow round of operator decisions 48 and 50', () => {
assert.equal(typeof releaseCheck.parseDoors, 'function', 'scripts/release-check.mjs exports parseDoors');
const body = unreleasedBody();
const d = releaseCheck.parseDoors(body);
assert.equal(d.found, true, 'the delivery section has a "### Doors" heading');
assert.deepEqual(d.bad, [], 'every top-level door line reads "- one-way: ..." or "- two-way: ..."');
const doors48 = d.entries.filter(e => /operator\s+decision\s+48\b/.test(flat(e.text)));
assert.ok(doors48.length >= 3,
`at least three door lines name operator decision 48: ${JSON.stringify(d.entries.map(e => e.text))}`);
const fixed = flat(subsection(body, 'Fixed'));
for (const id of ['4ab24b07', 'd03835c4', '69c9cebd', '8782602b', 'ebe164e2']) {
assert.ok(fixed.includes(id), `the Fixed slice names review ${id}`);
}
const known = topBullets(subsection(body, 'Known issues'));
for (const id of ['8a9b5a17', '8004ea85', 'c257625e', '818b1346']) {
const hits = known.filter(b => b.includes(id));
assert.equal(hits.length, 1, `exactly one Known-issues bullet names ${id}: ${JSON.stringify(hits)}`);
assert.match(hits[0], /operator\s+decision\s+48\b/, `the ${id} Known-issues bullet names operator decision 48: ${JSON.stringify(hits[0])}`);
}
const residual = known.filter(b => b.includes('4ab24b07'));
assert.equal(residual.length, 1, `exactly one Known-issues bullet names 4ab24b07: ${JSON.stringify(residual)}`);
assert.match(residual[0], /operator\s+decision\s+50\b/, `the 4ab24b07 Known-issues bullet names operator decision 50: ${JSON.stringify(residual[0])}`);
});
test('Phase 9 says the merge keeps the copy rule B counts more severe', () => {
assert.ok(P9.includes('keeps the copy rule B counts more severe'), 'Phase 9 says "keeps the copy rule B counts more severe"');
assert.ok(!P9.includes('keeps the more severe of the two severities'), 'the stale "keeps the more severe of the two severities" is gone');
});
test('Handover 4 states the overlap match and the distinct id', () => {
const h4 = flat(handover4());
assert.ok(h4.includes('only on overlapping text'), 'Handover 4 says the gate matches "only on overlapping text"');
assert.ok(h4.includes('`<rule_key>#n`'), 'Handover 4 names `<rule_key>#n`');
});
// ---- [STP] the stop rule (E plan, Part 1 step 1.4; decisions 217, 220) ----------------
const P10 = flat(phase(read('commands/trekplan.md'), 10));
/** Phase 9's stop-rule paragraph: from its bold lead to the next bold lead that opens a line. */
function stopPart() {
const at = P9.indexOf('**The stop rule**');
assert.ok(at >= 0, 'Phase 9 has a **The stop rule** paragraph');
const next = P9.indexOf('**After round 3', at);
return P9.slice(at, next < 0 ? undefined : next);
}
test('[STP] Phase 9: the stop rule gives no new round and asks Known issue or New mechanism, never one more round', () => {
const s = stopPart();
for (const want of ['stop_rule.fired', 'PLAN_GATE_STOP_RULE', 'no new round', 'AskUserQuestion',
'**Known issue**', 'plan-gate.mjs --accept-open', 'PLAN_GATE_SECURITY_OPEN',
'**New mechanism**', 'the plan stays unapproved', 'the direction goes to the operator']) {
assert.ok(s.includes(want), `the stop-rule paragraph says ${JSON.stringify(want)}`);
}
assert.doesNotMatch(s, /\*\*One more round\*\*/, 'the stop-rule question has no "One more round" option');
assert.ok(P9.includes('never offers `--extra-round` after the stop rule'),
'the round-3 blocker path says it never offers --extra-round after the stop rule');
});
test('[STP] Phase 10 prints the round of max_rounds + extra_rounds', () => {
assert.ok(P10.includes('**Critic rounds:** {round} of {max_rounds + extra_rounds}'), 'Phase 10 prints round of max_rounds + extra_rounds');
assert.ok(!P10.includes('**Critic rounds:** {N} of {max}'), 'the old "{N} of {max}" is gone');
});