/** * Test-command normalizer (#1857). * * A GSD verification gate resolves a project's test command and runs it. When * that command is a watch/dev-mode runner (vitest defaults to WATCH in an * interactive TTY — which is exactly a user running `gsd-execute-phase` in their * terminal — and jest with `--watch`/`--watchAll`), the process never exits and * the orchestrator waits forever. * * `normalizeTestCommand` rewrites a resolved command to a best-effort one-shot * form so a gate cannot hang on watch mode. It is intentionally conservative: * - it NEVER double-adds flags (already-one-shot commands are returned verbatim), * - it only touches commands it positively recognises as a watch runner, * - anything it cannot classify is returned unchanged. * The gate's wall-clock `timeout` is the ultimate guarantee for anything this * best-effort pass cannot defeat (e.g. an explicit `--watch` baked into a * project's `test` script, which even `CI=1` cannot override per vitest docs). * * Bounded by design (#1857 security review): the input is length-capped, all * scanning is linear-time (no super-linear regex backtracking), and package.json * is only read when it is a regular file — so normalization itself can never * hang or take super-linear time on an adversarial `workflow.test_command`. * * Single source of truth: all four test-command gates (regression, post-merge, * audit-fix, verify-phase) route their resolved command through this helper so * the paths cannot drift. * * Leaf module — depends only on node:fs / node:path. */ import fs from 'node:fs'; import path from 'node:path'; // A resolved test command is never realistically this long; anything larger is // not a real runner invocation. We skip normalization above this bound (the // gate's own timeout still bounds the actual run) so no regex ever scans an // adversarial multi-KB string. const MAX_COMMAND_LENGTH = 4096; // Markers proving a command is already one-shot / non-watch. If any is present // we return the command unchanged so we never double-add flags or fight an // explicit user choice. const ONE_SHOT_MARKERS: RegExp[] = [ /(?:^|\s)vitest\s+run\b/, // vitest run … /(?:^|\s)--run\b/, // vitest --run /(?:^|\s)--no-watch\b/, // vitest --no-watch /(?:^|\s)--watchAll=false\b/, // jest --watchAll=false /(?:^|\s)--watch=false\b/, /(?:^|\s)--ci\b/, // jest --ci /^\s*CI=/, // already forced into CI/run mode via env ]; /** True if the command already runs one-shot (so normalization is a no-op). */ function isAlreadyOneShot(cmd: string): boolean { return ONE_SHOT_MARKERS.some((re) => re.test(cmd)); } // Match a runner as a standalone command TOKEN (whitespace-delimited), NOT as a // substring — so "vitest.config.js" / "jest-environment" / "node vitest-x.js" // are not treated as the runner and are never mangled. const VITEST_TOKEN = /(?:^|\s)vitest(?=\s|$)/; const JEST_TOKEN = /(?:^|\s)jest(?=\s|$)/; /** * Linear-time detection of a package-manager `test` script invocation * (`npm test`, `pnpm run test`, `pnpm --dir app test`, `yarn test`, …). We split * on shell separators FIRST (linear), then test each bounded segment with simple * anchored regexes — no tempered-greedy scan, so no super-linear backtracking on * adversarial input. */ function isScriptInvocation(cmd: string): boolean { return cmd.split(/&&|\|\||;/).some((seg) => { const s = seg.trim(); return /^(?:npm|pnpm|yarn|bun)\b/.test(s) && /\btest\b/.test(s); }); } /** * Resolve the package.json directory for a command that may target a different * working dir via `--dir
` (pnpm), `-C
` (pnpm), or `--prefix
` (npm).
*/
function resolvePackageDir(cmd: string, cwd: string): string {
const m = cmd.match(/(?:--dir|--prefix|-C)[=\s]+(\S+)/);
if (m && m[1]) {
const p = m[1].replace(/^['"]|['"]$/g, '');
return path.isAbsolute(p) ? p : path.join(cwd, p);
}
return cwd;
}
type WatchRunner = 'vitest' | 'jest' | null;
/**
* Inspect package.json `scripts.test` for the given command's target dir and
* report whether it resolves to a watch-by-default / explicitly-watching runner.
* Only ever reads `scripts.test` as a STRING for classification — it is never
* executed or spliced into the output.
*/
function scriptTestRunner(cmd: string, cwd: string): WatchRunner {
try {
const pkgPath = path.join(resolvePackageDir(cmd, cwd), 'package.json');
// Only read a REGULAR file — never block on a FIFO/socket/dir named
// "package.json" reachable via --dir (#1857 security review).
let stat: fs.Stats;
try {
stat = fs.statSync(pkgPath);
} catch {
return null;
}
if (!stat.isFile()) return null;
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')) as {
scripts?: Record