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>
317 lines
12 KiB
JavaScript
317 lines
12 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');
|
|
|
|
// 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);
|
|
const NPM_VERSION_TIMEOUT_MS = 10_000;
|
|
let currentNpm = '';
|
|
let npmSpawnResult = null;
|
|
let npmSpawnThrew = null;
|
|
try {
|
|
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 (e) { npmSpawnThrew = e; }
|
|
|
|
if (!enginesNpm) {
|
|
addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping');
|
|
} else if (!currentNpm) {
|
|
addCheck('npm-version', 'fail', describeNpmVersionCheckFailure(npmSpawnResult, npmSpawnThrew, NPM_VERSION_TIMEOUT_MS));
|
|
} 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);
|