fix: round 6 of the release review of 6.3.0

Round 6 (v6.2.0..c3641ff, WARN: 11 MAJOR, 3 MINOR; 0 BLOCKER). The round-5
fixes hold (code-correctness-reviewer); three of the new MAJOR were side
effects of running checks with no shell. Each fixed behind a test that
failed first on c3641ff.

- git checks stay in the repo too: no path that climbs out with .., starts
  at / or reads <rev>:../x, and no --no-index (both reviewers).
- A trailing `# comment` on a shell-fence line is dropped: with no shell,
  its words made an honest check NOT RUN, among them the first check of the
  shipped example plan.
- A glob reaches `node --test` only on Node 21 and later, which expands it
  itself; on an older Node it is refused with that reason (engines >=18).
- With no shell, exit 126/127 is the test program's own: red proof reads it
  as a red (the control run still tells an environment fault apart), the
  done line and the test lock as a failure.
- A runner that could not start (not installed) is NOT RUN in the criteria
  runner and not-run (exit 2) in regression-check, as red-proof and
  done-line already said; never a red suite.
- Tests-only: a backticked `Makefile` alone in its entry is a path, and red
  proof confirms the plan's tests-only reading against the step's own diff
  (the altitude finding: the diff is a fact, the Files: prose is not).
- release-check stops a suite that never ends (15 minutes) and reads it as
  not measured (exit 2).
- The two lines this release added to pre-bash-executor.mjs are ASCII.
- MINOR: git and the other runners share one plain-word pattern.

Not in this round: MINOR f00a2eb8 (shared norm/ticked-path and isMain
helpers) and MINOR on plan-review-dedup's repeated tokenizing.

Suite 2194 (2192/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-11 01:07:47 +02:00
commit 45aa9e107a
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
17 changed files with 262 additions and 62 deletions

View file

@ -293,7 +293,8 @@ export function onFailureAction(text) {
*
* - A field with a backticked path names its backticked spans that pass the
* path test (a `.`, `/` or `\`; no URL, option or call like `parse()`),
* wherever they sit (`a.mjs`/`b.mjs`, inside a note). Its descriptions are
* wherever they sit (`a.mjs`/`b.mjs`, inside a note), and a backticked name
* with no extension that stands alone in its entry (`Makefile`). Its descriptions are
* not read: "covers pass/fail", "JSON, YAML" or a directory named in passing
* is no path. An entry of its own with no backticks (", .gitignore") counts
* by its first word, and only when that is clearly a file or a directory.
@ -331,8 +332,11 @@ export function fieldPaths(text) {
for (const raw of masked.split(/[,\n]/)) {
const entry = raw.trim().replace(/^[-*]\s+/, '');
if (entry === '') continue;
const ticked = [...entry.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1])).filter((i) => isTicked[i]);
const inEntry = [...entry.matchAll(/\u0000(\d+)\u0000/g)].map((m) => Number(m[1]));
const ticked = inEntry.filter((i) => isTicked[i]);
if (ticked.length) { out.push(...ticked.map((i) => spans[i])); continue; }
// A backticked `Makefile` alone in its entry is a file (round 6).
if (inEntry.length === 1 && /^\u0000\d+\u0000$/.test(entry) && BARE_NAME.test(spans[inEntry[0]])) { out.push(spans[inEntry[0]]); continue; }
const words = entry.split(/\s+/).filter((w) => !w.includes('\u0000')).map(plainWord).filter(Boolean);
if (tickedField) {
const first = plainWord(entry.split(/\s+/)[0]);
@ -371,15 +375,23 @@ export function stepTestsOnly(body) {
return stepTestsOnlyFromFields(parseStepFields(body));
}
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
export function stepTestsOnlyFromFields(f) {
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
/**
* Whether a path counts as a test for this step: its own `Test first:` file, or
* a path that is clearly a test. red-proof.mjs uses it on the step's diff.
* @returns {(path: string) => boolean}
*/
export function stepTestFile(f) {
const testFirst = f.has('Test first')
? [...f.get('Test first').text.matchAll(/\bFile:\**[ \t]*`([^`\n]+)`/gi)].map((m) => norm(m[1].trim()))
: [];
const isTest = (p) => testFirst.includes(p) ||
return (p) => testFirst.includes(p) ||
(isTestPath(p) && (UNDER_TESTS_RE.test(p) || !(DOC_RE.test(p) || DATA_RE.test(p))));
return { testsOnly: files.length > 0 && files.every(isTest), files };
}
/** `stepTestsOnly` for a caller that already holds the step's parsed fields. */
export function stepTestsOnlyFromFields(f) {
const files = f.has('Files') ? fieldPaths(f.get('Files').text) : [];
return { testsOnly: files.length > 0 && files.every(stepTestFile(f)), files };
}
// ---- sections (fence-aware) ------------------------------------------------

View file

@ -180,7 +180,8 @@ function main(argv) {
if (!screen.allowed) fail(2, `command blocked: ${screen.rule}`);
// No shell: the words the screen checked are the words that run.
const r = runCommand(testCmd, { cwd: cur.repo.root, timeoutMs: DEFAULT_TIMEOUT_MS });
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
// With no shell, 126/127 is the test program's own exit: a red, not "could not run".
if (r.error || r.status === null) {
fail(2, `the test command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`);
}
if (r.status !== 0) fail(1, `the test command exited ${r.status}; the lock stays unmarked`);

View file

@ -161,11 +161,14 @@ export function firstCommand(text) {
// A command line inside a shell-tagged fence. A console block may prefix the
// line with a `$` or `>` prompt; blank and comment-only lines declare nothing.
// A `#` root prompt is NOT stripped: it is indistinguishable from a comment,
// and running a comment is the worse of the two mistakes.
// and running a comment is the worse of the two mistakes. A trailing comment
// (`npm test # all green`) is not part of the command: a shell dropped it,
// and with no shell (runCommand) its words would reach the screen and make an
// honest check NOT RUN (release review of 6.3.0, round 6).
function fencedCommand(text) {
const line = text.trim();
if (line === '' || line.startsWith('#')) return null;
return line.replace(/^[$>]\s+/, '');
return line.replace(/^[$>]\s+/, '').replace(/\s+#.*$/, '');
}
function parseSection(markdown, heading, prefix) {
@ -292,9 +295,6 @@ const SHELL_OPERATORS = /[;&|<>]|\$\(|`/;
// Read-only git subcommands: they report, they do not write a repository.
const READONLY_GIT = new Set(['status', 'log', 'diff', 'show', 'ls-files']);
// A word that means the same to git with or without a shell (refs like HEAD~1
// and HEAD^, formats like %h, paths).
const GIT_LITERAL_WORD = /^[A-Za-z0-9_@%^~:.,=+/-]+$/;
// `--output`, `--output=<file>`, or an abbreviation of it git may accept.
function writesOutput(word) {
const name = word.split('=')[0];
@ -304,14 +304,18 @@ function writesOutput(word) {
const deny = (reason) => ({ allowed: false, reason });
const permit = () => ({ allowed: true, reason: '' });
// A word that means the same with or without a shell.
// A word that means the same with or without a shell (also git's refs like
// HEAD~1 and HEAD^, formats like %h, paths).
const PLAIN_WORD = /^[A-Za-z0-9_@%^~:.,=+/-]+$/;
// The same, with a glob in it: passed on only to `node --test`, which expands
// its own patterns (sh would have expanded `.?` to `..`).
// its own patterns from Node 21 on (sh would have expanded `.?` to `..`).
const GLOB_WORD = /^[A-Za-z0-9_./][A-Za-z0-9_@%^~:.,=+/*?-]*$/;
const takesGlobs = (t) => t[0] === 'node' && t[1] === '--test';
const NODE_GLOB_MAJOR = 21;
const runningNodeMajor = () => Number(process.versions.node.split('.')[0]);
// A path outside the repo: one that climbs with `..` or starts at `/`.
const leavesRepo = (w) => w.startsWith('/') || w.split('/').includes('..');
// The same for a git word, which may also name a path as `<rev>:<path>`.
const gitLeavesRepo = (w) => leavesRepo(w) || (w.includes(':') && leavesRepo(w.slice(w.indexOf(':') + 1)));
// After each runner's fixed words: the options it may take (`name` or
// `name=value`; `value` lists the ones whose value may be the next word, and
@ -415,9 +419,13 @@ const RUNNERS = {
// diff/log/show write to a file (release review of 6.3.0, 2026-10-10: a
// plan's check could overwrite any file, first plainly, then through
// quoting while a shell still ran it).
const odd = t.find((w) => !GIT_LITERAL_WORD.test(w));
const odd = t.find((w) => !PLAIN_WORD.test(w));
if (odd !== undefined) return deny(`git with a word that means something else in a shell (${odd}): only plain words run, as written`);
if (t.some(writesOutput)) return deny('git with --output writes a file, so it is not a read-only check');
// A check runs in the repo: no path that climbs out or starts at /, and no
// --no-index, which makes git diff compare any two files (round 6).
const out = t.slice(2).find((w) => w === '--no-index' || (!w.startsWith('-') && gitLeavesRepo(w)));
if (out !== undefined) return deny(`git with a path outside the repo (${out}): a check runs in the repo`);
return permit();
},
};
@ -463,12 +471,18 @@ function screenShape(command, opts, writeTime) {
const form = RUNNERS[tokens[0]](tokens, cwd, writeTime);
if (!form.allowed || tokens[0] === 'git') return form;
const words = tokens.slice(1);
const odd = words.find((w) => !PLAIN_WORD.test(w) && !(takesGlobs(tokens) && GLOB_WORD.test(w)));
const nodeTest = tokens[0] === 'node' && tokens[1] === '--test';
const nodeMajor = opts.nodeMajor ?? runningNodeMajor();
const odd = words.find((w) => !PLAIN_WORD.test(w) && !(nodeTest && GLOB_WORD.test(w)));
if (odd !== undefined) {
return deny(GLOB_WORD.test(odd)
? `a glob (${odd}): no shell runs this, and \`${tokens[0]}\` does not expand one itself; name the files, or use \`node --test\``
: `a word that means something else in a shell (${odd}): only plain words run, as written`);
}
const glob = nodeTest && nodeMajor < NODE_GLOB_MAJOR ? words.find((w) => !PLAIN_WORD.test(w)) : undefined;
if (glob !== undefined) {
return deny(`a glob (${glob}): node ${nodeMajor} does not expand one itself (Node ${NODE_GLOB_MAJOR} and later do), and no shell runs this; name the files`);
}
const at = words.find((w) => w.startsWith('@'));
if (at !== undefined) return deny(`a word that starts with @ (${at}): pytest reads @file as a file of more arguments`);
const out = words.find((w) => !w.startsWith('-') && leavesRepo(w));
@ -496,11 +510,13 @@ export function runCommand(command, { cwd, timeoutMs = DEFAULT_TIMEOUT_MS } = {}
// --- running ----------------------------------------------------------------
// A program that could not start (not installed: ENOENT) or was killed by the
// timeout has no exit code; its error is the output, so the row says why.
// A program that could not start (not installed: ENOENT) has no exit code and
// measured nothing: `startError` says so, and runCriteria reports it NOT RUN.
// One killed by the timeout did start, and a hang is a red (`failed`).
function defaultExec(command, { cwd, timeoutMs }) {
const r = runCommand(command, { cwd, timeoutMs });
const why = r.error ? `${r.error.message}\n` : r.signal ? `killed (${r.signal})\n` : '';
if (r.status === null && r.signal === null && r.error) return { status: null, stdout: '', stderr: '', startError: r.error.message };
const why = r.signal ? `killed (${r.signal})\n` : '';
return { status: r.status, stdout: r.stdout ?? '', stderr: `${r.stderr ?? ''}${why}` };
}
@ -513,9 +529,10 @@ function cap(text, max) {
* Run each criterion that has a command. Returns one result per criterion:
* passed the command exited 0
* failed the command exited non-zero (or was killed by the timeout)
* blocked the executor denylist denied it — it never reached a shell
* blocked the executor denylist denied it — it never ran
* unrunnable there is nothing to run: no command (placeholder or prose-only
* criterion), or a command outside the allowlist
* criterion), a command outside the allowlist, or a runner that
* could not start (`startError`: not installed)
*/
export function runCriteria(criteria, opts = {}) {
const exec = opts.exec ?? defaultExec;
@ -545,6 +562,9 @@ export function runCriteria(criteria, opts = {}) {
return { ...base, status: 'blocked', exitCode: null, output: `blocked: ${verdict.rule}` };
}
const r = exec(c.command, { cwd, timeoutMs });
if (r.startError) {
return { ...base, status: 'unrunnable', exitCode: null, output: `not runnable: the program could not start (${r.startError})`, startError: r.startError };
}
const output = cap(`${r.stdout ?? ''}${r.stderr ?? ''}`, maxOutput);
return {
...base,
@ -766,8 +786,8 @@ export function formatCriteriaEvidence(rep) {
out.push(
'**NOT RUN is not a pass.** You cannot run these commands yourself — never infer, assume or ' +
'reconstruct a result for a criterion that has none. A criterion whose result is FAILED or ' +
'BLOCKED is `Broken`; a criterion marked NOT RUN declared no command, or a command outside ' +
'the allowlist of test runners, so it never reached a shell — that is the ABSENCE of a ' +
'BLOCKED is `Broken`; a criterion marked NOT RUN declared no command, a command outside ' +
'the allowlist of test runners, or a runner that could not start, so it never ran — that is the ABSENCE of a ' +
'measurement and never on its own a finding; a criterion with no result is judged on ' +
'delivered code alone, and you say so in the Evidence column.'
);

View file

@ -7,7 +7,8 @@
// appended to the step's report file. Exit 0 = written, 1 = the command failed
// (nothing written), 2 = could not run (nothing written; never green): no
// --step, a command outside the test-runner allowlist, a command that cannot
// start, a working tree that is not a git repo.
// start (not installed) or was killed, a working tree that is not a git repo.
// With no shell, an exit 126/127 is the test program's own and a failure.
//
// `--check --step <N> --report <file> [--commit <sha>]`: exit 0 when the report
// carries a well-formed line for step N (at exactly that commit, with
@ -57,7 +58,7 @@ export function writeDoneLine({ cwd, step, cmd, report, timeoutMs = DEFAULT_TIME
if (head.status !== 0) return { status: 'not-run', reason: `no git HEAD in ${dir}` };
// No shell: the words the screen checked are the words that run.
const r = runCommand(cmd, { cwd: dir, timeoutMs });
if (r.error || r.status === null || r.status === 126 || r.status === 127) {
if (r.error || r.status === null) {
return { status: 'not-run', reason: `the command did not run to an exit code (${r.error?.message ?? r.signal ?? `exit ${r.status}`})`, exit: r.status };
}
if (r.status !== 0) return { status: 'failed', reason: `the test command exited ${r.status}`, exit: r.status };

View file

@ -15,8 +15,9 @@
// run, and only the full run is authoritative.
//
// `--plan <plan.md> --step <N>` adds `tests_only` to a `not-red-at-base`: true
// when every file in the step's `Files:` is a test, so the step can never be
// red and goes up to the plan (see `testsOnly` below).
// when every file in the step's `Files:` is a test and so is every file the
// step changed since its start point, so the step can never be red and goes
// up to the plan (see `testsOnly` below).
//
// Exit 0 = proven, 1 = rejected (`not-red-at-base` or `not-green-at-result`),
// 2 = could not run (`not-run`: no test files, an unknown start point, a command
@ -57,11 +58,11 @@
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { allowedCommand, runCommand, screenCommand } from './criteria-runner.mjs';
import { commandEnv } from '../util/command-env.mjs';
import { sliceSteps, stepTestsOnly } from '../parsers/plan-schema.mjs';
import { parseStepFields, sliceSteps, stepTestFile, stepTestsOnlyFromFields } from '../parsers/plan-schema.mjs';
export const DEFAULT_TIMEOUT_MS = 900_000;
const TAIL = 2000;
@ -110,23 +111,42 @@ const notRun = (reason, extra = {}) => ({ status: 'not-run', reason, ...extra })
// the executor sends the step up to the plan instead of into fix rounds
// (voyage-lekerepo trial 3, 2026-10-09). `null` = the plan or the step could
// not be read; without `--plan` the field is absent and nothing new happens.
function testsOnly(o) {
//
// The plan's `Files:` is prose, read by a reader that can misread it; the
// step's own diff is a fact. So a step the plan calls tests-only is so only
// when every file it changed since the start point is a test too (release
// review of 6.3.0, round 6). Before building (`--red-only`) the diff holds the
// step's test alone, so there the plan decides. The plan file itself and
// Voyage's own files under .claude/ (progress, approvals) are not the step's change.
function testsOnly(o, cwd, baseSha) {
if (!o.plan) return {};
let text;
try { text = readFileSync(o.plan, 'utf8'); }
catch (e) { return { tests_only: null, tests_only_note: `cannot read the plan: ${e.message}` }; }
const step = sliceSteps(text).find((s) => Number(s.n) === Number(o.step));
if (!step) return { tests_only: null, tests_only_note: `no "### Step ${o.step}:" in the plan` };
return { tests_only: stepTestsOnly(step.body).testsOnly };
const fields = parseStepFields(step.body);
if (!stepTestsOnlyFromFields(fields).testsOnly) return { tests_only: false };
const isTest = stepTestFile(fields);
const given = new Set(o.tests ?? []);
const changed = [
...lines(git(cwd, ['diff', '--name-only', '--no-renames', baseSha]).stdout),
...lines(git(cwd, ['ls-files', '--others', '--exclude-standard']).stdout),
];
const planRel = relative(cwd, realpathSync(o.plan)).split(sep).join('/');
const other = [...new Set(changed)].filter((p) => p !== planRel && !p.startsWith('.claude/') && !given.has(p) && !isTest(p));
if (other.length === 0) return { tests_only: true };
return { tests_only: false, tests_only_note: `the plan's Files: names only tests, but the step's diff also changes ${other.slice(0, 5).join(', ')}` };
}
// A program that could not start (a spawn error: not installed), a signal (the
// timeout), or 126/127 from a script that could not run its own command is not
// a test verdict at all.
// A program that could not start (a spawn error: not installed) or a signal
// (the timeout) is not a test verdict at all. With no shell, an exit 126/127
// is the test program's own (a script whose tool the step has not built yet):
// a red like any other, and the control run tells an environment fault apart
// (release review of 6.3.0, round 6).
function unrunnable(r) {
if (r.spawnError) return `the command could not start: ${r.spawnError}`;
if (r.exit === null) return `the command was killed (${r.signal ?? 'timeout'})`;
if (r.exit === 126 || r.exit === 127) return `the command could not be run (exit ${r.exit})`;
return null;
}
@ -221,7 +241,7 @@ export function proveRed(o) {
const baseBroken = unrunnable(base);
if (baseBroken) return notRun(`at the start point: ${baseBroken}`, { base, result, linked });
const out = { base_sha: baseSha, linked, base, result };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o), ...out };
if (base.exit === 0) return { status: 'rejected', reason: 'not-red-at-base', ...testsOnly(o, cwd, baseSha), ...out };
if (o.redOnly) return { status: 'proven', reason: 'red at the start point (--red-only: the result was not run)', red_kind: redKind(base), base_sha: baseSha, linked, base };
if (result.exit !== 0) return { status: 'rejected', reason: 'not-green-at-result', ...out };

View file

@ -9,7 +9,9 @@
// 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
// 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
@ -55,13 +57,15 @@ export function checkRegression({ planText, step, cwd = process.cwd(), run = (c,
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: r.status,
status,
command: r.command ?? null,
exit_code: r.exitCode ?? null,
output: r.output ?? '',
exit: EXIT[r.status] ?? EXIT.error,
exit: EXIT[status] ?? EXIT.error,
});
}