// tests/validators/plan-gate-open-major.test.mjs // Operator decision 2026-10-08 (voyage-lekerepo trial point 6, «samme skala, // og du spør»). In the trial the critics marked a non-discriminating test as // minor in round 3; with no round left there was no way to fix it, it was // built, and the review called the same weakness MAJOR (MISSING_TEST). Now: // - plan-critic and scope-guardian rate on the review's scale (a test that // cannot fail on a wrong implementation is MAJOR, as MISSING_TEST is); // - an open MAJOR stops the gate: with rounds left the loop revises; after // the last round the operator is asked — fix now (one extra round) or // build as is, recorded by `--accept-open` after their yes; // - an acceptance is bound to the round and the plan text it was given for. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { gatePlan, recordVerdict, planHash, acceptOpen, MAX_ROUNDS } from '../../lib/validators/plan-gate.mjs'; import { buildPlan } from '../helpers/plan-v18-builder.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const GATE = join(ROOT, 'lib', 'validators', 'plan-gate.mjs'); const BOTH = [{ agent: 'plan-critic', present: true }, { agent: 'scope-guardian', present: true }]; const finding = (severity, text) => ({ id: `${severity}-${text.length}`, file: 'plan.md', line: 3, rule_key: 'test-quality', severity, text, raised_by: ['plan-critic'] }); const dedup = (...f) => ({ findings: f, sources: BOTH }); const rec = (text, d, opts = {}) => recordVerdict(text, d, { reviewed: planHash(text), ...opts }); const codes = (list) => list.map(x => x.code); function lastRound(...f) { let text = buildPlan(); for (let i = 1; i < MAX_ROUNDS; i++) text = rec(text, dedup()).text; const r = rec(text, dedup(...f)); assert.equal(r.code, 0, r.message); assert.equal(r.verdict.round, MAX_ROUNDS); return r.text; } const WEAK = 'Step 2 test passes for any truncation at a hyphen: it cannot fail on the wrong cut'; test('an open MAJOR with rounds left stops and says: revise and run the next round', () => { const g = gatePlan(rec(buildPlan(), dedup(finding('major', WEAK))).text); assert.equal(g.code, 1); const r = g.reasons.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR'); assert.ok(r, codes(g.reasons).join(',')); assert.match(r.message, /next round/); }); test('an open MAJOR after the last round stops and says: ask the operator, fix now or build as is', () => { const g = gatePlan(lastRound(finding('major', WEAK))); assert.equal(g.code, 1); const r = g.reasons.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR_FINAL'); assert.ok(r, codes(g.reasons).join(',')); assert.match(r.message, /ask the operator/); assert.match(r.message, /--extra-round/); assert.match(r.message, /--accept-open/); }); test('--accept-open after the operator\'s yes: the gate passes, the accepted majors stay listed as a warning', () => { const text = lastRound(finding('major', WEAK), finding('minor', 'typo')); const a = acceptOpen(text, { now: '2026-10-08T07:00:00.000Z' }); assert.equal(a.code, 0, a.message); assert.equal(planHash(a.text), planHash(text), 'the acceptance is review log: it does not stale the verdict'); const g = gatePlan(a.text); assert.equal(g.code, 0, JSON.stringify(g.reasons)); const w = g.warnings.find(x => x.code === 'PLAN_GATE_OPEN_MAJOR'); assert.ok(w); assert.match(w.message, /accepted by the operator/); }); test('--accept-open never accepts a BLOCKER, and refuses when no MAJOR is open', () => { assert.equal(acceptOpen(lastRound(finding('blocker', 'Step 2 reads nothing'), finding('major', WEAK))).code, 1); assert.equal(acceptOpen(lastRound(finding('minor', 'typo'))).code, 1); }); test('an acceptance does not carry over to a later round', () => { const accepted = acceptOpen(lastRound(finding('major', WEAK))).text; const revised = accepted.replace('Add `run1()`', 'Add `run1()` and its error path'); const next = rec(revised, dedup(finding('major', 'Step 1 error path untested')), { extraRound: true }); assert.equal(next.code, 0, next.message); const g = gatePlan(next.text); assert.ok(codes(g.reasons).includes('PLAN_GATE_OPEN_MAJOR_FINAL'), codes(g.reasons).join(',')); }); test('CLI: --accept-open writes the acceptance into the plan and exits 0', () => { const dir = mkdtempSync(join(tmpdir(), 'plan-accept-')); try { const p = join(dir, 'plan.md'); writeFileSync(p, lastRound(finding('major', WEAK))); assert.equal(spawnSync(process.execPath, [GATE, p]).status, 1); const a = spawnSync(process.execPath, [GATE, '--accept-open', p], { encoding: 'utf8' }); assert.equal(a.status, 0, a.stdout + a.stderr); assert.match(readFileSync(p, 'utf8'), /"accepted_open"/); assert.equal(spawnSync(process.execPath, [GATE, p]).status, 0); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('prose: critics rate a test weakness on the review\'s scale; Phase 9 asks after the last round', () => { for (const a of ['agents/plan-critic.md', 'agents/scope-guardian.md']) { assert.match(readFileSync(join(ROOT, a), 'utf8'), /cannot fail on a wrong\s+implementation[\s\S]{0,200}\*\*major\*\*/, a); } const plan = readFileSync(join(ROOT, 'commands', 'trekplan.md'), 'utf8'); assert.match(plan, /plan-gate\.mjs --accept-open/); assert.doesNotMatch(plan, /carry the open majors into the Phase 10 summary as known/); });