Round 5 (v6.2.0..d37e538, WARN: 11 MAJOR, 2 MINOR). The patching did not converge (the same two places every round), so the operator chose a structural fix on 2026-10-11. Each change behind a test that failed first. - A check runs with no shell. criteria-runner's runCommand starts the runner with the very words the screen checked (spawnSync(words[0], words.slice(1)), without NODE_TEST_CONTEXT); Phase 7 criteria, Regression: (its exec copy is gone, MINOR 0e98d5c7), proof of red, the done line and the test lock all run through it. No quote, glob, tilde or variable can rewrite a checked word any more. The screen stays and says why: a word that means something else in a shell (quote, backslash, $, brace, leading ~) is refused; a glob reaches only node --test, which expands its own patterns (sh read `tests/.?/` as `tests/../`); no path climbs out with .. or starts at /; a word starting with @ is refused (pytest reads @file as more arguments). - The K6 plan-file sweep has v6.2.0's own Files: reader again (516dffa): backticked paths, and plain words only in a field without them. The shared reader from round 3 planned directories a description named and dropped paths v6.2.0 planned. fieldPaths stays for the tests-only check, with a path test on spans (`parse()`, `p.parse()` are no paths), plain descriptions ignored when the field has backticked paths, dotfiles, backslash paths and glued spans read. - Skill frontmatter reads a value as YAML does: trailing space is nothing, and a comment ends a plain value, a flow list or a block indicator (`model: opus # pinned`). A comment that cuts a plain description, or is the whole value, is still a hazard. - release-check reads the diff with --no-renames: a test moved into an allowed place after the review shows as removed. - The example plan's step 2 heading no longer says "must FAIL initially"; steps 1-2 keep the tests-only warning (operator 2026-10-09,73262b6). Open: MINOR c1fef1ad (one shared isMain helper) is not in this round. Measured: 0 of 463 Files: fields with backticked paths (520 fields in 182 plans across the marketplace repos) carry another path-like word. Suite 2185 (2183/0/2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
106 lines
5.2 KiB
JavaScript
106 lines
5.2 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, 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 });
|
|
return withProgress({
|
|
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(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));
|