Files
msd-core/tests/helpers.cjs
Tom Boucher 9faacc0c15 test(#3148): bound the long tail and delete the unbounded-spawn allowlist (#3192)
* test(#3148): bound the long tail and delete the allowlist

Migrates the final 170 unbounded sync spawn sites across 49 files, then
removes the allowlist entirely. local/no-unbounded-spawn now runs with no
exemption surface across tests/**: there is no file to add a name to.

drift-detection's throw-native git() helper routes to gitOrThrow -- bare
runGit would have taken 16 call sites quiet on failure. commands.test.cjs
has two independently-scoped runGsdTools/runCli helpers, one already bounded
and one not; they are kept distinct rather than unified, the same trap as the
two same-named git() helpers in Wave 1.

runNpm's bound was erasable. Its options spread callerOptions after the
defaults, so an explicit timeout:undefined silently dropped the 180000ms
bound -- the rule flagged it and was right; it was not a false positive. Fixed
by destructuring with a default, with a test that fails when the default is
removed.

Two sites stay on a raw spawn with an explicit timeout because the seam
cannot express them: one needs shell:true for npm.cmd on Windows, one
redirects stdout to a real fd. Both are the rule's own documented second
option, not an escape from it.

Closure verified rather than asserted: the derivation scan reports 0 unbounded
spawn helpers and 0 unbounded direct git call sites, and a temporary file
carrying an unbounded spawn still errors with the allowlist gone.

Closes #3064.

* test(#3148): close a hole in the guard's own eslint-disable ban

The ban listed only the top level of tests/, so it was blind to 37 .cjs
files under tests/helpers, qa, observability, fixtures and dispatch. With the
allowlist deleted this test is the sole remaining way to detect someone
silencing the rule inline, so the gap was load-bearing: a nested file could
carry an unbounded spawn plus an eslint-disable and pass everything.

Proven before and after. A probe planted under tests/helpers with both was
invisible to the guard and clean under eslint; after making the listing
recursive the guard fails on it. The scanned set goes from 771 files to 808.

Pre-existing since the guard shipped, but this wave is what promoted it to
sole defense, so it is fixed here rather than filed.

Also converts the last hand-rolled throw check to throwIfFailed and the last
re-derived legacy shape to compose toLegacyResult, which makes the epic's
none-remain claim true rather than nearly true. toLegacyResult itself is not
widened -- eight callers depend on its shape and one consumer does not
justify changing a shared contract.

* fix(#3148): correct seam incoherence at the bound and a slow review-lane error path

Two real failures from the remote runner, both fixed at the cause.

The seam could return outcome TIMED_OUT together with exitCode 0. At the
exact bound spawnSync reports ETIMEDOUT while the child has already exited
with a real status, and toSeamResult classified on the error code while
passing status straight through -- an incoherent pair its own boundary test
was written to catch, and did. A status that is not null is direct evidence
the child exited on its own, so it now decides the outcome before the
error-code branches run. process-seam.cjs was deliberately untouched by every
earlier wave; this is a defect in the module itself, kept surgical, with a
unit test that fails against the old logic.

review-lane with an unknown subcommand fell through to its usage error only
after loading the capability registry and building a per-lane plan, which
spawns one child process per lane -- up to twelve. The error path took
~1288ms instead of ~119ms, and under bench load it outran a caller's spawn
timeout and was killed before writing anything, which is the empty stdout and
stderr CI saw. It now fails fast before any of that work begins.

This is the epic's first production change. It is user-facing, so it carries
a changeset rather than a no-changelog label.

* test(#3148): replace a real-race timeout test with a deterministic one

E9 raced git rev-parse against a 1ms bound and assumed git always lost. On a
warm container git finishes first, spawnSync returns status 0 with no error
at all, the seam correctly classifies EXITED, and gitOrThrow correctly does
not throw -- so the test failed on both lanes. A probe confirms a genuine
timeout always carries status null, so this was never the seam misbehaving.

Raising the bound would only lengthen the odds, which is the same defect with
better luck. The test now drives gitOrThrow against a stubbed runGit that
returns a synthetic TIMED_OUT result, so it asserts exactly what it always
meant to -- that a timeout propagates as a throw -- with no timing
dependence. Five consecutive runs are identical where the old one varied.

I wrote this test in Wave 0; it is a real-race test by construction and
CLAUDE.md says to replace those rather than re-run them.

* chore(#3148): backfill changeset PR number 3192

---------

Co-authored-by: sim <sim@local>
2026-08-07 21:03:50 -04:00

767 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GSD Tools Test Helpers
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createFixture } = require('./fixtures/index.cjs');
const processSeam = require('./helpers/process-seam.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
// #2665: blank config-LOCATION vars so npm test never writes into the developer's
// live config directory. The resolver consults these before HOME, so an ambient
// value wins unconditionally over a sandboxed HOME. Per-site overrides still win
// because env is spread last in the child-env merge.
CLAUDE_CONFIG_DIR: '',
GSD_RUNTIME: '',
CODEX_HOME: '',
};
/**
* Run gsd-tools command.
*
* @param {string|string[]} args - Command string (shell-interpreted) or array
* of arguments (shell-bypassed via execFileSync, safe for JSON and dollar signs).
* @param {string} cwd - Working directory.
* @param {object} [env] - Optional env overrides merged on top of process.env.
* Pass { HOME: cwd } to sandbox ~/.gsd/ lookups in tests that assert concrete
* config values that could be overridden by a developer's defaults.json.
*/
function runGsdTools(args, cwd = process.cwd(), env = {}) {
// Resolve argv once so both the first attempt and the retry use the same vector.
const childEnv = { ...process.env, ...TEST_ENV_BASE, ...env };
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map(t => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
// Adapter over tests/helpers/process-seam.cjs (#3055). The seam returns a
// typed { outcome, exitCode, stdout, stderr, timedOut, signal, killed, code }
// result — never throws for a kill/timeout/buffer-overflow/spawn-failure.
// This adapter is the ONLY place that retries and the ONLY place that
// reconstructs runGsdTools's legacy { success, output, error, exitCode }
// shape, so all 136 callers keep their existing contract byte-identically.
//
// `processSeam.runNode` is looked up on the module object (not destructured
// at require time) so tests can `mock.method(processSeam, 'runNode', fn)`
// to inject TIMED_OUT / BUFFER_OVERFLOW / SPAWN_FAILED without waiting on
// real subprocess timers.
function attempt() {
return processSeam.runNode([TOOLS_PATH, ...argv], {
cwd,
env: childEnv,
timeoutMs: 60000,
});
}
function throwResourceStarvation(result) {
throw new Error(
`[runGsdTools: resource-starvation / subprocess-kill after retry] ` +
`gsd-tools was killed before completion ` +
`(signal=${result.signal}, code=${result.code}, killed=${result.killed}). ` +
`This indicates host OOM or scheduler contention, not a product bug. ` +
`stdout=${(result.stdout || '').trim()} ` +
`stderr=${(result.stderr || '').trim()}`
);
}
function toLegacyShape(result) {
if (result.outcome === processSeam.OUTCOME.EXITED) {
if (result.exitCode === 0) {
return { success: true, output: (result.stdout || '').trim(), exitCode: 0 };
}
// Clean non-zero exit (real command error, no kill signal, no spawn
// failure): return normally. No retry, no throw — preserves existing
// test behavior that asserts on error shape.
const stderrRaw = (result.stderr || '').trim();
// Prefer actual stderr content; fall back to the same "Command failed:
// <argv0> <args...>" message Node's execFileSync used to synthesize
// for a clean non-zero exit with no stderr (verified against this
// runtime's child_process internals: checkExecSyncError() only builds
// that message when `ret.error` is absent and `ret.status !== 0`, and
// never appends stderr when it is empty). If stderr is empty, append a
// note so CI logs show "stderr: (empty)" rather than silently losing
// the fact that the child process produced no error output — empty
// stderr with a non-zero exit code is a signal of OS-level crash (OOM
// kill, worker thread fatal error) rather than a gsd-tools application
// error.
const commandLine = [process.execPath, TOOLS_PATH, ...argv].join(' ');
const error = stderrRaw
|| `Command failed: ${commandLine} [stderr: (empty) exit:${result.exitCode ?? 1}]`;
return {
success: false,
output: (result.stdout || '').trim(),
error,
exitCode: result.exitCode ?? 1,
};
}
if (result.outcome === processSeam.OUTCOME.BUFFER_OVERFLOW) {
// Never retried. This is a DELIBERATE divergence from the old
// execFileSync-based helper, not an oversight: the old code saw a
// maxBuffer overflow as `err.signal === 'SIGTERM'`, which made the old
// `isKilled(err)` true and triggered a retry. The new seam classifies
// overflow as its own BUFFER_OVERFLOW outcome specifically so it stops
// being conflated with a kill — the child ran fine and produced too
// much output, so retrying wastes 60s and fails identically every
// time.
//
// exitCode is coerced to 1 here — RETRACTED claim from an earlier
// revision of this comment that it was "never coerced to exitCode:1,
// unlike the pre-seam helper": that was wrong. A real caller
// (tests/context-predicates-query.test.cjs) asserts
// `typeof r.exitCode === 'number'`, matching the old code's
// `err.status ?? 1` on every non-retried failure path. The SEAM layer
// still reports `exitCode: null` (see toSeamResult) — that typed
// result is where the "no numeric exit code exists" information
// lives, discriminated via `outcome`. This LEGACY adapter's job is to
// preserve the old numeric contract for existing callers, so it
// coerces null to 1 here rather than propagating the seam's null.
return {
success: false,
output: (result.stdout || '').trim(),
error: `gsd-tools output exceeded the subprocess buffer limit (code=${result.code})`,
exitCode: 1,
};
}
if (result.outcome === processSeam.OUTCOME.KILLED) {
// Defensive only: the retry loop below always retries KILLED once and
// throws throwResourceStarvation() if it is still KILLED afterward, so
// this function is never actually invoked with a KILLED result that
// has not already survived a retry. It is handled explicitly (instead
// of falling into the SPAWN_FAILED catch-all below, whose message
// would be misleading) so a KILLED result can never silently render as
// a generic {success:false, exitCode:1}-shaped spawn failure.
return {
success: false,
output: (result.stdout || '').trim(),
error: `gsd-tools was killed by signal (signal=${result.signal}, code=${result.code})`,
exitCode: null,
};
}
// SPAWN_FAILED: the process never started (matches old behavior — ENOENT
// and friends carry no signal, so the old `isKilled(err)` was false).
// Never retried — retrying is pointless.
//
// exitCode is coerced to 1 here — same retraction as the BUFFER_OVERFLOW
// branch above: this was previously described as "never coerced to
// exitCode:1," which was wrong for the ADAPTER path. The old
// execFileSync-based helper returned `err.status ?? 1` on every
// non-retried failure, i.e. always `1` for a spawn failure, and a real
// caller depends on `typeof exitCode === 'number'`. The SEAM's own
// `toSeamResult` still reports `exitCode: null` for SPAWN_FAILED — that
// typed layer is where "no numeric exit code exists" is expressed via
// `outcome`; this legacy adapter re-applies the old numeric contract on
// top of it.
return {
success: false,
output: (result.stdout || '').trim(),
error: `gsd-tools failed to spawn (code=${result.code})`,
exitCode: 1,
};
}
// Kill-signal discrimination (#969): transient OOM/contention usually
// succeeds on retry; retry ONCE before surfacing the labeled error.
// TIMED_OUT and KILLED are retried — together they reproduce the OLD
// execFileSync-based `isKilled(err)` semantics exactly:
// old = err.killed || err.signal != null || err.code === 'ETIMEDOUT'
// TIMED_OUT covers the timeout case; KILLED covers a child terminated by a
// signal nobody in the seam sent (e.g. an external OOM kill) — the exact
// #969 case this retry exists for. BUFFER_OVERFLOW and SPAWN_FAILED are
// not kills and are never retried (see their branches in toLegacyShape).
const first = attempt();
if (first.outcome === processSeam.OUTCOME.TIMED_OUT || first.outcome === processSeam.OUTCOME.KILLED) {
const retry = attempt();
if (retry.outcome === processSeam.OUTCOME.TIMED_OUT || retry.outcome === processSeam.OUTCOME.KILLED) {
// Still killed after retry — persistent resource starvation, throw.
throwResourceStarvation(retry);
}
return toLegacyShape(retry);
}
return toLegacyShape(first);
}
// Create a bare temp directory (no .planning/ structure)
function createTempDir(prefix = 'gsd-test-') {
return fs.mkdtempSync(path.join(require('os').tmpdir(), prefix));
}
// Create temp directory structure
function createTempProject(prefix = 'gsd-test-') {
return createFixture({ prefix, planning: true, git: false });
}
// Create temp directory with initialized git repo and at least one commit
function createTempGitProject(prefix = 'gsd-test-') {
return createFixture({ prefix, planning: true, git: true, projectDoc: true });
}
// The OS temp root has several canonical spellings, and a path a caller
// legitimately passes to cleanup() may arrive in any of them: macOS resolves
// os.tmpdir() under /var/folders/... but /var is a symlink to /private/var;
// Windows CI runners report os.tmpdir() in the 8.3 SHORT form
// (C:\Users\RUNNER~1\AppData\Local\Temp) while the caller's path is the
// expanded LONG form, and fs.realpathSync() does not reliably expand 8.3
// short names there — only fs.realpathSync.native() does; drive-letter and
// path casing can also differ (C:\ vs c:\). This function collects the full
// set of accepted temp roots — os.tmpdir()'s several spellings are one part
// of that set, not the whole of it (see below). Each probe is wrapped in its
// own try/catch — none of them may throw and crash cleanup(), they just
// contribute nothing if unavailable.
// Memoization cache for tmpRootCandidates(), keyed on the LIVE os.tmpdir()
// value (not hoisted to a plain module-level constant) — two test files in
// this suite mutate TMPDIR/TEMP/TMP mid-run and restore them afterward, so
// caching on the current os.tmpdir() read is what keeps a stale cache from
// leaking across that override instead of a one-time computation baked in
// at module load.
let _tmpRootCandidatesCacheKey;
let _tmpRootCandidatesCache;
function tmpRootCandidates() {
const cacheKey = os.tmpdir();
if (cacheKey === _tmpRootCandidatesCacheKey && _tmpRootCandidatesCache) {
return _tmpRootCandidatesCache;
}
const deduped = _computeTmpRootCandidates();
_tmpRootCandidatesCacheKey = cacheKey;
_tmpRootCandidatesCache = Object.freeze(deduped);
return _tmpRootCandidatesCache;
}
function _computeTmpRootCandidates() {
const roots = [];
try {
roots.push(path.resolve(os.tmpdir()));
} catch (_) { /* os.tmpdir() unavailable — skip this variant */ }
try {
roots.push(fs.realpathSync(os.tmpdir()));
} catch (_) { /* temp root unreadable — skip this variant */ }
try {
roots.push(fs.realpathSync.native(os.tmpdir()));
} catch (_) { /* native realpath unavailable/unreadable — skip this variant */ }
const isWindows = process.platform === 'win32';
// os.tmpdir() alone is too narrow: it honors $TMPDIR, but some tests
// (e.g. tests/config-schema.property.test.cjs's getWritableTmp()) create
// fixtures directly under the conventional system temp roots instead of
// through $TMPDIR — on macOS that is /private/tmp, which can differ from
// os.tmpdir()'s /var/folders/.../T. Probe the well-known non-Windows temp
// roots too, each independently and only if it actually exists on this
// host, so the accepted set stays a bounded, explicit list rather than an
// open-ended patch list. macOS additionally exposes /tmp as a symlink to
// /private/tmp, so both the unprefixed and /private-prefixed spellings —
// and each one's realpath — are collected.
if (!isWindows) {
for (const candidate of ['/tmp', '/private/tmp']) {
try {
if (fs.existsSync(candidate)) {
roots.push(path.resolve(candidate));
try {
roots.push(fs.realpathSync(candidate));
} catch (_) { /* exists but unreadable via realpath — skip this variant */ }
}
} catch (_) { /* existsSync itself should not throw, but fail closed if it does */ }
}
}
const seen = new Set();
const deduped = [];
for (const root of roots) {
const key = isWindows ? root.toLowerCase() : root;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(root);
}
return deduped;
}
function cleanup(tmpDir) {
if (typeof tmpDir !== 'string' || tmpDir.length === 0) return;
const target = path.resolve(tmpDir);
const cwd = path.resolve(process.cwd());
// The temp-root check below was previously done only inside the catch block,
// so it classified a transient Windows error but was never consulted by the
// destructive rmSync call itself — a wrong `target` would still chdir out of
// its own tree and get force-deleted. Hoisted above both the chdir and the
// rmSync so an out-of-temp-root path is refused before either can run.
// Comparison is case-insensitive on Windows (drive-letter and path casing
// vary there) and case-sensitive everywhere else; the error message below
// always prints the original-case target.
const isWindows = process.platform === 'win32';
const tmpRoots = tmpRootCandidates();
function isUnderRoots(p) {
const pForCompare = isWindows ? p.toLowerCase() : p;
return tmpRoots.some((root) => {
const rootForCompare = isWindows ? root.toLowerCase() : root;
if (pForCompare === rootForCompare) return true;
// A root that is itself a filesystem root (`/`, or `C:\` reachable via
// TMPDIR=/) already ends with path.sep — appending a second one would
// build `//`, which only the literal string `/` satisfies, refusing
// every real descendant. Only append the separator when it is not
// already there.
const prefix = rootForCompare.endsWith(path.sep)
? rootForCompare
: `${rootForCompare}${path.sep}`;
return pForCompare.startsWith(prefix);
});
}
if (!isUnderRoots(target)) {
throw new Error(
`cleanup() refused to remove a path outside the known temp roots ` +
`(${tmpRoots.join(', ')}): ${target}`
);
}
// No symlink-escape check here: fs.rmSync does not follow a top-level
// symlink — it unlinks the link itself and leaves the target intact — so
// there is no live hazard for the root-membership check above to guard
// against. That check is the one closing an actual defect.
if (cwd === target || cwd.startsWith(`${target}${path.sep}`)) {
// Windows cannot remove a directory that is the current working directory.
process.chdir(path.dirname(target));
}
// maxRetries/retryDelay absorbs transient Windows EBUSY where AV scanners,
// file-indexers, or just-exited child processes still hold handles when
// teardown runs. On POSIX the retry loop is a no-op (rmSync succeeds first try).
// Budget: 20 × 250ms = 5s total — Windows Defender's deferred scan can hold
// newly-written files for several seconds on cold runners.
try {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 });
} catch (error) {
// After retries, Windows can still briefly hold temp dirs open after a timed-out
// child exits. Ignore that teardown-only flake for temp roots, but rethrow everything else.
// By this point target is guaranteed under a temp root: the guard clauses above throw
// for any other path, so this swallow doesn't need to re-test that.
const isTransientWinErr = process.platform === 'win32'
&& ['EBUSY', 'ENOTEMPTY', 'EPERM'].includes(error && error.code);
if (!isTransientWinErr) throw error;
}
}
/**
* Read a text file with CRLF normalized to LF.
*
* DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE (CONTEXT.md; recurring since #1700):
* a test that reads a workflow/agent/reference `.md` file, slices or
* regex-matches a fenced code block out of it, and hands that block to
* `spawnSync('bash', ...)` breaks on a Windows checkout — `.gitattributes`
* `eol=lf` is not always honored by `actions/checkout` on `windows-latest`,
* so `readFileSync` can return `\r\n` line endings. Bash then treats the
* trailing `\r` on every line as part of the token; an opening quote never
* finds its match and the parser dies mid-script with "unexpected EOF while
* looking for matching `"'" or a bare syntax error at the next `{`/`)`.
*
* `.split(/\r?\n/)` on the FENCE DELIMITER alone does not fix this — it only
* protects the boundary match, not the captured body between the fences,
* which still carries embedded `\r` characters (the exact bug #2650's
* verification round found in tests/fix-2650-plan-phase-stall-detection.test.cjs,
* despite that file's fence regex already using `\r?\n`).
*
* Normalizing ONCE at the read boundary, before any slicing/regex/fence
* parsing runs, is cheaper and safer than normalizing at each extraction
* call site: every downstream `indexOf`/`slice`/regex/`spawnSync` then
* operates on LF-only content by construction, and a new `.md`-extraction
* test is correct by default just by reading through this helper.
*
* @param {string} filePath - Absolute or relative path to a text file.
* @returns {string} File content with every `\r\n` replaced by `\n`.
*/
function readFileNormalized(filePath) {
return fs.readFileSync(filePath, 'utf-8').replace(/\r\n/g, '\n');
}
/**
* Read a workflow .md file plus every .md file under its sibling
* `<workflow-basename>/steps/` directory, concatenated in document order
* (host file first, then step files sorted by filename).
*
* ADR-1671's workflow fragmentization (#2930/#2932/#2993 et al.) moves whole
* sections out of a host workflow (e.g. `plan-phase.md`) into lazily-loaded
* step files under `gsd-core/workflows/<name>/steps/*.md`. A structural or
* drift guard that reads the host file alone goes blind the moment a
* section it cares about moves out — this is exactly the shape #2650's own
* regression tests hit when #2993 relocated plan-phase.md's chunked-planning
* spawn sites into `plan-phase/steps/chunked-planning-mode.md`. Any test
* that needs to see the FULL picture (counting markers, asserting a marker
* exists somewhere in the workflow) should read through this helper instead
* of `fs.readFileSync(workflowPath)` alone, so the next relocation doesn't
* silently blind it again. Originally local to
* tests/plan-phase-drift-guard.test.cjs (readPlanPhaseCombined) — promoted
* here so a second, divergent copy is never written (Generative Fix
* Divergence class).
*
* @param {string} workflowPath - absolute path to the host workflow .md file.
* @returns {string} host content, then '\n' + each step file's content in
* sorted-filename order. An absent steps directory degrades to the host
* content alone (not an error — most workflows have no steps/ dir).
*/
function readWorkflowCombined(workflowPath) {
let combined = readFileNormalized(workflowPath);
const stepsDir = path.join(path.dirname(workflowPath), path.basename(workflowPath, '.md'), 'steps');
if (fs.existsSync(stepsDir)) {
for (const entry of fs.readdirSync(stepsDir).sort()) {
if (entry.endsWith('.md')) {
combined += '\n' + readFileNormalized(path.join(stepsDir, entry));
}
}
}
return combined;
}
/**
* Parse a Markdown frontmatter block into a flat key→value map.
*
* Handles the YAML scalar forms emitted by the install converters:
* key: "json-encoded value" → JSON.parse
* key: 'value with ''escape'' → strip quotes, unescape ''
* key: bare value → trimmed string
*
* Multi-line and block scalars are out of scope — every converter in
* `bin/install.js` emits single-line scalars only. Throws if the content
* has no closed `---` block so a regression in the emitter shape fails
* loudly rather than silently returning {}.
*
* Tests use this helper instead of `result.includes('key: value')` to
* follow the project's "tests parse, never grep" convention.
*
* @param {string} content - Full file content beginning with `---`.
* @returns {Record<string, string>} Map of frontmatter keys to decoded values.
*/
function parseFrontmatter(content) {
if (!content.startsWith('---')) {
throw new Error(`parseFrontmatter: content must start with '---', got: ${content.slice(0, 40)}`);
}
// CRLF tolerance: a Windows-authored file split on `\n` would leave a
// trailing `\r` on every line, making `lines[i] === '---'` fail to
// recognize delimiters. Same goes for whitespace-padded delimiter lines.
// Normalize via a CRLF-aware split + trimmed comparison.
const lines = content.split(/\r?\n/);
let openIdx = -1;
let closeIdx = -1;
for (let i = 0; i < lines.length; i += 1) {
if (lines[i].trim() === '---') {
if (openIdx === -1) openIdx = i;
else { closeIdx = i; break; }
}
}
if (openIdx === -1 || closeIdx === -1) {
throw new Error('parseFrontmatter: no closed --- block');
}
const fields = {};
for (const line of lines.slice(openIdx + 1, closeIdx)) {
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
if (!match) continue; // skip block-list items, blank lines, comments
const [, key, rawValue] = match;
const value = rawValue.trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
fields[key] = JSON.parse(value);
} else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
fields[key] = value.slice(1, -1).replace(/''/g, "'");
} else {
fields[key] = value;
}
}
return fields;
}
// #3026 CR: shared `--help` output check used by bug-1818 + bug-3019 tests.
// Render-on-help shape is `Usage: gsd-tools …\nCommands: …` — both lines
// must be present; structural test, not prose substring matching.
function isUsageOutput(text) {
return /Usage:\s*gsd-tools/.test(text) && /Commands:/.test(text);
}
/**
* Isolated HOME directory used by runNpm() for the lifetime of this process.
*
* npm reads $HOME/.npmrc (user config) and writes to $HOME/.npm (default cache)
* when these paths are not overridden. On Docker hosts the running user's HOME
* may be uninitialized, unwritable, or contain stale state from a prior run —
* any of which causes `npm pack` / `npm install -g` to fail. Fix: create a
* fresh temp directory once per process, redirect HOME + cache + userconfig into
* it, and clean up on process exit. This makes runNpm() independent of the
* caller's environment. (#131)
*/
const _npmIsolatedHome = fs.mkdtempSync(path.join(require('os').tmpdir(), 'npm-home-'));
process.on('exit', () => {
try { fs.rmSync(_npmIsolatedHome, { recursive: true, force: true }); } catch (_) { /* best-effort */ }
});
/**
* Run `fn` with console.log/warn/error captured, returning {stdout, stderr}
* with ANSI colors stripped. Re-throws any exception fn threw AFTER restoring
* the real console so the caller's assertion path sees the failure (without
* this, a fn that crashes before printing would falsely pass !hasReady-style
* assertions). #2775 CR follow-up established this exact contract.
*
* Previously duplicated in bug-2775, bug-2829, bug-3033, bug-3211, bug-3231,
* bug-3359, and installer-migration-install-integration.
*/
function captureConsole(fn) {
const stdout = [];
const stderr = [];
const origLog = console.log;
const origWarn = console.warn;
const origError = console.error;
console.log = (...a) => stdout.push(a.join(' '));
console.warn = (...a) => stderr.push(a.join(' '));
console.error = (...a) => stderr.push(a.join(' '));
let threw = null;
try {
fn();
} catch (e) {
threw = e;
} finally {
console.log = origLog;
console.warn = origWarn;
console.error = origError;
}
if (threw) throw threw;
// Built via String.fromCharCode (not a literal control character in a
// regex, which `no-control-regex` rejects) so the ESC byte itself is
// matched at runtime — this strips real ANSI color codes, not a decoy.
const ansiPattern = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, 'g');
const strip = (s) => s.replace(ansiPattern, '');
return {
stdout: stdout.map(strip).join('\n'),
stderr: stderr.map(strip).join('\n'),
};
}
/**
* Normalize platform path separators to POSIX forward slashes. Use for
* cross-platform path comparisons in test assertions where the runtime
* emits the platform-native separator (\ on Windows) but the test
* fixture or expected literal is POSIX. Returns the input unchanged if
* null/undefined so it composes safely with optional chaining.
*/
function toPosixPath(p) {
return p == null ? p : p.split(path.sep).join('/');
}
/**
* Build the expected absolute, POSIX-normalized `.planning/...` path for a
* given fixture root — the shape #2376's init/state path-field output now
* emits (anchored on process.cwd() / --cwd) instead of the historical
* relative literal.
*
* Centralizes the identical inline `absPlanningPath` helper previously
* duplicated across tests/quick-research.test.cjs, tests/init.test.cjs,
* tests/onboard-command.test.cjs, and tests/roadmap-parser.test.cjs.
*
* Callers MUST pass a realpath'd fixture root (e.g.
* `fs.realpathSync(createTempProject())`) so the expected value matches
* what a spawned child process actually resolves via `process.cwd()` — on
* macOS `os.tmpdir()` is a symlink (`/var/...` -> `/private/var/...`) that
* the child's cwd resolves through but a bare `mkdtempSync()` does not.
*
* @param {string} base - fixture root (should be realpath'd by the caller).
* @param {...string} segments - path segments under `.planning/`.
* @returns {string} POSIX-normalized absolute path.
*/
function absPlanningPath(base, ...segments) {
return toPosixPath(path.join(base, '.planning', ...segments));
}
/**
* Run an npm command via execFileSync with cross-platform portability.
*
* Handles the Windows `npm.cmd` vs POSIX `npm` distinction and the
* `shell: true` requirement on Windows so tests do not need to
* re-implement platform detection inline.
*
* @param {string[]} args - npm subcommand and flags (e.g. ['pack', '--pack-destination', dir]).
* @param {object} [options] - execFileSync options merged with platform defaults.
* `cwd`, `encoding`, `timeout`, and `env` are the commonly overridden keys.
* @returns {string} trimmed stdout string (encoding: 'utf-8').
* @throws {Error} re-throws the execFileSync error on non-zero exit so callers
* get the full stderr in the error message.
*/
function runNpm(args, options = {}) {
const isWindows = process.platform === 'win32';
const npmCmd = isWindows ? 'npm.cmd' : 'npm';
// Inject an isolated HOME so npm never reads from or writes to the caller's
// $HOME. This prevents failures on Docker hosts where HOME is unwritable or
// uninitialized. The caller may still pass { env: {...} } in options to
// further override specific variables — those overrides win because they are
// applied after the isolated env below (via the spread in the merge). (#131)
const isolatedEnv = {
...process.env,
HOME: _npmIsolatedHome,
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
npm_config_loglevel: 'error',
npm_config_update_notifier: 'false',
NO_UPDATE_NOTIFIER: '1',
};
const defaults = {
encoding: 'utf-8',
shell: isWindows,
env: isolatedEnv,
};
// Merge options; if caller passes their own env, merge it on top of isolatedEnv
// so the isolation is preserved unless the caller explicitly overrides HOME.
// `timeout` is destructured with a default (not left inside `defaults`) so an
// explicit `timeout: undefined` in `options` — an own key, not an omission —
// cannot silently erase the bound via the spread below; a destructure default
// only applies on `undefined`, whereas `{ ...defaults, ...otherOptions }`
// would let that own key win and fall through to no bound at all. 180000ms:
// npm install/pack against an isolated HOME; this is the pre-existing value,
// preserved.
const NPM_TIMEOUT_MS = 180000;
const { env: callerEnv, timeout = NPM_TIMEOUT_MS, ...otherOptions } = options;
const mergedEnv = callerEnv ? { ...isolatedEnv, ...callerEnv } : isolatedEnv;
return execFileSync(npmCmd, args, { ...defaults, ...otherOptions, timeout, env: mergedEnv }).trim();
}
/**
* Returns the isolated npm environment dict used by runNpm().
*
* Callers (e.g. runSmoke()) can spread this into a spawnSync env so that npm
* never reads from or writes to the caller's $HOME — the same guarantee
* runNpm() already provides. (#131)
*
* @returns {object} env dict with HOME, npm_config_cache, npm_config_userconfig
* pointing into a process-scoped temp directory.
*/
function isolatedNpmEnv() {
return {
...process.env,
HOME: _npmIsolatedHome,
npm_config_cache: path.join(_npmIsolatedHome, '.npm'),
npm_config_userconfig: path.join(_npmIsolatedHome, '.npmrc'),
npm_config_loglevel: 'error',
npm_config_update_notifier: 'false',
NO_UPDATE_NOTIFIER: '1',
};
}
/**
* Run a callback with process-level state isolation.
* Restores cwd, exitCode, and process.env after callback returns or throws.
*
* @template T
* @param {() => T} fn
* @returns {T}
*/
function withIsolatedProcessState(fn) {
const originalCwd = process.cwd();
const originalExitCode = process.exitCode;
const originalEnv = { ...process.env };
try {
return fn();
} finally {
if (process.cwd() !== originalCwd) {
process.chdir(originalCwd);
}
process.exitCode = originalExitCode;
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value;
}
}
}
/**
* Async delay — yields the event loop for `ms` ms without a synchronous block.
* Replaces raw setTimeout / Atomics.wait sleeps in tests. `ms` is an identifier
* and the Promise is not awaited inline, so it does not trip the no-magic-sleep
* / no-restricted-syntax test rules (which only scan *.test.cjs anyway).
*/
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Poll `predicate` until it returns truthy or the deadline elapses — the approved
* poll-for-condition pattern for cross-process test synchronization. Returns the
* predicate's truthy value; throws Error(message) on timeout.
*
* `predicate` must return a boolean or truthy value when ready; any falsy result
* (including `0` or `''`) is treated as "not ready yet". Do not use predicates
* whose meaningful result can be falsy.
*
* `predicate` should not throw — exceptions propagate out of `waitFor` uncaught
* and are NOT retried. If the readiness check can throw on a transient state
* (e.g. parsing a partially-written file), guard inside the predicate and return
* `false` instead.
*/
async function waitFor(predicate, { timeoutMs = 10000, stepMs = 25, message = 'waitFor timed out' } = {}) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const value = predicate();
if (value) return value;
if (Date.now() >= deadline) throw new Error(message);
await delay(stepMs);
}
}
/**
* Reset all runtime-warning caches in config-loader.cjs and model-resolver.cjs.
*
* Use this in beforeEach/afterEach hooks in tests that exercise warning-emission
* paths so that each test starts with a clean slate. Replaces the duplicated local
* `_resetRuntimeWarningCacheForTests` wrappers in individual test files.
*/
function resetRuntimeWarningCaches() {
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
const modelResolver = require('../gsd-core/bin/lib/model-resolver.cjs');
configLoader._resetRuntimeWarningCacheForTests();
modelResolver._resetModelPolicyWarningCacheForTests();
modelResolver._resetModelOverrideWarningCacheForTests();
}
/**
* Env vars that influence workstream-session identity (getWorkstreamSessionKey
* in active-workstream-store.cjs) or workstream/project resolution (planningDir).
* Single source of truth for tests that need a deterministic, session-key-free
* and workstream/project-free process.env — save/clear before, restore after.
* Union of the sets previously hand-duplicated in
* tests/active-workstream-store.unit.test.cjs and tests/gsd-statusline.test.cjs
* (#2850 code review finding: the two copies had already silently diverged).
*/
const SESSION_ENV_KEYS = [
'GSD_SESSION_KEY', 'CODEX_THREAD_ID', 'CLAUDE_SESSION_ID', 'CLAUDE_CODE_SSE_PORT',
'OPENCODE_SESSION_ID', 'GEMINI_SESSION_ID', 'CURSOR_SESSION_ID', 'WINDSURF_SESSION_ID',
'TERM_SESSION_ID', 'WT_SESSION', 'TMUX_PANE', 'ZELLIJ_SESSION_NAME',
'TTY', 'SSH_TTY', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
'GSD_WORKSTREAM', 'GSD_PROJECT',
];
function saveSessionEnv() {
const saved = {};
for (const k of SESSION_ENV_KEYS) saved[k] = process.env[k];
return saved;
}
function restoreSessionEnv(saved) {
for (const k of SESSION_ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
}
function clearSessionEnv() {
for (const k of SESSION_ENV_KEYS) delete process.env[k];
}
module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, tmpRootCandidates, readFileNormalized, readWorkflowCombined, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, absPlanningPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, resetRuntimeWarningCaches, SESSION_ENV_KEYS, saveSessionEnv, restoreSessionEnv, clearSessionEnv, TOOLS_PATH };