fix: round 3 of the release review of 6.3.0

Round 3 (v6.2.0..96cb977, BLOCK: 1 BLOCKER, 8 MAJOR, 2 MINOR), each fixed
behind a test that failed first.

- Every allowlisted runner, not only git, takes plain words and a short list
  of options that only read: an option that writes a file or loads code
  (--test-reporter-destination, --outputFile, --junitxml, --basetemp,
  --import, --config, -p, -o, -exec, a reporter path) is on no list; after
  `npm test --` only paths follow; `make test` takes no VAR=value. A built-in
  node test reporter (tap, spec, dot, junit, lcov) stays allowed.
- /trekexecute: a restore the guard refuses (destructive git, in the mode
  that shows no prompts) or the operator declines is never recorded as
  reverted: simplify = "revert-refused" and the run stops before review; a
  refused step revert is reported with its paths.
- stepTestsOnly: a fixture document under tests/ is a test file, "e.g." is
  not a path, ./ is dropped; the plan-file sweep reads Files: with the same
  reader (fieldPaths); the validator hands its parsed fields over.
- plan-validator --steps-from asks for Verify: on a 1.8 plan too.
- skill frontmatter: a value starting with # is a hazard; a quote after an
  escaped backslash closes the value. Routing reads kind tags with hasOwn.
- release-check reads scope_sha_end as written (an all-digit sha keeps its
  leading zero), and Voyage's own artifacts under .claude/projects/ and
  .claude/voyage/ committed after the review do not count as unreviewed.

Suite 2170 (2168/0/2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-10 23:25:24 +02:00
commit 0e612c88ac
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
14 changed files with 284 additions and 40 deletions

View file

@ -257,7 +257,13 @@ export function screenCommand(command, opts = {}) {
// - It is not a sandbox. `npm test`, `npm run <script>` and `make test` run
// whatever the repo's own package.json/Makefile says they run, including a
// script that pushes. That is the repo's responsibility, not this file's.
// - It has no opinion about arguments beyond the shape rules below.
// - It is not an option parser. Each runner takes only plain words (no
// quote, backslash, `$` or brace; a glob only inside a path) and a short
// list of options that only read (RUNNER_ARGS); an option that writes a
// file or loads code (`--test-reporter-destination`, `--outputFile`,
// `--junitxml`, `--basetemp`, `--import`, `--config`, `-o`, `-exec`) is on
// no list (release review of 6.3.0, round 3: rounds 1-2 closed this for
// git only).
// - It rejects plenty of honest commands: an env prefix (`CI=1 npm test`), a
// project's own binary (`./build/app --check`), anything piped. A check
// that needs one of those is declared through a runner instead —
@ -279,6 +285,60 @@ function writesOutput(word) {
const deny = (reason) => ({ allowed: false, reason });
const permit = () => ({ allowed: true, reason: '' });
// A word /bin/sh hands on unchanged, or a path with a glob in it: a glob can
// only expand to names that start with the word's first character, so it
// cannot make an option out of a path.
const PLAIN_WORD = /^(?:[A-Za-z0-9_@%^~:.,=+/-]+|[A-Za-z0-9_./][A-Za-z0-9_@%^~:.,=+/*?-]*)$/;
// 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
// `only` the ones whose value must be one of a few words: a built-in node test
// reporter only formats, a reporter path would load code),
// whether `--` may hand the rest to the repo's own script (`dashDash`: then
// only paths follow), and whether a plain word may carry `=` (make reads
// `VAR=value` as an override). The lists hold options that only read.
const RUNNER_ARGS = {
npm: { fixed: (t) => (t[1] === 'run' ? 3 : 2), opts: [], dashDash: true },
node: {
fixed: 2,
opts: ['--test-name-pattern', '--test-skip-pattern', '--test-only', '--test-concurrency', '--test-timeout', '--test-reporter'],
value: ['--test-reporter'],
only: { '--test-reporter': ['spec', 'tap', 'dot', 'junit', 'lcov'] },
},
vitest: { fixed: 1, opts: ['--run', '--passWithNoTests', '--silent', '--bail'] },
jest: { fixed: 1, opts: ['--ci', '--runInBand', '-i', '--silent', '--passWithNoTests', '--bail', '--verbose'] },
pytest: { fixed: 1, opts: ['-q', '-qq', '-v', '-vv', '-x', '-s', '--quiet', '--verbose', '--exitfirst', '--tb', '--maxfail'] },
cargo: { fixed: 2, opts: ['--release', '--workspace', '--all', '--lib', '--doc', '-q', '--quiet', '--no-fail-fast'] },
go: { fixed: 2, opts: ['-v', '-short', '-race', '-failfast', '-cover', '-count', '-run'], value: ['-count', '-run'] },
make: { fixed: 2, opts: [], noAssign: true },
};
RUNNER_ARGS.python = RUNNER_ARGS.python3 = RUNNER_ARGS.uv = { ...RUNNER_ARGS.pytest, fixed: 3 };
function screenArgs(t, spec) {
const fixed = typeof spec.fixed === 'function' ? spec.fixed(t) : spec.fixed;
for (let i = fixed, paths = false; i < t.length; i++) {
const w = t[i];
if (paths) {
if (w.startsWith('-')) return deny(`an option after \`--\` (${w}): only paths may follow it`);
continue;
}
if (w === '--' && spec.dashDash) { paths = true; continue; }
if (w.startsWith('-')) {
const name = w.split('=')[0];
if (!spec.opts.includes(name)) return deny(`\`${t[0]}\` with an option that is not on its read-only list (${name})`);
const inline = w.includes('=');
const value = inline ? w.slice(name.length + 1) : spec.value?.includes(name) ? t[i + 1] : undefined;
if (!inline && spec.value?.includes(name)) i++;
if (spec.only?.[name] && !spec.only[name].includes(value)) {
return deny(`\`${t[0]}\` with ${name} ${value ?? '(no value)'}: only ${spec.only[name].join('|')}`);
}
continue;
}
if (spec.noAssign && w.includes('=')) return deny(`\`${t[0]}\` with a variable override (${w})`);
}
return permit();
}
// `bash <path>` is the way a repo declares a check the allowlist has no name
// for. The path must live under tests/, be relative, and not climb out of it.
function isTestScript(path) {
@ -371,7 +431,11 @@ function screenShape(command, opts, writeTime) {
const tokens = text.trim().split(/\s+/);
if (!Object.hasOwn(RUNNERS, tokens[0])) return deny(`\`${tokens[0]}\` is not a known test runner`);
return RUNNERS[tokens[0]](tokens, cwd, writeTime);
const form = RUNNERS[tokens[0]](tokens, cwd, writeTime);
if (!form.allowed || tokens[0] === 'git') return form;
const odd = tokens.find((w) => !PLAIN_WORD.test(w));
if (odd !== undefined) return deny(`a word the shell would rewrite (${odd}): only plain words are checked`);
return Object.hasOwn(RUNNER_ARGS, tokens[0]) ? screenArgs(tokens, RUNNER_ARGS[tokens[0]]) : permit();
}
// --- running ----------------------------------------------------------------

View file

@ -33,7 +33,7 @@ import { readFileSync, existsSync, realpathSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { resolve, relative, dirname, isAbsolute, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
import { sliceSteps, parseStepFields, fieldPaths } from '../parsers/plan-schema.mjs';
import { parseManifest } from '../parsers/manifest-yaml.mjs';
// Files Voyage's own process writes while planning and executing.
@ -44,9 +44,8 @@ export const PROCESS_FILES = Object.freeze([
]);
const BACKTICK_SPAN = /`([^`\n]+)`/g;
// Backticked: spaces allowed (`docs/my file.md`); bare: no whitespace.
// A backticked path in `Test first:` (spaces allowed: `docs/my file.md`).
const looksLikeTickedPath = s => /[./]/.test(s) && !/^https?:/.test(s) && !s.startsWith('-') && s.trim() === s;
const looksLikePath = s => !/\s/.test(s) && looksLikeTickedPath(s);
function pathsIn(text) {
const out = [];
@ -62,12 +61,9 @@ export function plannedPaths(planText) {
for (const s of sliceSteps(planText)) {
const f = parseStepFields(s.body);
const files = f.get('Files');
if (files) {
const ticked = pathsIn(files.text);
// Older plans write bare paths: `- Files: a.ts, b.ts`.
const bare = ticked.length ? [] : files.text.split(/[,\s]+/).filter(looksLikePath);
for (const p of [...ticked, ...bare]) out.add(norm(p));
}
// One reader with the tests-only check (plan-schema.mjs fieldPaths):
// backticked spans as written, else every path-like word.
if (files) for (const p of fieldPaths(files.text)) out.add(norm(p));
const tf = f.get('Test first');
if (tf) {
for (const line of tf.text.split('\n')) {