#!/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 --step [--cwd ] [--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, realpathSync } from 'node:fs'; 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'; 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 --step [--cwd ] [--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 })); } 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));