Files
msd-core/scripts/check-env.cjs
sim 1cd17cc3d6 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>
2026-09-09 08:07:34 -04:00

323 lines
13 KiB
JavaScript

#!/usr/bin/env node
'use strict';
// scripts/check-env.cjs — Environment parity validator for contributors (issue #117).
//
// Node.js port of scripts/check-env.sh. Behaviorally identical output and
// exit codes; shell-agnostic so it runs on Windows, macOS, and Linux.
//
// Checks that the developer's environment matches project requirements before
// running tests or audits. Designed to catch mismatches early rather than
// through cryptic test failures.
//
// Exit codes:
// 0 All checks passed
// 1 One or more checks failed
// 2 Tool error (missing required tool, corrupt package.json, etc.)
//
// Usage:
// node scripts/check-env.cjs # Human-readable report
// node scripts/check-env.cjs --json # Structured JSON report
// node scripts/check-env.cjs --help # This message
//
// Sources:
// npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines
// Reproducible builds: https://reproducible-builds.org/docs/source-tree/
// npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci
// gsd-test-runner: https://github.com/open-gsd/gsd-test-runner
const fs = require('fs');
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');
const { execNpm } = require('../gsd-core/bin/lib/shell-command-projection.cjs');
// On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without
// shell:true requires the exact filename including extension.
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Semver comparison: does `version` satisfy `constraint`?
* Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, <X.Y.Z, =X.Y.Z, X.Y.Z
* Returns true if satisfied, false otherwise.
*/
function satisfiesConstraint(version, constraint) {
// Strip leading 'v' and pre-release/build suffixes
version = version.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
let op, reqVer;
const opMatch = constraint.match(/^(>=|>|<=|<|=)(.+)$/);
if (opMatch) {
op = opMatch[1];
reqVer = opMatch[2];
} else {
op = '=';
reqVer = constraint;
}
reqVer = reqVer.replace(/^v/, '').replace(/-.*$/, '').replace(/\+.*$/, '');
function parseTuple(v) {
const parts = (v + '.0.0').split('.');
return [
parseInt(parts[0], 10) || 0,
parseInt(parts[1], 10) || 0,
parseInt(parts[2], 10) || 0,
];
}
const [vMaj, vMin, vPat] = parseTuple(version);
const [rMaj, rMin, rPat] = parseTuple(reqVer);
const vNum = vMaj * 1_000_000 + vMin * 1_000 + vPat;
const rNum = rMaj * 1_000_000 + rMin * 1_000 + rPat;
switch (op) {
case '>=': return vNum >= rNum;
case '>': return vNum > rNum;
case '<=': return vNum <= rNum;
case '<': return vNum < rNum;
case '=': return vNum === rNum;
default: return false;
}
}
/**
* Read a field from package.json using dot-notation (e.g. 'engines.node').
* Returns the string value or empty string if absent.
* Uses './package.json' so Node resolves relative to CWD on all platforms.
*/
function pkgField(fieldPath, PROJECT_ROOT) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
let val = pkg;
for (const key of fieldPath.split('.')) {
if (val == null || typeof val !== 'object') return '';
val = val[key];
}
return val != null ? String(val) : '';
} catch {
return '';
}
}
function main() {
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
let jsonMode = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--json') {
jsonMode = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' +
'\n' +
'Checks that the developer\'s environment matches project requirements before\n' +
'running tests or audits. Designed to catch mismatches early rather than\n' +
'through cryptic test failures.\n' +
'\n' +
'Exit codes:\n' +
' 0 All checks passed\n' +
' 1 One or more checks failed\n' +
' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' +
'\n' +
'Usage:\n' +
' node scripts/check-env.cjs # Human-readable report\n' +
' node scripts/check-env.cjs --json # Structured JSON report\n' +
' node scripts/check-env.cjs --help # This message\n'
);
return 0;
} else {
process.stderr.write(`Unknown option: ${arg}\n`);
throw new ExitError(2);
}
}
// ---------------------------------------------------------------------------
// Locate the project root (directory containing package.json)
// ---------------------------------------------------------------------------
const PROJECT_ROOT = process.cwd();
const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json');
if (!fs.existsSync(PACKAGE_JSON)) {
process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`);
throw new ExitError(2);
}
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
const checks = [];
function addCheck(name, status, message) {
checks.push({ name, status, message });
}
// ---------------------------------------------------------------------------
// Check 1: Node version vs engines.node
// ---------------------------------------------------------------------------
const enginesNode = pkgField('engines.node', PROJECT_ROOT);
let currentNode = '';
try {
currentNode = process.version.replace(/^v/, '');
} catch { /* ignore */ }
if (!currentNode) {
addCheck('node-version', 'fail', 'node binary not found on PATH');
} else if (!enginesNode) {
addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)');
} else {
if (satisfiesConstraint(currentNode, enginesNode)) {
addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`);
} else {
addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`);
}
}
// ---------------------------------------------------------------------------
// Check 2: npm version vs engines.npm (skip if field absent)
// ---------------------------------------------------------------------------
const enginesNpm = pkgField('engines.npm', PROJECT_ROOT);
// #4460: was a hand-rolled spawnSync(npmCmd, ...) with its own 10s timeout
// and shell:true/npm.cmd handling, duplicating -- imperfectly -- the
// canonical execNpm seam (src/shell-command-projection.cts, "OS Shell
// Projection: All OS-facing I/O; single platform seam" per CLAUDE.md).
// Routing through it directly gives the SAME npm.cmd/shell handling other
// callers rely on, the repo's own documented npm-subprocess timeout
// (execNpm's 15s default, vs. CLAUDE.md's "60s for npm" for network-facing
// peeks -- --version never touches the network, so the general-purpose
// default is the right analogue), and the canonical, cross-platform-correct
// timeout predicate (isSpawnTimeout / result.timedOut, which checks
// error.code === 'ETIMEDOUT' -- documented there as more reliable than
// signal === 'SIGTERM', which is "platform-fragile" specifically on
// Windows, the exact platform this was discovered failing on).
const npmVersionResult = execNpm(['--version']);
const currentNpm = npmVersionResult.exitCode === 0 && npmVersionResult.stdout ? npmVersionResult.stdout : '';
if (!enginesNpm) {
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
} else if (!currentNpm) {
addCheck('npm-version', 'fail', describeNpmVersionCheckFailure(npmVersionResult));
} else {
if (satisfiesConstraint(currentNpm, enginesNpm)) {
addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`);
} else {
addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`);
}
}
// ---------------------------------------------------------------------------
// Check 3: Lockfile presence
// ---------------------------------------------------------------------------
const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json');
if (fs.existsSync(LOCKFILE)) {
addCheck('lockfile-present', 'pass', 'package-lock.json exists');
} else {
addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it");
}
// ---------------------------------------------------------------------------
// Check 4: Lockfile sync (npm ci --dry-run)
// ---------------------------------------------------------------------------
if (fs.existsSync(LOCKFILE)) {
try {
// --ignore-scripts: this is a lockfile-vs-package.json sync check, not a
// build. Without it, npm would run the `prepare` lifecycle (build:lib via
// tsc) — which fails when check:env runs before deps are installed (tsc
// absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish.
const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
});
if (res.status === 0) {
addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json');
} else {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} catch {
addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore");
}
} else {
addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing');
}
// ---------------------------------------------------------------------------
// Check 5: Version manager pin vs active Node
// Looks for .nvmrc, .node-version, or .tool-versions at project root.
// ---------------------------------------------------------------------------
const NVMRC = path.join(PROJECT_ROOT, '.nvmrc');
const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version');
const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions');
let pinnedMajor = '';
let pinSource = '';
if (fs.existsSync(NVMRC)) {
const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.nvmrc';
} else if (fs.existsSync(NODE_VERSION_FILE)) {
const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, '');
pinnedMajor = content.split('.')[0];
pinSource = '.node-version';
} else if (fs.existsSync(TOOL_VERSIONS)) {
const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n');
const nodeLine = lines.find(l => /^nodejs\s+/.test(l));
if (nodeLine) {
const ver = nodeLine.split(/\s+/)[1] || '';
pinnedMajor = ver.replace(/^v/, '').split('.')[0];
pinSource = '.tool-versions';
}
}
if (!pinnedMajor) {
addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping');
} else if (process.env.CI === 'true') {
addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)');
} else {
const activeMajor = process.version.replace(/^v/, '').split('.')[0];
if (activeMajor === pinnedMajor) {
addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`);
} else {
addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`);
}
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
const overallPass = checks.every(c => c.status !== 'fail');
if (jsonMode) {
// Structured JSON: {pass: bool, checks: [{name, status, message}]}
const out = {
pass: overallPass,
checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })),
};
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
} else {
// Human-readable report
process.stdout.write('=== Environment Check ===\n');
for (const { name, status, message } of checks) {
const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]';
const namePadded = name.padEnd(25);
process.stdout.write(` ${icon} ${namePadded} ${message}\n`);
}
process.stdout.write('\n');
if (overallPass) {
process.stdout.write('Result: ALL CHECKS PASSED\n');
} else {
process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n');
}
}
return overallPass ? 0 : 1;
}
runMain(main);