fix: route check-env.cjs's npm-version check through the canonical execNpm seam

Per /research + /diagnose direction: the timeout fix landed earlier this
session correctly diagnosed the failure (a real spawnSync timeout under
Windows CI contention, not npm being absent) but it recurred on the very
next push -- same chunk, same ~51-file load. Rather than raise the
hand-rolled 10s timeout myself (CLAUDE.md's own rule: fix the cost, not
the tolerance, and never touch a timeout without explicit instruction),
investigated the repo's own precedent first.

Found: this repo already has a canonical OS-shell-projection seam for
exactly this (src/shell-command-projection.cts's execNpm), already used
by dozens of other scripts/*.cjs files (require('../gsd-core/bin/lib/...')
is an extremely well-established pattern), with:
  - the same npm.cmd/shell:true Windows handling check-env.cjs was
    hand-rolling, but centralized;
  - a 15s default timeout (vs. check-env.cjs's 10s) -- not invented here,
    an EXISTING value already governing npm subprocess calls elsewhere;
  - isSpawnTimeout / result.timedOut, the canonical cross-platform timeout
    predicate (error.code === 'ETIMEDOUT'), whose own docstring explicitly
    warns that checking signal === 'SIGTERM' (what my first fix did) is
    "platform-fragile" with a Windows-specific false-negative risk -- the
    exact platform this bug lives on.

check-env.cjs's npm-version check now calls execNpm(['--version']) instead
of hand-rolling spawnSync + npmCmd + shell:true, and
describeNpmVersionCheckFailure now operates on execNpm's SpawnResultOutput
shape (using timedOut, not signal) rather than a raw spawnSync result.
This is a genuine architectural fix, not just a bigger number: it removes
a duplicate, slightly-divergent re-implementation of an existing seam and
inherits whatever that seam's timeout/handling becomes in the future.

Manually verified end-to-end (npm run check:env against the real
environment) and re-verified describeNpmVersionCheckFailure's branches
directly against execNpm's actual return shape before wiring the test
file, since this repo blocks local node --test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-09 06:59:21 -04:00
parent 8c3ef049b2
commit 1cd17cc3d6
4 changed files with 72 additions and 58 deletions

View File

@@ -6,11 +6,16 @@
* no-defer policy): scripts/check-env.cjs's npm-version check used to
* collapse every spawnSync failure mode into one message, "npm binary not
* found on PATH" -- including a TIMEOUT under CI load, which looks
* identical to a genuine ENOENT (status stays null, stdout stays empty
* either way). describeNpmVersionCheckFailure is the extracted, pure
* reason-selection logic; kept in scripts/lib/ (not scripts/check-env.cjs
* itself, which runs its CLI unconditionally on require) so it can be
* required directly here without triggering a real environment check.
* identical to a genuine ENOENT (exitCode stays non-zero, stdout stays
* empty either way). Confirmed live, twice, on real Windows CI: the check
* now correctly reports "timed out under CI load" for exactly that case.
*
* describeNpmVersionCheckFailure is the extracted, pure reason-selection
* logic operating on execNpm's SpawnResultOutput shape (the canonical
* OS-shell-projection seam, src/shell-command-projection.cts). Kept in
* scripts/lib/ (not scripts/check-env.cjs itself, which runs its CLI
* unconditionally on require) so it can be required directly here without
* triggering a real environment check.
*/
const { describe, test } = require('node:test');
@@ -20,40 +25,40 @@ const { describeNpmVersionCheckFailure } = require('../scripts/lib/npm-version-c
describe('describeNpmVersionCheckFailure (#4460)', () => {
test('a genuine ENOENT (npm truly absent) reports the original message', () => {
const spawnResult = { status: null, stdout: '', signal: null, error: Object.assign(new Error('spawnSync npm.cmd ENOENT'), { code: 'ENOENT' }) };
const result = { exitCode: 127, stdout: '', stderr: 'npm: not found', signal: null, error: Object.assign(new Error('spawnSync npm ENOENT'), { code: 'ENOENT' }), timedOut: false };
assert.equal(
describeNpmVersionCheckFailure(spawnResult, null, 10_000),
describeNpmVersionCheckFailure(result),
'npm binary not found on PATH',
);
});
test('a signal-killed spawn (the timeout case) is reported as a timeout, not a missing binary', () => {
const spawnResult = { status: null, stdout: '', signal: 'SIGTERM', error: null };
const reason = describeNpmVersionCheckFailure(spawnResult, null, 10_000);
assert.match(reason, /killed \(signal SIGTERM\)/);
assert.match(reason, /10000ms timeout under CI load/);
test('a timed-out spawn is reported as a timeout, not a missing binary', () => {
const result = { exitCode: 1, stdout: '', stderr: '', signal: 'SIGTERM', error: Object.assign(new Error('ETIMEDOUT'), { code: 'ETIMEDOUT' }), timedOut: true };
const reason = describeNpmVersionCheckFailure(result);
assert.match(reason, /timed out under CI load/);
assert.doesNotMatch(reason, /^npm binary not found on PATH$/);
});
test('a non-zero exit with no stdout is reported with the actual exit code', () => {
const spawnResult = { status: 1, stdout: '', signal: null, error: null };
test('timedOut is authoritative even without an ENOENT-shaped error object', () => {
// Mirrors what execNpm/_spawnResult actually produces for a timeout:
// error is set (spawnSync always populates it when `timeout` fires),
// but its code is ETIMEDOUT, not ENOENT -- timedOut is what matters.
const result = { exitCode: 1, stdout: '', stderr: '', signal: 'SIGTERM', error: null, timedOut: true };
assert.match(describeNpmVersionCheckFailure(result), /timed out under CI load/);
});
test('a non-zero exit with no stdout and no timeout is reported with the actual exit code', () => {
const result = { exitCode: 1, stdout: '', stderr: '', signal: null, error: null, timedOut: false };
assert.equal(
describeNpmVersionCheckFailure(spawnResult, null, 10_000),
describeNpmVersionCheckFailure(result),
'npm --version exited 1 with no usable output',
);
});
test('spawnSync itself throwing (not just returning a failure result) is reported with the thrown message', () => {
const thrown = new Error('EACCES: permission denied');
test('exitCode 0 with no stdout (defensive default) falls back to the original message', () => {
const result = { exitCode: 0, stdout: '', stderr: '', signal: null, error: null, timedOut: false };
assert.equal(
describeNpmVersionCheckFailure(null, thrown, 10_000),
'npm --version could not be spawned: EACCES: permission denied',
);
});
test('no spawn result and no thrown error (defensive default) falls back to the original message', () => {
assert.equal(
describeNpmVersionCheckFailure(null, null, 10_000),
describeNpmVersionCheckFailure(result),
'npm binary not found on PATH',
);
});