fix(validate): a check that can never run is caught when the brief or plan is written

voyage-lekerepo end-to-end trial (2026-10-06), report points 2, 3, 4, 9:
11 of 13 brief criteria were NOT RUN at review with no warning in /trekbrief;
a plan `git grep` criterion passed validator, gate and three critic rounds,
then felled the run in Phase 7; the End-to-end field and Phase 7's text
search never met; a prose Verify left the test command undefined.

- criteria-runner: runnableCheck() screens a declared check without running
  it (same allowlist; an npm run script is not looked up at write time);
  /bin/bash tests/<script> allowed; the plan report carries end_to_end,
  verified when a passed criterion runs a step's End-to-end command (the
  older text form still counts).
- plan-validator 1.8: PLAN_VERIFICATION_NOT_RUNNABLE,
  PLAN_STEP_VERIFY_NOT_RUNNABLE, PLAN_END_TO_END_NOT_RUNNABLE,
  PLAN_END_TO_END_NOT_VERIFIED (the gate inherits them).
- brief-validator: BRIEF_SC_NOT_RUNNABLE warning; /trekbrief offers a
  tests/ script; brief-reviewer no longer proposes unrunnable commands.
- trekexecute: Phase 7 copies end_to_end from the runner; Verify's test
  command is its first backticked span.

Measured on the trial's own brief and plan: 11 warnings (the 11 NOT RUN),
and the plan stops on V5 git grep and the unlisted End-to-end command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-08 06:42:33 +02:00
commit c56f852ab6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 422 additions and 38 deletions

View file

@ -73,7 +73,7 @@ test('K3: no critic verdict → 1 (the plan has not been through the critic)', (
});
test('K3: an invalid plan → 1 even with a clean verdict', () => {
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] });
const bad = buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] });
const g = gatePlan(rec(bad, dedupOut()).text);
assert.equal(g.code, 1);
assert.ok(g.reasons.some(x => x.code === 'PLAN_GATE_INVALID_PLAN'));

View file

@ -31,7 +31,7 @@ test('a complete 1.8 architectural plan validates strict', () => {
});
test('1.8: a step without `Test first:` is rejected', () => {
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node src/cli.mjs' }] }));
const r = validatePlanContent(buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] }));
assert.equal(r.valid, false);
assert.ok(codes(r).includes('PLAN_STEP_NO_TEST_FIRST'), codes(r).join(','));
assert.match(r.errors.find(e => e.code === 'PLAN_STEP_NO_TEST_FIRST').message, /Step 1/);
@ -57,10 +57,10 @@ test('1.8: `End-to-end:` without a backticked command does not count', () => {
test('1.8: K4 fields are optional, but an empty one is rejected', () => {
const withFields = buildPlan({ steps: [
{ interfaceOut: '`run1(): number` read by Step 2', reviewFocus: 'empty input — pinned by `tests/mod1.test.mjs`' },
{ interfaceIn: '`run1()` from Step 1', endToEnd: 'node src/cli.mjs' },
{ interfaceIn: '`run1()` from Step 1', endToEnd: 'bash tests/e2e.sh' },
] });
assert.deepEqual(validatePlanContent(withFields).errors, []);
const empty = buildPlan({ steps: [{ interfaceIn: '' }, { endToEnd: 'node src/cli.mjs' }] });
const empty = buildPlan({ steps: [{ interfaceIn: '' }, { endToEnd: 'bash tests/e2e.sh' }] });
assert.ok(codes(validatePlanContent(empty)).includes('PLAN_STEP_FIELD_EMPTY'));
});
@ -236,7 +236,7 @@ test('CLI --strict exits 1 on a 1.8 plan missing Test first; exits 0 through a s
const dir = mkdtempSync(join(tmpdir(), 'plan v18 '));
try {
const bad = join(dir, 'bad.md');
writeFileSync(bad, buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'node x' }] }));
writeFileSync(bad, buildPlan({ steps: [{ testFirst: false }, { endToEnd: 'bash tests/e2e.sh' }] }));
let code = 0;
try { execFileSync('node', [VALIDATOR, '--strict', '--json', bad], { stdio: 'pipe' }); } catch (e) { code = e.status; }
assert.equal(code, 1);

View file

@ -0,0 +1,197 @@
// tests/validators/runnable-checks.test.mjs
// A check that can never run is caught when the brief or the plan is written,
// not when the run is judged (voyage-lekerepo end-to-end trial, 2026-10-06,
// report points 2, 3, 4 and 9):
// - 11 of 13 brief success criteria were NOT RUN at review, and nothing in
// /trekbrief warned;
// - a plan whose `## Verification` held `git grep ...` passed the validator,
// the plan gate and three critic rounds, then felled the run in Phase 7;
// - the plan's `End-to-end:` field and Phase 7's end-to-end check did not
// meet: Phase 7 only searched the criterion TEXT for "end to end";
// - a step's `Verify:` was prose with several commands, and nothing said
// which one the executor runs.
// The runner's own allowlist decides "can run" — one list, used early and late.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { validatePlanContent } from '../../lib/validators/plan-validator.mjs';
import { validateBriefContent } from '../../lib/validators/brief-validator.mjs';
import {
allowedCommand, runnableCheck, endToEndCommands, runPlanVerification,
} from '../../lib/verification/criteria-runner.mjs';
import { buildPlan } from '../helpers/plan-v18-builder.mjs';
const codes = (list) => list.map((e) => e.code);
// --- the shared screen -------------------------------------------------------
test('runner: `/bin/bash tests/<script>.sh` is the same declared check as `bash tests/<script>.sh`', () => {
assert.equal(allowedCommand('/bin/bash tests/e2e.sh').allowed, true);
assert.equal(allowedCommand('/bin/bash scripts/deploy.sh').allowed, false);
});
test('runner: at write time an `npm run` script may still be added by the plan itself', () => {
const empty = mkdtempSync(join(tmpdir(), 'voyage-runnable-'));
try {
assert.equal(allowedCommand('npm run e2e', { cwd: empty }).allowed, false);
assert.equal(runnableCheck({ command: 'npm run e2e' }, { cwd: empty }).runnable, true);
} finally {
rmSync(empty, { recursive: true, force: true });
}
});
test('runner: runnableCheck says why a check can never run', () => {
const r = runnableCheck({ command: 'git grep -q -e --max -- README.md' });
assert.equal(r.runnable, false);
assert.match(r.reason, /read-only/);
assert.equal(runnableCheck({ command: null, reason: 'no-command' }).runnable, false);
assert.equal(runnableCheck({ command: 'npm test' }).runnable, true);
});
// --- the plan (point 3, 4, 9) ------------------------------------------------
test('plan 1.8: a `## Verification` criterion the runner can never run is an error, named', () => {
const plan = buildPlan().replace(
'- [ ] `npm test` → expected: exit 0',
'- [ ] `npm test` → expected: exit 0\n- [ ] `git grep -q -e --max -- README.md` → expected: exit 0',
);
const r = validatePlanContent(plan);
assert.equal(r.valid, false);
const e = r.errors.find((x) => x.code === 'PLAN_VERIFICATION_NOT_RUNNABLE');
assert.ok(e, codes(r.errors).join(','));
assert.match(e.message, /git grep/);
assert.match(e.hint, /bash tests\//);
});
test('plan 1.8: a `## Verification` bullet with no command is an error (a plan promises commands)', () => {
const plan = buildPlan().replace(
'- [ ] `npm test` → expected: exit 0',
'- [ ] `npm test` → expected: exit 0\n- [ ] the README mentions the new flag',
);
assert.ok(codes(validatePlanContent(plan).errors).includes('PLAN_VERIFICATION_NOT_RUNNABLE'));
});
test('plan 1.8: an `End-to-end:` command the runner can never run is an error', () => {
const r = validatePlanContent(buildPlan({ steps: [{}, { endToEnd: 'node src/cli.mjs --demo' }] }));
assert.ok(codes(r.errors).includes('PLAN_END_TO_END_NOT_RUNNABLE'), codes(r.errors).join(','));
});
test('plan 1.8: an `End-to-end:` command that does not also stand under `## Verification` is an error', () => {
const plan = buildPlan().replace('\n- [ ] `bash tests/e2e.sh` → expected: exit 0', '');
assert.ok(!plan.includes('- [ ] `bash tests/e2e.sh`'), 'fixture: the builder lists the end-to-end command');
const r = validatePlanContent(plan);
assert.ok(codes(r.errors).includes('PLAN_END_TO_END_NOT_VERIFIED'), codes(r.errors).join(','));
});
test('plan 1.8: a step whose `Verify:` does not open with one runnable test command is an error', () => {
const plan = buildPlan().replace(
'- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass',
'- **Verify:** `node bin/slug.mjs 2>&1 | grep -q -- --max` → expected: exit 0',
);
const r = validatePlanContent(plan);
const e = r.errors.find((x) => x.code === 'PLAN_STEP_VERIFY_NOT_RUNNABLE');
assert.ok(e, codes(r.errors).join(','));
assert.match(e.message, /Step 1/);
});
test('plan 1.8: a step whose `Verify:` opens with a test command and then describes output is fine', () => {
const plan = buildPlan().replace(
'- **Verify:** `node --test tests/mod1.test.mjs` → expected: pass',
'- **Verify:** `node --test tests/mod1.test.mjs` → expected: `fail 0`',
);
assert.deepEqual(validatePlanContent(plan).errors, []);
});
test('plan 1.8, soft: the runnability findings are warnings', () => {
const r = validatePlanContent(buildPlan({ steps: [{}, { endToEnd: 'node src/cli.mjs' }] }), { strict: false });
assert.ok(codes(r.warnings).includes('PLAN_END_TO_END_NOT_RUNNABLE'));
assert.ok(!codes(r.errors).includes('PLAN_END_TO_END_NOT_RUNNABLE'));
});
test('plan 1.7: none of the runnability rules apply', () => {
const plan = buildPlan({ version: '1.7', steps: [{}, { endToEnd: 'node src/cli.mjs' }] })
.replace('- [ ] `npm test` → expected: exit 0', '- [ ] `git grep x` → expected: exit 0');
const r = validatePlanContent(plan);
for (const c of ['PLAN_VERIFICATION_NOT_RUNNABLE', 'PLAN_END_TO_END_NOT_RUNNABLE', 'PLAN_END_TO_END_NOT_VERIFIED', 'PLAN_STEP_VERIFY_NOT_RUNNABLE']) {
assert.ok(!codes(r.errors).includes(c) && !codes(r.warnings).includes(c), c);
}
});
// --- Phase 7 reads the End-to-end field (point 4) ----------------------------
function withPlan(text, fn) {
const dir = mkdtempSync(join(tmpdir(), 'voyage-e2e-'));
try {
const p = join(dir, 'plan.md');
writeFileSync(p, text);
return fn(p);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
const passAll = { exec: () => ({ status: 0, stdout: '', stderr: '' }), screen: () => ({ allowed: true, rule: '' }) };
test('runner: the plan\'s `End-to-end:` commands are read from its steps', () => {
assert.deepEqual(endToEndCommands(buildPlan()), ['bash tests/e2e.sh']);
});
test('runner: a passed criterion that runs the step\'s End-to-end command verifies end to end — no magic words needed', () => {
withPlan(buildPlan(), (p) => {
const rep = runPlanVerification(p, passAll);
assert.equal(rep.summary.ok, true);
assert.equal(rep.end_to_end.status, 'verified');
assert.equal(rep.end_to_end.by, 'end-to-end-field');
});
});
test('runner: the older text form still verifies (session specs, plans below 1.8)', () => {
const plan = buildPlan({ steps: [{}, {}] }).replace(
'- [ ] `npm test` → expected: exit 0',
'- [ ] `npm test` → expected: exit 0 — runs the CLI end to end',
);
withPlan(plan, (p) => {
assert.equal(runPlanVerification(p, passAll).end_to_end.status, 'verified');
});
});
test('runner: a failing verification is never verified end to end', () => {
withPlan(buildPlan(), (p) => {
const rep = runPlanVerification(p, { ...passAll, exec: (cmd) => ({ status: cmd === 'npm test' ? 1 : 0, stdout: '', stderr: '' }) });
assert.equal(rep.summary.ok, false);
assert.equal(rep.end_to_end.status, 'not-verified');
});
});
// --- the brief (point 2) -----------------------------------------------------
function brief(criteria) {
return [
'---', 'type: trekbrief', 'brief_version: "2.2"', 'task: t', 'slug: s',
'research_topics: 0', 'research_status: skipped', 'framing: new-direction', 'phase_signals_partial: true', '---', '',
'## TL;DR', '', 'A toy.', '', '## Intent', '', 'Why.', '', '## Goal', '', 'What.', '',
'## Success Criteria', '', ...criteria, '',
].join('\n');
}
test('brief: a success criterion whose command the runner can never run is a warning that names it and the way in', () => {
const r = validateBriefContent(brief([
'- `npm test` exits 0.',
'- `node bin/slug.mjs --max 9 "Hello Big World"` prints `hello-big`.',
'- The README explains the flag.',
]));
assert.equal(r.valid, true, JSON.stringify(r.errors));
const w = r.warnings.filter((x) => x.code === 'BRIEF_SC_NOT_RUNNABLE');
assert.equal(w.length, 1, JSON.stringify(r.warnings));
assert.match(w[0].message, /SC2/);
assert.match(w[0].message, /node bin\/slug\.mjs/);
assert.match(w[0].hint, /bash tests\//);
});
test('brief: runnable and prose-only criteria raise no runnability warning', () => {
const r = validateBriefContent(brief(['- `npm test` exits 0.', '- `bash tests/e2e.sh` exits 0.', '- The output reads well.']));
assert.ok(!codes(r.warnings).includes('BRIEF_SC_NOT_RUNNABLE'), JSON.stringify(r.warnings));
});