voyage/lib/verification/regression-check.mjs
Kjell Tore Guttormsen 29b4ac91b3
fix: round 7 of the release review of 6.3.0, for 6.3.1
Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.

- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
  text says so (red-proof, trekexecute Sub-step B, step-implementer,
  HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
  already in the tree when the step began no longer counts against it, and a
  plan placed outside .claude/ reads the same. The round-6 test that had the
  diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
  (", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
  the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
  not "could not start"; the release check tells a timeout from output past
  its buffer and from another signal. One reading of a stop
  (criteria-runner stoppedBy) for red proof, the criteria runner and the
  release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
  command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
  the realpath guard and the 20 modules still on the file:// guard, which
  exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
  three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
  passed at once; it fails when 127 is read as could-not-run); the prose now
  names the git revision `@~1` as the one leading-@ exception.

Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.

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

102 lines
5.3 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, or
// a runner that could not start (not installed): nothing was measured,
// as red-proof and done-line say for the same case
// JSON on stdout: {step, status, progress_status, command, exit_code, output}
// status: passed | failed | blocked | unrunnable | absent | error
// progress_status: the value progress.json takes for steps.N.regression
// (blocked counts as unrunnable, error as not-run), so the executor
// copies one field and never translates
//
// 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 exactly as the runner runs a criterion: its own run, with
// no shell and without NODE_TEST_CONTEXT (criteria-runner.mjs `runCommand`).
// This file had a copy of that code, and a fix made in one copy would have
// left K10 regressions running differently from Phase 7 criteria (release
// review of 6.3.0, round 5).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { firstCommand, runCriteria } from './criteria-runner.mjs';
import { isMain } from '../util/is-main.mjs';
export const REGRESSION_FIELD = 'Regression';
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
const PROGRESS = Object.freeze({ passed: 'passed', absent: 'absent', failed: 'failed', blocked: 'unrunnable', unrunnable: 'unrunnable', error: 'not-run' });
const withProgress = (r) => ({ ...r, progress_status: PROGRESS[r.status] ?? 'not-run' });
/**
* @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) }) {
const base = { step, command: null, exit_code: null, output: '' };
const found = sliceSteps(planText).find((s) => Number(s.n) === step);
if (!found) return withProgress({ ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error });
const field = parseStepFields(found.body).get(REGRESSION_FIELD);
if (!field) return withProgress({ ...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 });
// A runner that could not start measured nothing: not a red suite (round 6).
const status = r.startError ? 'error' : r.status;
return withProgress({
step,
status,
command: r.command ?? null,
exit_code: r.exitCode ?? null,
output: r.output ?? '',
exit: EXIT[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(withProgress({ step, status: 'error', command: null, exit_code: null, output: `cannot read ${plan}: ${e.message}`, exit: 2 }));
}
return say(checkRegression({ planText, step, cwd }));
}
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));