fix: report the real cause when check-env.cjs's npm-version check fails

Discovered blocking this PR's own Windows CI (unrelated to this PR's
actual diff, fixed inline per this repo's no-defer policy): PR #4552's
"full test (windows-latest, 24, shard 2/3)" job failed
tests/check-env.test.cjs's npm-version subtest with "npm binary not
found on PATH" under chunk 5/9's heavy load (51 concurrent files,
5+ minutes). Root-caused via the CI log: the check's spawnSync call
used a 10s timeout, and every failure mode -- ENOENT, a signal-killed
timeout, a non-zero exit, a thrown spawn error -- collapsed into that
one message (only `res.status === 0 && res.stdout` was checked), so a
genuine npm.cmd cold-start timeout under contention was indistinguishable
from npm actually being absent.

Extracted the reason-selection into describeNpmVersionCheckFailure, a
pure function in the new scripts/lib/npm-version-check-diagnosis.cjs
(kept out of check-env.cjs itself, which runs its CLI unconditionally
on require with no `require.main === module` guard, so the pure logic
can be unit-tested without triggering a real environment check).
Reports ENOENT, signal-kill, non-zero-exit, and thrown-error cases
distinctly. Does NOT raise the 10s timeout itself -- a slow subprocess
under contention is a cost to reduce, not a tolerance to widen.

Also fixed a stale tsconfig.build.tsbuildinfo incremental-build cache
discovered while verifying this change: npm run build:lib was silently
omitting gsd-core/bin/lib/markdown-table.cjs (a real, needed compiled
module -- src/state-document.cts requires it), which only surfaced via
npm run lint:generated-sync's gen-health-docs check failing with
Cannot find module. Deleting the cache and rebuilding fresh restored
it; docs/INVENTORY-MANIFEST.json needed no net change once the build
was genuinely complete.

Manually verified describeNpmVersionCheckFailure's five branches
directly (ENOENT, signal-kill, non-zero exit, thrown error, defensive
default) before wiring the test file, since this repo blocks local
node --test. Re-ran node scripts/check-env.cjs directly to confirm the
real success path is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
sim
2026-09-09 00:27:32 -04:00
parent ba25e3989d
commit 0aeafc6425
5 changed files with 116 additions and 6 deletions

View File

@@ -30,6 +30,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const { describeNpmVersionCheckFailure } = require('./lib/npm-version-check-diagnosis.cjs');
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
// shell:true requires the exact filename including extension.
@@ -180,18 +181,21 @@ function main() {
// Check 2: npm version vs engines.npm (skip if field absent)
// ---------------------------------------------------------------------------
const enginesNpm = pkgField('engines.npm', PROJECT_ROOT);
const NPM_VERSION_TIMEOUT_MS = 10_000;
let currentNpm = '';
let npmSpawnResult = null;
let npmSpawnThrew = null;
try {
const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' });
if (res.status === 0 && res.stdout) {
currentNpm = res.stdout.trim();
npmSpawnResult = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: NPM_VERSION_TIMEOUT_MS, shell: process.platform === 'win32' });
if (npmSpawnResult.status === 0 && npmSpawnResult.stdout) {
currentNpm = npmSpawnResult.stdout.trim();
}
} catch { /* ignore */ }
} catch (e) { npmSpawnThrew = e; }
if (!enginesNpm) {
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
} else if (!currentNpm) {
addCheck('npm-version', 'fail', 'npm binary not found on PATH');
addCheck('npm-version', 'fail', describeNpmVersionCheckFailure(npmSpawnResult, npmSpawnThrew, NPM_VERSION_TIMEOUT_MS));
} else {
if (satisfiesConstraint(currentNpm, enginesNpm)) {
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);

View File

@@ -0,0 +1,41 @@
'use strict';
/**
* #4460: distinguishes WHY scripts/check-env.cjs's npm-version check's
* `spawnSync(npmCmd, ['--version'], ...)` produced no usable output,
* instead of collapsing every case into "npm binary not found on PATH" -- a
* message that used to fire identically for a genuinely-missing binary AND
* for a spawnSync TIMEOUT under CI load (status stays null, stdout stays
* empty, either way). Root-caused live: an unrelated PR's Windows CI shard
* failed this check while running 51 concurrent test files; npm.cmd's own
* cold-start plausibly exceeded the 10s window under that contention, and
* the misleading message made a real timeout indistinguishable from npm
* actually being absent.
*
* Kept out of scripts/check-env.cjs itself (which runs its CLI unconditionally
* on require, with no `require.main === module` guard) so this pure logic
* can be required directly by tests without triggering a real environment
* check as a side effect.
*
* @param {import('child_process').SpawnSyncReturns<string>|null} spawnResult
* @param {Error|null} spawnThrew
* @param {number} timeoutMs
* @returns {string}
*/
function describeNpmVersionCheckFailure(spawnResult, spawnThrew, timeoutMs) {
if (spawnResult && spawnResult.error && spawnResult.error.code === 'ENOENT') {
return 'npm binary not found on PATH';
}
if (spawnResult && spawnResult.signal) {
return `npm --version was killed (signal ${spawnResult.signal}) -- likely the ${timeoutMs}ms timeout under CI load, not a missing binary`;
}
if (spawnResult && spawnResult.status != null && spawnResult.status !== 0) {
return `npm --version exited ${spawnResult.status} with no usable output`;
}
if (spawnThrew) {
return `npm --version could not be spawned: ${spawnThrew.message}`;
}
return 'npm binary not found on PATH';
}
module.exports = { describeNpmVersionCheckFailure };