A PR whose diff is entirely under docs/ runs zero tests, so a guard whose INPUT
is shipped prose cannot protect the PR lane of the diffs it exists to check. Its
only firing opportunity is after merge, on the shared branch -- which is how next
went red on dacae9273 while the PR that caused it (#3746) was green on every
check.
The docs-lint job in .github/workflows/docs-required.yml -- an ALREADY-REQUIRED
context -- now selects and runs the docs guards that read the specific docs files
the PR changed.
scripts/docs-guard-registry.cjs test file -> the docs paths it reads (63)
scripts/select-docs-guards.cjs pure (changedPaths, registry) -> test files
scripts/lint-docs-guard-registration.cjs drift guard, wired into lint:ci
scripts/ci-test-scope.cjs is NOT touched -- `git diff origin/next --` on it is
empty -- so #764's saving stands and its 21 pinning tests are untouched.
Selection: exact path; trailing-slash directory prefix (boundary-checked --
docs/adrenaline.md does NOT match docs/adr/, which a naive startsWith gets
wrong); and '*' for the 6 entries that walk docs/ generally or read a computed
path. Unknown maps to '*' -- guessing narrow is how a guard silently stops
running. Measured: a typo fix selects 6 of 63; docs/AGENTS.md selects 12;
docs/COMMANDS.md selects 18.
Four things this got wrong first, each found by an independent reviewer or by
probe, and each having been asserted safe in a comment:
1. The registry started as a RULE in ci-test-scope.cjs's RULES, on the theory
that classify()'s !codeChanged normalization made it inert. True for
docs-ONLY diffs; false for MIXED docs+code diffs, where codeChanged is true
and the normalization never runs:
node scripts/ci-test-scope.cjs --files "docs/a.md src/semver.cts"
with the RULE: 25 targeted_tests
origin/next: 3 targeted_tests
Category error: RULES is the scoped lane's input; a docs-guard registry is a
lane manifest for a consumer that never calls classify(). Extracted; pinned
by value.
2. The second attempt was a dedicated workflow with paths: [docs/**]. Such a
workflow never reports on a non-docs PR, so it can never be a required
context without hanging every non-docs PR -- and a non-required check does not
block a merge, so the guard would have been advisory and #3753 unfixed.
docs-required.yml already has no paths: filter, already supplies the required
docs-lint context, already computes docs_changed, and already ran one docs
guard gated on it. Generalizing that step needs no ruleset edit at all.
3. The registry and the drift lint were built from ONE path-segment heuristic, so
both were blind identically -- and blind at the guard that motivated the issue.
The reader-call regex required a character BEFORE its keyword, so a callee
named exactly read( / load( / parse( / doc( / file( / content( could never
match; and only an INLINE path.join(ROOT,'docs','X.md') argument was caught,
missing the two-step-via-variable form -- the MAJORITY spelling -- plus
template literals and concatenation. Detector 1 fired on 14 of ~450 files, so
35 genuine guards sat unregistered while the lint reported 0 violations,
including cursor-reviewer (reads docs/COMMANDS.md, asserts
.includes('--cursor')) and inventory-headings-countfree. The "accepted blind
spot" this shipped with was the common case, not a fringe.
4. With detection fixed the true population is 115 files: 63 genuine guards, 52
incidental. Running all 63 in a REQUIRED check on a one-line typo fix is the
cost #764 exists to avoid -- install.test.cjs is 7840 lines and reads exactly
one docs file, docs/AGENTS.md, for its frontmatter. Dropping it reproduces the
bug; running it for a typo elsewhere is waste. Hence the map.
Then a second review round found six more, all fixed here:
- fragment-single-edit-propagation.install.test.cjs was EXEMPTED as
"overlay fixture only". False: it reads the real docs/registries/eos.json and
asserts on a registry entry name, and reads the real ADR-0001 and asserts its
H1. A docs-only PR touching either would have gone green and red next -- #3753
shipping again, from inside the fix for it. Now registered against both paths,
and all 52 remaining exemptions were re-audited one by one.
- The SUITES-collision guard compared RAW registry keys, but run-tests.cjs strips
a leading `tests/` BEFORE its suite check. So it caught 'all' and missed
'tests/all' -- the only spelling that can actually occur, since every key
carries the prefix. One typo would have run all 824 test files inside the
required job. Now normalized the same way run-tests.cjs normalizes.
- The lint failed OPEN on an unreadable tests dir or candidate file: 0 violations,
ok:true. A guard that cannot read its input must never report success.
- The exemption ratchet gated identity only, so a baselined file that later
STARTED asserting on shipped docs stayed exempt silently -- 52 permanently blind
files. The baseline now fingerprints the docs paths each exempted file
references and fails when that set changes, naming what changed.
- The exemption marker was still honored inside a multi-line template literal in
the header window. The scanner now tracks template-literal and block-comment
state.
- `git diff --name-only | grep '^docs/'` silently dropped C-quoted non-ASCII docs
paths, making docs_changed=false a green zero-guard check. Both call sites now
pass -c core.quotepath=false.
- The run step was gated on hashFiles(), which a force-committed
.docs-guard-tests.txt would satisfy. The step now rm -f's both scratch files
first and gates on an output it sets itself.
Three empty states, deliberately distinct, because conflating them rebuilds
#3753: an empty or malformed registry HARD-FAILS; docs changed with no guard
covering them logs and skips; no docs change is already gated. The middle state
must never be expressed as an empty --files-from, which prints `no tests in suite
"all"` and exits 0 -- a green check that guarded nothing. With the current
registry that state is unreachable, because the six '*' entries always match;
the branch is kept as defensive handling for a future registry and says so.
timeout-minutes: 15 bounds the required job against a hanging fork-supplied test;
it had none. npm ci was added because the job never installed dependencies -- the
previous single-file step got away without it, the registry does not.
docs/contributing/docs-guard-registration.md documents the rule, following its
sibling cross-platform-portability-rules.md, and CONTRIBUTING.md's CI Test
Quality Checks table links to it. It is also load-bearing: without a docs/ file
in the diff this PR would not have triggered its own lane, shipping an
unexercised change to a required check.
One unrelated fix, included because this PR surfaced it and CLAUDE.md forbids
deferring a defect found while working. On this branch's first CI run,
`full test (windows-latest, 24, shard 3/3)` was CANCELLED at exactly 30 minutes;
tests were still passing 0.8s before the cancel, so it is a wall-clock timeout,
not a hang, and a cancelled job reddens `Required tests`.
The cause is not this PR's test file, which costs ~60ms. Shard composition is
unstable: adding ONE file to the unit suite reshuffled 115 of 268 files between
shards, and shard 3 drew a heavier mix. Underneath that is a real pre-existing
defect. tests/ci-test-job-timeout-budget.test.cjs requires every lane's budget to
be >= 1.5x its MEASURED cost -- "a lane that got slower must be re-budgeted, not
excused" -- and its test-full entry recorded 19m from a windows-22 shard. That is
stale. Measured on `next` with none of this PR's changes present: 26m18s (run
32614439702, windows-latest/24 shard 3/3), 23m36s and 23m17s on shard 2/3. So the
lane costs ~26m and the 30-minute cap carried 1.14x headroom, not 1.5x. The gate
had been out of compliance with its own rule; this PR was merely the file
addition that reshuffled shard 3 past the cliff.
Fixed as that file prescribes: measuredMinutes 19 -> 27 with fresh evidence, and
test-full timeout-minutes 30 -> 45. The rule's minimum for 27m is 41; 45 is
deliberately above it because the reshuffle means per-shard worst case moves run
to run, and a budget pinned to the exact minimum would be re-breached by the next
test file anyone adds. Only that one job's timeout changed; test.yml's scope,
matrix and steps are untouched, so #764's saving is unaffected.
Raising that cap let the Windows shard finish (28m45s, inside 45) and uncovered
a real failure the 30-minute cancel had been masking:
`new quick-task branch branches off origin/main (#2916)` died with
`outcome=timed_out exitCode=null`, SIGTERM, at the 15000ms bound.
tests/quick-branching.test.cjs:149 `runStep` runs a `#!/usr/bin/env bash` script
executing MULTIPLE git commands, but was bound to GIT_TIMEOUT_MS (15000) -- the
norm for a SINGLE git plumbing call. tests/helpers/timeouts.cjs already documents
this exact failure and exists to fix it: HOOK_FANOUT_TIMEOUT_MS was created after
PR #3285 recorded "outcome=timed_out exitCode=null at exactly the 15000ms probe
bound while every other lane passed the same commit", and calls that "a bound
sized for the wrong class, not a slow machine". Our failure is that case
verbatim, so both sites move to the class norm rather than to a bigger number.
The same class also failed on `next` itself 21 hours earlier -- run 32608945654,
windows-latest/24 shard 1/3, `plan touching only src/ in a submodule project
keeps worktree isolation ENABLED` -- where tests/worktree-safety.test.cjs:5845
`runGate` fans out to `git config --file .gitmodules` under a hardcoded 30000.
Fixed too, since it is a defect in the tree regardless of which branch surfaced
it.
A survey of the whole tests/ tree found the same class-mismatch at further
bash fan-out sites bound under 60000ms, and the maintainer approved sweeping
them rather than leaving them latent to surface the same way one at a time. 16
fan-out sites across 16 files now use the class norm.
The sweep is class-correctness, not raising numbers until things pass. Sites
were moved ONLY where the bash body demonstrably spawns something (git, node,
npm, a CLI); self-contained shell snippets were left where they are, and are
listed as deliberately unchanged: pure if/printf bodies (copilot-install), pure
array/case builtins (code-review-pipeline-regression:638), a documented
pure-shell gsd_run stub (host-integration), single-process hook calls
(workflow-guard:222/271/302), and a deliberately tight 5000ms fast-check hook
(gsd-write-guard.property). Nothing was lowered. process-seam.test.cjs:513
(literal 300) is untouched on purpose -- it tests timeout BEHAVIOR, so raising
it would destroy what it asserts.
Shared file-level constants were the trap here, and were handled per file rather
than by redefinition: GIT_TIMEOUT_MS has ~15 users in git-base-branch and only 1
is a fan-out; WORKTREE_TIMEOUT_MS has 16 users in worktree.test.cjs and 3 are;
PROBE_TIMEOUT_MS has several in three more files. In each the CALL SITE was
changed and the constant left alone, so no single-plumbing-call site silently
inherited a 60s bound. The one exception is hooks-opt-in.test.cjs, where
HOOK_TIMEOUT_MS has exactly one consumer -- spawnHook, the fan-out itself -- so
redefining it is identical in effect and reads better.
Only two of these sites have actually been observed failing. The rest cite that
shared class and those two run ids rather than inventing evidence of their own.
Co-authored-by: sim <sim@local>
1089 lines
50 KiB
JavaScript
1089 lines
50 KiB
JavaScript
/**
|
||
* 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');
|
||
|
||
// Session-IDENTITY vars. Blanked so a child cannot inherit the developer's
|
||
// terminal/agent session and key shared state off it.
|
||
const SESSION_IDENTITY_ENV_KEYS = [
|
||
'GSD_SESSION_KEY',
|
||
'CODEX_THREAD_ID',
|
||
'CLAUDE_SESSION_ID',
|
||
'CLAUDE_CODE_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',
|
||
];
|
||
|
||
// LAZY, and memoized. These live in the BUILT runtime lib, so requiring them at
|
||
// module scope made an unbuilt tree throw during `require('./helpers.cjs')` —
|
||
// before a single test() had registered — which turns one missing
|
||
// `npm run build:lib` into a whole-suite crash with no actionable message, in the
|
||
// file ~370 test files import. `npm test` builds via its pretest hook, so the
|
||
// shape that hits this is a direct `node --test` invocation.
|
||
//
|
||
// Deferring the require means only the tests that actually need the derived scrub
|
||
// set pay for the build, and they fail with a message that names the remedy.
|
||
let _builtLib = null;
|
||
function builtLib() {
|
||
if (_builtLib) return _builtLib;
|
||
try {
|
||
const { runtimes } = require('../gsd-core/bin/lib/capability-registry.cjs');
|
||
const {
|
||
NON_REGISTRY_CONFIG_HOME_DESCRIPTORS,
|
||
GSD_LOCATION_ENV_KEYS,
|
||
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||
_builtLib = { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS };
|
||
} catch (cause) {
|
||
throw new Error(
|
||
'tests/helpers.cjs derives the config-location scrub set from the built runtime '
|
||
+ 'lib (gsd-core/bin/lib), which is not present. Run `npm run build:lib` first — '
|
||
+ '`npm test` does this for you via its pretest script.',
|
||
{ cause },
|
||
);
|
||
}
|
||
return _builtLib;
|
||
}
|
||
|
||
// Config-location vars that are neither in the registry nor descriptor-shaped,
|
||
// each with its reader:
|
||
// GROK_AGENTS_HOME — hardcoded `grok` branch in getGlobalConfigDir (src/runtime-homes.cts)
|
||
// GSD_RUNTIME — selects WHICH runtime home resolves (src/model-resolver.cts)
|
||
// GSD_PROJECT — planningDir() project segment (src/planning-workspace.cts)
|
||
// GSD_WORKSTREAM — planningDir() workstream segment (src/planning-workspace.cts)
|
||
//
|
||
// #2665 round 3: this list shrinks as sources become enumerable, and that direction
|
||
// is the point. KIMI_SHARE_DIR was NOT added here — it now derives from
|
||
// NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, because hand-adding each var a reviewer
|
||
// names is precisely what reopened this bug three times.
|
||
const NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS = [
|
||
'GROK_AGENTS_HOME',
|
||
'GSD_RUNTIME',
|
||
'GSD_PROJECT',
|
||
'GSD_WORKSTREAM',
|
||
// #3245: host-session signals GSD now reads (host-runtime-detection.cts's
|
||
// detectHostRuntime / resolveReportedRuntime). Scrubbed for the same reason
|
||
// GSD_RUNTIME is — an ambiently-set CODEX_SANDBOX / (this repo's test suite
|
||
// running from inside a Codex session, or any host that happens to export
|
||
// these) would non-deterministically flip the detected runtime for every
|
||
// test that does not explicitly pass them. Tests that WANT them set still
|
||
// can, via the per-call env override, which is applied after this base and
|
||
// so continues to win.
|
||
'CODEX_SANDBOX',
|
||
'CODEX_SANDBOX_NETWORK_DISABLED',
|
||
];
|
||
|
||
// Write-escape PERMISSIONS — deliberately its own family, and deliberately NOT
|
||
// folded into any of the four rungs below.
|
||
//
|
||
// #2665 round 5: GSD_ALLOW_SYMLINKED_DEST is boolean and names no path, so it is
|
||
// not a config-location var by any honest reading. But install-engine.cts reads it
|
||
// env-first (`:214`) and threads it as `allowOptInFollow` into the symlink-escape
|
||
// guard at four call sites, each gating a write (`:361/:367`, `:416/:424`,
|
||
// `:785/:790`, `:927/:932`). That guard is what stops a write leaving the install
|
||
// root, so an ambient `=1` disarms it for the whole suite — the #2665 hazard
|
||
// exactly, arriving through a permission rather than a path.
|
||
//
|
||
// Blanking is fail-safe in the only direction that matters: '' is neither '1' nor
|
||
// 'true', so a blanked value makes the guard STRICTER, never looser. That asymmetry
|
||
// is why this can be scrubbed wholesale without reasoning about each call site.
|
||
const WRITE_ESCAPE_PERMISSION_ENV_KEYS = ['GSD_ALLOW_SYMLINKED_DEST'];
|
||
|
||
// Config-LOCATION vars — distinct in kind from the session-identity vars above:
|
||
// these decide WHERE a child writes, so leaving one ambient lets a test that
|
||
// sandboxes HOME still escape into the developer's real config dir.
|
||
//
|
||
// #2665: this list is DERIVED, not hand-maintained. A hand-written list is
|
||
// exactly what reopened this bug twice — it can only ever be as complete as the
|
||
// author's recall, and every resolver in `runtime-homes.cts` is env-FIRST, so a
|
||
// key missing here is a live escape hatch rather than a cosmetic gap. Sourcing
|
||
// it from the same registry the resolver reads makes the scrub list structurally
|
||
// incapable of being narrower than the surface it guards: adding a capability
|
||
// that declares a new configHome env var extends this set in the same commit.
|
||
let _configLocationEnvKeys = null;
|
||
function configLocationEnvKeys() {
|
||
if (_configLocationEnvKeys) return _configLocationEnvKeys;
|
||
const { runtimes, NON_REGISTRY_CONFIG_HOME_DESCRIPTORS, GSD_LOCATION_ENV_KEYS } = builtLib();
|
||
_configLocationEnvKeys = [
|
||
...new Set([
|
||
// 1. Every runtime descriptor the capability registry carries — including
|
||
// the nested skillsHome descriptor, which resolves independently of
|
||
// configHome (resolveSkillsBaseFromDescriptor) and can carry its own
|
||
// env array. Inert today (only kilo declares skillsHome, with env: []),
|
||
// but walking configHome.env alone is the identical gap-shape this PR
|
||
// closed twice already, one field over. (#2665 round 4)
|
||
...Object.values(runtimes).flatMap((r) => r?.runtime?.configHome?.env ?? []),
|
||
...Object.values(runtimes).flatMap(
|
||
(r) => r?.runtime?.configHome?.skillsHome?.env ?? [],
|
||
),
|
||
// 2. Descriptor-shaped config homes resolved OUTSIDE the registry (kimi's
|
||
// native config.toml home via KIMI_SHARE_DIR). Derived, not hand-listed.
|
||
// Same skillsHome walk as rung 1 — a descriptor is a descriptor.
|
||
...NON_REGISTRY_CONFIG_HOME_DESCRIPTORS.flatMap((d) => [
|
||
...(d?.env ?? []),
|
||
...(d?.skillsHome?.env ?? []),
|
||
]),
|
||
// 3. GSD's OWN location vars — a different family: they decide where GSD keeps
|
||
// user-owned state ($GSD_HOME/.gsd/), not where a runtime keeps its config.
|
||
...GSD_LOCATION_ENV_KEYS,
|
||
// 4. The residue that is neither registry-carried nor descriptor-shaped.
|
||
...NON_REGISTRY_CONFIG_LOCATION_ENV_KEYS,
|
||
// 5. Write-escape permissions — NOT locations. Same mechanism because the
|
||
// hazard is identical (ambient env lets a suite write outside the sandbox);
|
||
// named separately above so the list does not misdescribe what they are.
|
||
...WRITE_ESCAPE_PERMISSION_ENV_KEYS,
|
||
]),
|
||
].sort();
|
||
return _configLocationEnvKeys;
|
||
}
|
||
|
||
let _testEnvBase = null;
|
||
function testEnvBase() {
|
||
if (_testEnvBase) return _testEnvBase;
|
||
_testEnvBase = Object.fromEntries(
|
||
[...SESSION_IDENTITY_ENV_KEYS, ...configLocationEnvKeys()].map((k) => [k, '']),
|
||
);
|
||
return _testEnvBase;
|
||
}
|
||
|
||
/**
|
||
* Save + clear every config-LOCATION env var on THIS process; returns a restorer.
|
||
*
|
||
* #2665: TEST_ENV_BASE only reaches CHILD processes. A test that calls the real
|
||
* installer IN-PROCESS — `install(true, 'claude')` — resolves through the same
|
||
* env-first `getGlobalConfigDir`, so an ambient CLAUDE_CONFIG_DIR beats a
|
||
* sandboxed `process.env.HOME` and a complete global install (agents/, commands/,
|
||
* skills/, gsd-core/, manifest, settings) lands in the developer's live config
|
||
* dir. No child-env scrub can reach that call; only clearing the parent's env can.
|
||
*
|
||
* Pair with a HOME sandbox, not instead of one: HOME covers the home-derived
|
||
* fallback, this covers the env-first branch that overrides it.
|
||
*
|
||
* @returns {() => void} restorer — call in afterEach to put the env back exactly
|
||
* as it was (deleting keys that were previously unset, rather than setting '').
|
||
*/
|
||
function scrubConfigLocationEnv() {
|
||
const saved = {};
|
||
const keys = configLocationEnvKeys();
|
||
for (const key of keys) {
|
||
saved[key] = process.env[key];
|
||
delete process.env[key];
|
||
}
|
||
return function restoreConfigLocationEnv() {
|
||
for (const key of keys) {
|
||
if (saved[key] === undefined) delete process.env[key];
|
||
else process.env[key] = saved[key];
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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, ...testEnvBase(), ...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_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];
|
||
}
|
||
|
||
/**
|
||
* Save + clear GSD_WORKSTREAM and GSD_PROJECT on process.env, paired with
|
||
* restoreWorkstreamEnv(). planningDir() reads both directly from
|
||
* process.env when its params are omitted, so a test asserting
|
||
* workstream/project-scoped behavior must isolate them from ambient shell
|
||
* state (and from whatever an earlier test in the same process left behind).
|
||
*
|
||
* Previously duplicated as a local isolateWorkstreamEnv()/restoreWorkstreamEnv()
|
||
* pair in tests/phase-locator.test.cjs, and as the GSD_WORKSTREAM/GSD_PROJECT
|
||
* slice of tests/model-resolver.test.cjs's broader isolateHome()/restoreHome()
|
||
* (which still isolates HOME/USERPROFILE/GSD_HOME/GSD_RUNTIME locally — that
|
||
* part is genuinely specific to model-resolver's tests and stays there).
|
||
*
|
||
* Module-level save slot (not a returned snapshot) to match the exact
|
||
* no-arg isolate()/restore() call shape both prior local copies used.
|
||
*/
|
||
let _origGsdWorkstream;
|
||
let _origGsdProject;
|
||
|
||
function isolateWorkstreamEnv() {
|
||
_origGsdWorkstream = process.env.GSD_WORKSTREAM;
|
||
_origGsdProject = process.env.GSD_PROJECT;
|
||
delete process.env.GSD_WORKSTREAM;
|
||
delete process.env.GSD_PROJECT;
|
||
}
|
||
|
||
function restoreWorkstreamEnv() {
|
||
if (_origGsdWorkstream === undefined) delete process.env.GSD_WORKSTREAM;
|
||
else process.env.GSD_WORKSTREAM = _origGsdWorkstream;
|
||
if (_origGsdProject === undefined) delete process.env.GSD_PROJECT;
|
||
else process.env.GSD_PROJECT = _origGsdProject;
|
||
}
|
||
|
||
/**
|
||
* #3156: env for a RAW installer spawn — one that bypasses runGsdTools and so
|
||
* never receives TEST_ENV_BASE on its own.
|
||
*
|
||
* Blanking config-LOCATION vars is necessary but NOT sufficient here.
|
||
* bin/install.js writes GSD's own user-owned store through os.homedir()
|
||
* DIRECTLY (writeNonClaudeDefaults -> <home>/.gsd/defaults.json, #2834), and
|
||
* os.homedir() consults no GSD variable at all — so nothing in
|
||
* CONFIG_LOCATION_ENV_KEYS can reach it, and blanking GSD_HOME does not reach
|
||
* it either, because a blank GSD_HOME falls back to exactly that homedir().
|
||
* Only a sandboxed HOME/USERPROFILE contains it.
|
||
*
|
||
* HOME stays deliberately OUT of TEST_ENV_BASE — blanking it would break far
|
||
* more than it fixed — so it is sandboxed per spawn instead, which is the
|
||
* discipline the suite already applies by hand elsewhere. USERPROFILE is set
|
||
* with it because os.homedir() reads that one on Windows.
|
||
*
|
||
* The sandbox home is per-process and removed on exit, so a caller gets
|
||
* containment without having to own a lifecycle.
|
||
*
|
||
* SCOPE, stated because it is a real residual rather than an oversight: this is
|
||
* one home per test-FILE process, not one per spawn. Two installer spawns in the
|
||
* same file therefore share `.gsd` state, so a prior non-Claude install can be
|
||
* observed by a later spawn. That is strictly better than the status quo it
|
||
* replaces -- which shared the developer's REAL home, and all of its state --
|
||
* and it closes the leak this helper exists for; it does not claim isolation
|
||
* BETWEEN spawns. A test needing that passes its own { HOME, USERPROFILE }.
|
||
*/
|
||
let installSpawnHomeDir = null;
|
||
function installSpawnHome() {
|
||
if (installSpawnHomeDir === null) {
|
||
installSpawnHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-install-home-'));
|
||
process.on('exit', () => {
|
||
try { fs.rmSync(installSpawnHomeDir, { recursive: true, force: true }); } catch { /* best effort */ }
|
||
});
|
||
}
|
||
return installSpawnHomeDir;
|
||
}
|
||
|
||
function installSpawnEnv(overrides = {}) {
|
||
const home = installSpawnHome();
|
||
// #3712 — carry the sandbox marker too, not just HOME. The guard falls back to
|
||
// the marker on hosts with no readable passwd entry (some CI images) and
|
||
// otherwise REFUSES. A spawned installer inherits NODE_TEST_CONTEXT and has a
|
||
// legitimately redirected HOME, so without this it is refused on exactly the
|
||
// environment the fallback exists to serve. The name is the same constant
|
||
// sandboxHome() writes; see the note there on why it is a bare string.
|
||
const env = {
|
||
...process.env,
|
||
...testEnvBase(),
|
||
HOME: home,
|
||
USERPROFILE: home,
|
||
[TEST_HOME_SANDBOX_MARKER]: home,
|
||
...overrides,
|
||
};
|
||
// The marker attests to the home ACTUALLY in effect, so it has to follow an
|
||
// overridden HOME rather than keep naming this helper's default one. Spreading
|
||
// `overrides` last is deliberate (an explicit HOME must win — see the docblock's
|
||
// "A test needing that passes its own { HOME, USERPROFILE }"), but it left the
|
||
// marker stale: a caller supplying its own HOME got HOME=<theirs> and
|
||
// marker=<helper default>. On a passwd-less host the guard compares the two and
|
||
// REFUSES a legitimately sandboxed spawn — tests/install.test.cjs:7143 and
|
||
// install-shared.cjs's own runInstaller both take that path. An explicitly
|
||
// supplied marker still wins over both. Reported in Codex review of #3725.
|
||
if (!(TEST_HOME_SANDBOX_MARKER in overrides)) env[TEST_HOME_SANDBOX_MARKER] = env.HOME;
|
||
return env;
|
||
}
|
||
|
||
/**
|
||
* #3712 — sandbox HOME/USERPROFILE for the duration of ONE test.
|
||
*
|
||
* The spawn-side helpers above (#3156) cover CHILD processes only. A test that
|
||
* calls the installer IN-PROCESS gets no protection from them, and a runtime kind
|
||
* may declare a global `home` override that resolves from `os.homedir()` rather
|
||
* than from the sandboxed configDir — codex's skills kind (`.agents`, ADR-1239 /
|
||
* #2088) is the live case. Without this, such a call writes to, and prunes
|
||
* `gsd-*` entries from, the developer's REAL ~/.agents/skills.
|
||
*
|
||
* Promoted here from the identical private copies in executed-plan.test.cjs and
|
||
* install-runtime-artifacts.test.cjs so new in-process callers have one obvious
|
||
* helper to reach for instead of re-deriving it (or forgetting it).
|
||
*
|
||
* Pass the test's own temp configDir as `dir` where possible: codex's skills dir
|
||
* then resolves to `<configDir>/.agents/skills`, keeping every artifact the call
|
||
* writes inside the directory the test already cleans up.
|
||
*
|
||
* @param {{ after: (fn: () => void) => void }} t - node:test context.
|
||
* @param {string} dir - directory to use as HOME for the duration of the test.
|
||
*/
|
||
// #3712: the marker NAME is a constant, duplicated here deliberately rather than
|
||
// required from the compiled guard. helpers.cjs is imported by ~370 test files and
|
||
// documents (see builtLib above) that it must NOT load gsd-core/bin/lib at module
|
||
// scope — an unbuilt tree would then fail on import alone, turning a missing
|
||
// `npm run build:lib` into a whole-suite crash. A lazy require inside sandboxHome
|
||
// would satisfy that too, but a bare string needs no build at all. The pairing is
|
||
// pinned by a test so the two cannot drift.
|
||
const TEST_HOME_SANDBOX_MARKER = 'GSD_TEST_HOME_SANDBOX';
|
||
|
||
function sandboxHome(t, dir) {
|
||
const savedHome = process.env.HOME;
|
||
const savedUserProfile = process.env.USERPROFILE;
|
||
const savedMarker = process.env[TEST_HOME_SANDBOX_MARKER];
|
||
process.env.HOME = dir;
|
||
process.env.USERPROFILE = dir;
|
||
// Records WHICH directory this call sandboxed to. src/real-home-guard.cts fails
|
||
// CLOSED when it cannot read a passwd entry to compare HOME against (some CI
|
||
// images), and consults this only in that branch, accepting it only when it
|
||
// names the home actually in effect — so a stale marker cannot vouch for a
|
||
// later, un-sandboxed call.
|
||
process.env[TEST_HOME_SANDBOX_MARKER] = dir;
|
||
t.after(() => {
|
||
if (savedHome === undefined) delete process.env.HOME;
|
||
else process.env.HOME = savedHome;
|
||
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
||
else process.env.USERPROFILE = savedUserProfile;
|
||
if (savedMarker === undefined) delete process.env[TEST_HOME_SANDBOX_MARKER];
|
||
else process.env[TEST_HOME_SANDBOX_MARKER] = savedMarker;
|
||
});
|
||
}
|
||
|
||
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, isolateWorkstreamEnv, restoreWorkstreamEnv, TOOLS_PATH, SESSION_IDENTITY_ENV_KEYS, scrubConfigLocationEnv, installSpawnEnv, installSpawnHome, sandboxHome, TEST_HOME_SANDBOX_MARKER };
|
||
|
||
// Lazy, for the reason builtLib() is lazy: reading either of these is what
|
||
// forces the built-lib require, so a test file that needs neither can still
|
||
// import this helper on an unbuilt tree. Enumerable, so destructuring and
|
||
// Object.keys() behave exactly as they did when these were plain properties.
|
||
Object.defineProperties(module.exports, {
|
||
TEST_ENV_BASE: { enumerable: true, get: testEnvBase },
|
||
CONFIG_LOCATION_ENV_KEYS: { enumerable: true, get: configLocationEnvKeys },
|
||
});
|