// tests/lib/proevesett.test.mjs // Structure of the plugin-eval suite under evals/ (docs/proevesett.md). The // suite itself runs headless children and costs money; these tests pin what // can be checked offline: that each grader can see what it claims to see. // // - the new case for veikart steg 1's gate exists, and its fixture really // reaches the intent gate (passes the sequencing gate, has no marker) // - case 4 (plan-halts-without-phase-signals) can only be stopped by the // sequencing gate: its fixture carries a current intent approval // - review-requires-project: the error line is written by CODE (the arg // parser), and the grader reads the parser's code from the trace // - grader weaknesses the PM re-measurement found (2026-09-23): // PASS/FAIL as raw substrings, no-error-code missing REVIEW_WRONG_TYPE, // no-write blind to Bash writes // // Trace facts this relies on (measured on the PM's recorded traces): the // expanded command prose is NOT in the trace; tool output is, JSON-escaped // once (\"code\": \"REVIEW_BAD_FINDING_ID\"). import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const EVALS = join(ROOT, 'evals'); const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); /** Minimal frontmatter reader for grader files: key: value, quoted or not. */ function grader(rel) { const text = read(rel); const m = /^---\n([\s\S]*?)\n---/.exec(text); assert.ok(m, `${rel}: no frontmatter`); const out = {}; for (const line of m[1].split('\n')) { const kv = /^(\w+):\s*(.*)$/.exec(line); if (!kv) continue; let v = kv[2].trim(); if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1).replace(/''/g, "'"); else if (v.startsWith('"') && v.endsWith('"')) v = JSON.parse(v); out[kv[1]] = v; } return out; } const cases = () => readdirSync(EVALS).filter((d) => existsSync(join(EVALS, d, 'prompt.md'))).sort(); const graders = (c) => readdirSync(join(EVALS, c, 'graders')).filter((f) => f.endsWith('.md')).sort(); /** How a tool's JSON output appears inside a trace line (escaped once). */ const inTrace = (obj, indent) => JSON.stringify(JSON.stringify(obj, null, indent)); /** How a Bash tool input appears to tool_used's input_match (JSON-encoded input). */ const bashInput = (command) => JSON.stringify({ command, description: 'Validate the review -> report' }); const COMMAND_PROSE = readdirSync(join(ROOT, 'commands')).map((f) => read(join('commands', f))).join('\n'); // ---- the new case: veikart steg 1's intent gate ---- const GATE_CASE = 'plan-halts-without-intent-approval'; test('proevesett: a case exists for the steg 1 intent gate, with its graders', () => { assert.ok(cases().includes(GATE_CASE), `evals/${GATE_CASE} missing`); assert.deepEqual(graders(GATE_CASE), ['gate-ran.md', 'names-code.md', 'no-agent.md', 'no-plan-file.md']); const prompt = read(`evals/${GATE_CASE}/prompt.md`); assert.match(prompt, /^\/voyage:trekplan --project proj$/m); assert.match(prompt, /^runs: 1$/m); const g = grader(`evals/${GATE_CASE}/graders/gate-ran.md`); assert.equal(g.type, 'tool_used'); assert.equal(g.tool, 'Bash'); assert.match('node /p/lib/validators/intent-approval.mjs --check --json "proj/brief.md"', new RegExp(g.input_match)); const n = grader(`evals/${GATE_CASE}/graders/names-code.md`); assert.equal(n.target, 'trace'); const re = new RegExp(n.pattern); assert.match(inTrace({ valid: false, errors: [{ code: 'BRIEF_INTENT_NOT_APPROVED' }] }, 2), re); assert.doesNotMatch(COMMAND_PROSE, re, 'the pattern must not be satisfiable by quoting the command prose'); assert.doesNotMatch(inTrace({ valid: false, errors: [{ code: 'BRIEF_V51_MISSING_SIGNALS' }] }, 2), re); const noAgent = grader(`evals/${GATE_CASE}/graders/no-agent.md`); assert.deepEqual([noAgent.type, noAgent.tool, noAgent.min, noAgent.max], ['tool_used', 'Agent', '0', '0']); const noPlan = grader(`evals/${GATE_CASE}/graders/no-plan-file.md`); assert.deepEqual([noPlan.type, noPlan.path, noPlan.exists], ['file_exists', '**/plan.md', 'false']); }); test('proevesett: the gate case fixture passes the sequencing gate and stops at the intent gate', () => { const ws = mkdtempSync(join(tmpdir(), 'proevesett-')); try { const s = spawnSync('bash', [join(EVALS, GATE_CASE, 'scaffold.sh')], { cwd: ws, encoding: 'utf8' }); assert.equal(s.status, 0, s.stderr); const brief = join(ws, 'proj', 'brief.md'); const v = spawnSync('node', [join(ROOT, 'lib/validators/brief-validator.mjs'), '--soft', '--json', brief], { encoding: 'utf8' }); assert.equal(v.status, 0, `brief-validator must pass so the run reaches the intent gate: ${v.stdout} ${v.stderr}`); const g = spawnSync('node', [join(ROOT, 'lib/validators/intent-approval.mjs'), '--check', '--json', brief], { encoding: 'utf8' }); assert.equal(g.status, 1); assert.deepEqual(JSON.parse(g.stdout).errors.map((e) => e.code), ['BRIEF_INTENT_NOT_APPROVED']); } finally { rmSync(ws, { recursive: true, force: true }); } }); // ---- case 4: only the sequencing gate may stop it ---- // Measured 2026-09-23: with no intent marker the child sometimes ran the // intent gate first and halted there (PM: 6 of 12 on dc9b480, 7 of 10 on // 66e1fa1). The halt was right, but the case then measured the wrong gate. const SEQ_CASE = 'plan-halts-without-phase-signals'; test('plan-halts-without-phase-signals: the fixture carries a current intent approval and fails only the sequencing gate', () => { const ws = mkdtempSync(join(tmpdir(), 'proevesett-')); try { const s = spawnSync('bash', [join(EVALS, SEQ_CASE, 'scaffold.sh')], { cwd: ws, encoding: 'utf8' }); assert.equal(s.status, 0, s.stderr); const brief = join(ws, 'proj', 'brief.md'); const g = spawnSync('node', [join(ROOT, 'lib/validators/intent-approval.mjs'), '--check', '--json', brief], { encoding: 'utf8' }); assert.equal(g.status, 0, `the intent gate must pass so only the sequencing gate can stop the run: ${g.stdout}`); const v = spawnSync('node', [join(ROOT, 'lib/validators/brief-validator.mjs'), '--soft', '--json', brief], { encoding: 'utf8' }); assert.deepEqual(JSON.parse(v.stdout).errors.map((e) => e.code), ['BRIEF_V51_MISSING_SIGNALS']); } finally { rmSync(ws, { recursive: true, force: true }); } }); test('plan-halts-without-phase-signals: a halt at the intent gate fails the case', () => { assert.ok(graders(SEQ_CASE).includes('no-intent-halt.md'), graders(SEQ_CASE).join(',')); const g = grader(`evals/${SEQ_CASE}/graders/no-intent-halt.md`); assert.deepEqual([g.type, g.target, g.match], ['regex', 'trace', 'not_contains']); const re = new RegExp(g.pattern); for (const code of ['BRIEF_INTENT_NOT_APPROVED', 'BRIEF_INTENT_APPROVAL_STALE', 'BRIEF_INTENT_APPROVAL_INVALID']) { assert.match(inTrace({ valid: false, errors: [{ code }] }, 2), re, `${code} must fail the case`); } assert.doesNotMatch(inTrace({ valid: true, errors: [], warnings: [] }, 2), re); assert.doesNotMatch(inTrace({ valid: false, errors: [{ code: 'BRIEF_V51_MISSING_SIGNALS' }] }, 2), re); assert.doesNotMatch(COMMAND_PROSE, re, 'the command prose must not trip the grader'); }); // ---- case 5: the missing-flag error comes from code ---- test('review-requires-project: the arg parser, run as trekreview.md spells it, prints the error (exit 1)', () => { const lines = read('commands/trekreview.md').split('\n').filter((l) => /^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l)); assert.equal(lines.length, 1, 'trekreview.md must carry exactly one arg-parser line'); const line = lines[0].trim(); assert.match(line, /\$ARGUMENTS/, 'the parser must receive $ARGUMENTS (the Bash tool has no "$@")'); const run = (args) => spawnSync('bash', ['-c', line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' }); const none = run(''); assert.equal(none.status, 1, `missing --project must exit 1: ${none.stdout} ${none.stderr}`); assert.deepEqual(JSON.parse(none.stdout).errors.map((e) => e.code), ['ARG_REQUIRED_MISSING']); assert.match(none.stderr, /^Error: --project