* refactor(#3071): normalize execGit's call and result shape, unify ExecGitFn ExecGitFn was declared four times. Three were hand-copies of one signature and two of those were wrong: they typed exitCode as number|null when _spawnResult returns `result.status ?? 1` and can never yield null, weakened signal from NodeJS.Signals to string, and widened error from Error to unknown. Only verification.cts got it right, via `typeof execGit`. The root cause was a missing export: SpawnResultOutput was declared without `export`, so no other module could name the return type of execGit. Three authors independently hand-copied it instead. Exported now. Normalizing the type alone would have left the pressure that caused the divergence, so the function is normalized on both sides. It now ACCEPTS every call its consumers make — worktree-safety's declaration could not express an env-carrying call at all — and RETURNS every result code they need: timedOut moves into _spawnResult, so execGit, execNpm and execTool all carry it and the one extension that justified a separate type disappears. All four sites are now `typeof execGit` with nothing left to restate. timedOut reuses the existing isSpawnTimeout predicate introduced by #3050 rather than re-deriving it. That predicate checks error.code === 'ETIMEDOUT' only; the signal === 'SIGTERM' conjunct was deliberately dropped there because Windows does not reliably report SIGTERM and requiring it risks a false negative. There is no false-positive risk, and a test proves it: an externally-delivered SIGTERM leaves error null, so it is still not reported as a timeout. No dead null-checks surfaced. Every exitCode comparison in the two affected modules is === 0, !== 0 or === 128 — never a null guard — so the nullable declaration had never been written against. Closes #3071 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3056): add the in-process fault-injection adapter Adds tests/helpers/faulty-deps.cjs — makeFaultyGit() and withFaultyFs() — so a module's error branch can be driven deterministically and its degraded verdict asserted, instead of a counter-test that only proves the call did not throw. makeFaultyGit returns a value structurally assignable to `typeof execGit`, so one stub satisfies all four seams that the #3071 normalization collapsed into that single shape. A parity test drives the same stub through a real injectable entry point in each of worktree-safety, git-base-branch, worktree-base-ref and verification; it fails the moment any of them re-grows its own shape. Faults are scoped rather than global — by argv predicate and by call ordinal — because a fault adapter that faults everything looks like it works and proves nothing, and because verification.cts's two-call error handling needs to fault the second call only. Invocations are recorded so a test can assert an exact call count. The timeout fault carries error.code === 'ETIMEDOUT', and a test asserts the real isSpawnTimeout predicate matches it, so the fixture cannot drift from the production definition of a timeout. A companion test asserts an externally delivered SIGTERM with a null error is still NOT reported as a timeout — the false-positive guard for #3050's dropped conjunct. withFaultyFs restores in a finally so a throwing body still restores, patches only the named methods, and nests without clobbering an outer saved original. It never uses chmod: that no-ops under root, so the test would pass with zero coverage in root Docker/CI. The adapter is in-process via deps only. The Phase 1 process seam is documented as deliberately not a fault-injection surface — it cannot distinguish an injected timeout from a genuine bench OOM and would retry it. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3071): add changeset fragment for the execGit normalization Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3071): route the last two timeout checks through the shared predicate An isolated review found this branch had normalised the timeout verdict but left two callers still hand-rolling the fragile version of it. check-command-router's runBoundedShell computed `timedOut: r.signal === 'SIGTERM'` while the correctly-derived `r.timedOut` sat on the same result object. graphify's execGraphify branched on `result.signal === 'SIGTERM'`, with a comment asserting the very premise isSpawnTimeout exists to reject. Both fail in both directions. On Windows a genuine timeout is not reliably reported as SIGTERM, so the guard silently fails to fire — the false negative #3050 was raised for. And an externally-delivered SIGTERM is not a timeout at all, so the check also fires when it should not; isSpawnTimeout avoids that because `error` is null in that case and it keys on error.code. Both now read the derived `timedOut`, and graphify's comment states the actual rule instead of the fragile assumption. Also replaces a vacuous test: "execGitDefault now accepts env" never called execGitDefault (it is unexported), called execGit — whose signature already accepted env before this branch — and asserted only that exitCode was a number, which would pass whether or not the change under test existed. It now proves env reaches the child by asserting `git var GIT_EDITOR` returns the injected sentinel. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#3071): make the graphify timeout fixture faithful to a real timeout The remote matrix failed on both Linux lanes: graphify's "returns exitCode 124 on timeout" got 1 instead of 124. The fixture was wrong, not the production change. It stubbed spawnSync as { status: null, signal: 'SIGTERM', error: undefined }. That is not a timeout. A real spawnSync timeout also sets error.code 'ETIMEDOUT'; a SIGTERM with no error is an externally delivered signal — a kill. The old `result.signal === 'SIGTERM'` check accepted it as a timeout, which is the false positive the shared predicate exists to reject, so this test was locking that bug in rather than guarding against it. The fixture now carries a real ETIMEDOUT error and all three original assertions pass unchanged. A counter-test is added alongside it: an externally delivered SIGTERM with no error must NOT be reported as a timeout. That is the assertion whose absence let the false positive live. Swept every SIGTERM/SIGKILL stub under tests/ for the same unfaithful shape. No other instance: the worktree-safety, worktree-base-ref and commit-staging fixtures already set ETIMEDOUT, and the remaining hits are either deliberate external-kill tests or feed code that never consults timedOut. Two sites keep their own signal check deliberately and are NOT changed: capability-source.cts:1301,1386 fail closed on ANY abnormal termination, which is correct — reading timedOut there would stop it failing closed on a kill and let it parse stdout from a killed process. Their reason strings, and check-latest-version.cjs:115, label any signal as "timed out", which is imprecise wording over a correct verdict, not a silent failure. Refs #3051 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3071): backfill changeset pr number to 3077 --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
200 lines
7.1 KiB
JavaScript
200 lines
7.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* In-process fault-injection adapter (issue #3056, Phase 2 of epic #3051).
|
|
*
|
|
* `makeFaultyGit()` and `withFaultyFs()` inject faults ONLY via the `deps`
|
|
* parameters that production seams already accept (`execGit`-shaped
|
|
* functions, `fs` module methods). This is in-process injection ONLY.
|
|
*
|
|
* The Phase 1 subprocess-dispatch seam (`tests/helpers/process-seam.cjs`,
|
|
* issue #3055) is DELIBERATELY NOT a fault-injection surface and must never
|
|
* be used to simulate a timeout, an ENOENT, or any other fault: the
|
|
* subprocess path has no way to distinguish an injected failure from a
|
|
* genuine bench OOM or a real flaky CI host, and — per the process seam's
|
|
* own retry policy — it would retry an injected timeout exactly as if it
|
|
* were a real one, silently corrupting the test's intent (design matrix
|
|
* row 24). Fault injection belongs at the `deps` seam, in-process, where the
|
|
* test controls exactly what is faulted and exactly once.
|
|
*
|
|
* `makeFaultyGit(options)` returns a function structurally assignable to
|
|
* `typeof execGit` from `shell-command-projection.cjs` — i.e. it satisfies
|
|
* every `ExecGitFn` declaration in the tree (worktree-safety.cts,
|
|
* git-base-branch.cts, worktree-base-ref.cts, verification.cts) because all
|
|
* four are structural subsets/supersets of that one shape (#3071).
|
|
*
|
|
* `withFaultyFs(patches, body)` monkeypatches named `fs` methods and
|
|
* guarantees restoration in a `finally` — this file is a standalone helper
|
|
* with no test context, which is the ONLY place CONTRIBUTING.md ("Setup and
|
|
* Cleanup") permits a `try/finally`: "try/finally is only permitted inside
|
|
* standalone utility or helper functions that have no access to test
|
|
* context." Test bodies must never use try/finally directly.
|
|
*
|
|
* NEVER use chmod to simulate an fs fault. `chmod 0o000` no-ops under root
|
|
* (root bypasses mode bits), so a chmod-based fault silently passes with
|
|
* zero coverage in root Docker/CI — see CONTRIBUTING.md and the Phase 2
|
|
* design doc's "Not-corruption / negative space" section.
|
|
*/
|
|
|
|
const DEFAULT_PASSTHROUGH_RESULT = Object.freeze({
|
|
exitCode: 0,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
error: null,
|
|
timedOut: false,
|
|
});
|
|
|
|
/**
|
|
* Build the result shape for a single fault `kind`. Mirrors the shapes
|
|
* `_spawnResult` in shell-command-projection.cts produces, so a faulted
|
|
* result is indistinguishable in shape from a real one.
|
|
*
|
|
* @param {{kind: string, exitCode?: number, stderr?: string}} fault
|
|
* @returns {{exitCode:number, stdout:string, stderr:string, signal:(string|null), error:(Error|null), timedOut:boolean}}
|
|
*/
|
|
function _buildFaultResult(fault) {
|
|
switch (fault.kind) {
|
|
case 'timeout': {
|
|
const error = Object.assign(new Error('timed out'), { code: 'ETIMEDOUT' });
|
|
return {
|
|
exitCode: 1,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: 'SIGTERM',
|
|
error,
|
|
timedOut: true,
|
|
};
|
|
}
|
|
case 'exit': {
|
|
return {
|
|
exitCode: typeof fault.exitCode === 'number' ? fault.exitCode : 1,
|
|
stdout: '',
|
|
stderr: fault.stderr || '',
|
|
signal: null,
|
|
error: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
case 'spawnFail': {
|
|
const error = Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' });
|
|
return {
|
|
exitCode: 127,
|
|
stdout: '',
|
|
stderr: '',
|
|
signal: null,
|
|
error,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(`makeFaultyGit: unknown fault kind "${fault.kind}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return true when `fault.when` matches this invocation's argv.
|
|
* `when` may be an argv predicate `(args) => boolean` or an array treated as
|
|
* a prefix match against `args`.
|
|
*
|
|
* @param {*} when
|
|
* @param {string[]} args
|
|
* @returns {boolean}
|
|
*/
|
|
function _whenMatches(when, args) {
|
|
if (when === undefined) return true;
|
|
if (typeof when === 'function') return Boolean(when(args));
|
|
if (Array.isArray(when)) {
|
|
if (when.length > args.length) return false;
|
|
return when.every((token, i) => args[i] === token);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build a fault-injecting stand-in for `execGit` (and, by structural typing,
|
|
* for every `ExecGitFn` site in the tree — see module doc). Faults are
|
|
* configured, never global: a call only faults when it matches every
|
|
* constraint on a configured fault; every other call delegates to
|
|
* `options.passthrough` (default: a benign zero-exit result).
|
|
*
|
|
* @param {object} [options]
|
|
* @param {Array<{
|
|
* kind: 'timeout'|'exit'|'spawnFail',
|
|
* exitCode?: number,
|
|
* stderr?: string,
|
|
* when?: ((args: string[]) => boolean) | string[],
|
|
* onCall?: number,
|
|
* }>} [options.faults] - fault configurations, evaluated in order; the first
|
|
* whose `when`/`onCall` match the current call wins.
|
|
* @param {(args: string[], opts?: object) => object} [options.passthrough] -
|
|
* delegate for calls that do not match any configured fault. Defaults to a
|
|
* benign `{exitCode:0, stdout:'', stderr:'', signal:null, error:null, timedOut:false}`.
|
|
* @returns {((args: string[], opts?: object) => object) & { calls: Array<{args:string[], opts:object}> }}
|
|
*/
|
|
function makeFaultyGit(options) {
|
|
const opts = options || {};
|
|
const faults = Array.isArray(opts.faults) ? opts.faults : [];
|
|
const passthrough = typeof opts.passthrough === 'function'
|
|
? opts.passthrough
|
|
: () => ({ ...DEFAULT_PASSTHROUGH_RESULT });
|
|
|
|
const calls = [];
|
|
|
|
function faultyGit(args, callOpts) {
|
|
const recordedArgs = Array.isArray(args) ? args.slice() : args;
|
|
const recordedOpts = callOpts || {};
|
|
calls.push({ args: recordedArgs, opts: recordedOpts });
|
|
const callNumber = calls.length;
|
|
|
|
for (const fault of faults) {
|
|
if (fault.onCall !== undefined && fault.onCall !== callNumber) continue;
|
|
if (!_whenMatches(fault.when, recordedArgs)) continue;
|
|
return _buildFaultResult(fault);
|
|
}
|
|
|
|
return passthrough(recordedArgs, recordedOpts);
|
|
}
|
|
|
|
faultyGit.calls = calls;
|
|
return faultyGit;
|
|
}
|
|
|
|
/**
|
|
* Monkeypatch named `fs` methods for the duration of `body()`, guaranteeing
|
|
* restoration via `finally` even when `body` throws. Nestable: each call
|
|
* saves its own originals into a local (not module-level) map, so an inner
|
|
* `withFaultyFs` restoring first does not clobber an outer call's saved
|
|
* original.
|
|
*
|
|
* This is the ONLY place in the test tree permitted to use try/finally —
|
|
* see the module doc and CONTRIBUTING.md "Setup and Cleanup": "try/finally
|
|
* is only permitted inside standalone utility or helper functions that have
|
|
* no access to test context." This helper has no test context; it is a
|
|
* plain function.
|
|
*
|
|
* @template T
|
|
* @param {Record<string, (...args: any[]) => any>} patches - maps an `fs`
|
|
* method name to its replacement (typically a function that throws).
|
|
* @param {() => T} body
|
|
* @returns {T}
|
|
*/
|
|
function withFaultyFs(patches, body) {
|
|
const fs = require('node:fs');
|
|
const methodNames = Object.keys(patches);
|
|
const originals = new Map();
|
|
for (const name of methodNames) {
|
|
originals.set(name, fs[name]);
|
|
fs[name] = patches[name];
|
|
}
|
|
try {
|
|
return body();
|
|
} finally {
|
|
for (const name of methodNames) {
|
|
fs[name] = originals.get(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = { makeFaultyGit, withFaultyFs };
|