// 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'; 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, CLAUDE.md and the planning orchestrator', () => { 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'); assert.ok(flat(read('agents/planning-orchestrator.md')).includes(rule), 'agents/planning-orchestrator.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 open Unreleased section records it, Phase 9 says what a merge // keeps, and Handover 4 states the matcher's overlap rule and the distinct id. // 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 body of the open `## Unreleased` section, up to the next `## ` heading. function unreleasedBody() { const lines = read('CHANGELOG.md').split('\n'); const start = lines.findIndex(l => /^## Unreleased\b/.test(l)); assert.ok(start >= 0, 'CHANGELOG.md has a "## Unreleased" section'); 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'); } // A `### ` body of the Unreleased 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 Unreleased 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 Unreleased 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('`#n`'), 'Handover 4 names `#n`'); });