voyage/lib/verification/regression-check.mjs
Kjell Tore Guttormsen 920fbd3c47
feat(trekexecute): couple the executor to the review gate, the plan approval and the step checks
Wave 2, order 6. /trekexecute now:
- refuses a plan whose logged approval does not cover its current sha256
  (K2, approval-log verify --kind plan), before step 1;
- runs the step check (K9) and the step's optional Regression: (K10, new
  lib/verification/regression-check.mjs through the criteria runner) at step
  end, before the checkpoint; exit 2 is "not measured", never green;
- runs the architecture rules (K5) and the plan-file sweep (K6) after the last
  step; a violation or a file outside the plan turns completed into partial;
- reviews by itself in the closing run (Phase 7.2): run the thing, /simplify,
  /trekreview, verdict-gate; a BLOCK adds the review's remediation steps after
  the plan validator accepts them and the operator re-approves the plan, capped
  at TREKEXECUTE_MAX_REVIEW_ROUNDS (3);
- makes the main-merge gate a lock on the verdict: BLOCK or undecided keeps
  the merge closed; when open, the executor merges and the guard's question is
  the one question (operator decision 2026-10-08). The --gates pause before
  the wave merge-back is gone for the same reason.

The guard refuses a merge into main and a push in the permission mode that
shows no prompts, since no one can be asked (operator decision 2026-10-09; two
mode-read exemptions approved by the operator).

progress.json gains optional coupling fields (Handover 5); the validator
rejects the runner's exitCode in plan_verification.failed_criteria (C15).
Pointer lines name the skills test-first and debugging (S2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:22:00 +02:00

108 lines
5 KiB
JavaScript

#!/usr/bin/env node
// lib/verification/regression-check.mjs
// K10 at execution time: run ONE step's `Regression:` command through the
// criteria runner's own screens, before the step's checkpoint.
//
// node lib/verification/regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]
// 0 = the step's `Regression:` passed, or the step has no `Regression:`
// field (`status: "absent"` — nothing new happens, K10)
// 1 = it failed, the executor denylist blocked it, or it is not runnable
// (no command, or a command outside the test-runner allowlist): the
// step is not done, and that is a finding for the fix rounds
// 2 = could not run: bad usage, a plan that cannot be read, no step N
// JSON on stdout: {step, status, command, exit_code, output}
// status: passed | failed | blocked | unrunnable | absent | error
//
// The command is the field's first backticked span — the same reading as the
// runner's `## Verification` bullets — and it goes through the same two
// screens: the allowlist of test runners, then the plugin's PreToolUse
// denylist. The plan validator rejects a `Regression:` that cannot run once K10
// is in it (wave 2, order 9); this is the run-time check, so a plan validated
// before that is never read as green.
//
// The runner reports `exitCode`; this CLI writes `exit_code`, the key
// progress.json uses (C15). Nothing else is translated.
//
// The command runs the way the runner runs it (`/bin/sh -c`), but without
// NODE_TEST_CONTEXT in its environment (red-proof.mjs `commandEnv`): measured
// 2026-10-08, a red `node --test` regression read as passed when this CLI was
// itself started under `node --test`, because the nested run reported to the
// parent and exited 0.
function exec(command, { cwd, timeoutMs }) {
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
}
import { readFileSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { firstCommand, runCriteria } from './criteria-runner.mjs';
import { commandEnv } from './red-proof.mjs';
export const REGRESSION_FIELD = 'Regression';
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
/**
* @param {{ planText: string, step: number, cwd?: string, run?: Function }} o
* @returns {{ step: number, status: string, command: string|null, exit_code: number|null, output: string, exit: 0|1|2 }}
*/
export function checkRegression({ planText, step, cwd = process.cwd(), run = (c, o) => runCriteria(c, { ...o, exec }) }) {
const base = { step, command: null, exit_code: null, output: '' };
const found = sliceSteps(planText).find((s) => Number(s.n) === step);
if (!found) return { ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error };
const field = parseStepFields(found.body).get(REGRESSION_FIELD);
if (!field) return { ...base, status: 'absent', output: 'no Regression: field — nothing to run', exit: EXIT.absent };
const { command, reason } = firstCommand(field.text);
const [r] = run([{ label: `R${step}`, text: field.text, command, reason }], { cwd });
return {
step,
status: r.status,
command: r.command ?? null,
exit_code: r.exitCode ?? null,
output: r.output ?? '',
exit: EXIT[r.status] ?? EXIT.error,
};
}
export function main(argv) {
let plan; let step; let cwd = process.cwd(); let asJson = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--plan' && argv[i + 1]) plan = argv[++i];
else if (a === '--step' && argv[i + 1]) step = Number(argv[++i]);
else if (a === '--cwd' && argv[i + 1]) cwd = resolve(argv[++i]);
else if (a === '--json') asJson = true;
else {
process.stderr.write(`regression-check: unknown argument ${a}\n`);
return 2;
}
}
const say = (r) => {
const { exit, ...rest } = r;
process.stdout.write(asJson ? `${JSON.stringify(rest)}\n`
: `regression step ${rest.step}: ${rest.status}${rest.command ? ` — \`${rest.command}\`` : ''}${rest.exit_code === null ? '' : ` (exit ${rest.exit_code})`}\n`);
return exit;
};
if (!plan || !Number.isInteger(step) || step < 1) {
process.stderr.write('Usage: regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]\n');
return 2;
}
let planText;
try { planText = readFileSync(plan, 'utf8'); } catch (e) {
return say({ step, status: 'error', command: null, exit_code: null, output: `cannot read ${plan}: ${e.message}`, exit: 2 });
}
return say(checkRegression({ planText, step, cwd }));
}
function isMain() {
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMain()) process.exitCode = main(process.argv.slice(2));