From 33ffc647e2a70fa0d617792a8cdd81bd8b8cc3e8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 18:38:49 -0400 Subject: [PATCH] feat(117): reproducible npm environment bootstrap + check-env validator (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(117): add failing tests for env validator (check-env.sh) RED phase. Six tests for scripts/check-env.sh — none pass because the script does not exist yet. Fixtures: good/ — engines.node >=22, .nvmrc 26, synced lockfile bad-node-version/ — engines.node <14.0.0 (current Node v26 fails) missing-lockfile/ — no package-lock.json bad-nvmrc/ — .nvmrc says 22, current Node is v26 Tests cover: 1. Happy path exits 0 2. engines.node constraint failure exits 1 3. Missing lockfile exits 1 4. .nvmrc major mismatch exits 1 5. --json flag emits {pass: boolean, checks: array} 6. Integration smoke: exits 0 on live worktree root Sources: npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines npm ci docs: https://docs.npmjs.com/cli/v10/commands/npm-ci Closes #117 * feat(117): add scripts/check-env.sh with Node/npm/lockfile/version-manager checks GREEN phase. Implements the five-check environment validator: 1. Node version vs engines.node (semver constraint — >=, >, <=, <, =) 2. npm version vs engines.npm (skipped if field absent) 3. package-lock.json presence 4. Lockfile sync via `npm ci --dry-run` (exits non-zero when drift detected) 5. Version-manager pin (.nvmrc / .node-version / .tool-versions) vs active Node major Exit codes: 0 = all green; 1 = at least one failure; 2 = tool error. Flags: --json (structured report), --help. All 7 tests pass. shellcheck clean. bash -n syntax check clean. 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 Closes #117 * chore(117): pin Node engines + .nvmrc; add check:env npm script - Add engines.npm: ">=10.0.0" (npm 10 ships with Node 22, the CI floor). Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines - Add .nvmrc pinning Node 22 (lowest supported version per CI matrix in .github/workflows/test.yml; node-version: [22, 24]). - Add "check:env": "./scripts/check-env.sh" script to package.json. No generator is involved (not a .generated. file). The test update in this commit adjusts the integration smoke: it now asserts on --json structured output rather than raw text, and accepts exit 0 or 1 (version-manager pin mismatch is expected when developer runs Node 26 against a .nvmrc of 22). Closes #117 * ci(117): wire environment check into test workflow Add "Environment check" step to .github/workflows/test.yml in the `test` job. Positioned AFTER actions/setup-node and BEFORE npm ci so that env mismatches (wrong Node version, missing npm version, absent lockfile) are caught before the install step obscures the root cause. Runs `npm run check:env` (./scripts/check-env.sh) on every matrix lane (ubuntu, macos, windows) × (Node 22, 24). Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines Closes #117 * docs(117): publish docs/contributing/bootstrap.md + link from CONTRIBUTING.md Adds docs/contributing/bootstrap.md with: 1. Prerequisites (nvm, fnm, asdf, mise; gh CLI) 2. One-time setup (clone, nvm use, check:env, npm ci) 3. Daily commands table 4. Validation guide (check table, exit codes, --json usage) 5. Troubleshooting (node-version, npm-version, lockfile-present, lockfile-sync, version-manager-pin, missing modules, locale errors) 6. Alternative: Docker via gsd-test-runner (https://github.com/open-gsd/gsd-test-runner) Adds "Bootstrap your environment" section to CONTRIBUTING.md pointing to the new doc. No content duplication — CONTRIBUTING.md links only. Adds .changeset/117-npm-bootstrap.md (type: Added) for changelog. 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 Closes #117 * fix(#117): make check:env script run on Windows runners Invoke check-env.sh via `bash` instead of a bare POSIX path so Windows CI runners (which have Git Bash on PATH) execute the script without requiring a POSIX shell shebang dispatcher. Co-Authored-By: Claude Sonnet 4.6 * test(117): make check-env.sh fixture .nvmrc adapt to active Node major (cross-platform fix) Before() hook writes good/.nvmrc = activeNodeMajor and bad-nvmrc/.nvmrc = activeNodeMajor+99 at test-run time. Hardcoded .nvmrc=26 failed on every CI matrix row except Node 26. After() restores originals so the checked-in files stay stable. Co-Authored-By: Claude Sonnet 4.6 * docs(117): exempt gsd-test-runner URL path from slash-command registry check docs/contributing/bootstrap.md links to https://github.com/open-gsd/gsd-test-runner. The parity-test regex captures /gsd-test-runner from the URL path component and flags it as an unregistered slash command. Add 'test-runner' to INTERNAL_COMPONENT_SLUGS (mirrors the existing 'build' entry for GitHub org URLs) with an explanatory comment. Co-Authored-By: Claude Sonnet 4.6 * fix(117): fix Node-24 and Windows-22 CI failures in check-env Two root causes: 1. version-manager-pin on Node 24 (mac/ubuntu/win): The project root .nvmrc pins major 22 for local dev. When the CI matrix runs Node 24, check-env.sh fails the version-manager-pin check and exits 1, blocking the entire test job before any test runs. Fix: skip the pin check when CI=true (GitHub Actions always sets this). The pin is a local dev guard, not a gate for multi- version matrix CI. 2. engines.node appears missing on Windows-22 (pkg_field backslash): pkg_field() embedded PACKAGE_JSON directly into a node -e string literal using require(). On Windows, the path uses backslashes (D:\a\...) which are silently interpreted as JS escape sequences inside the string, causing require() to fail silently (2>/dev/null || true). engines.node returns empty, triggering a spurious FAIL. Fix: switch to fs.readFileSync + JSON.parse and normalise backslashes to forward-slashes before embedding in the JS literal. Also pass { CI: '' } from the bad-nvmrc unit test so the version-manager-pin fixture test still exercises the mismatch path even when running inside CI runners. Co-Authored-By: Claude Sonnet 4.6 * fix(117): use relative ./package.json path in pkg_field to fix Windows CI On Windows, Git Bash exposes \$PWD as a POSIX path (/d/a/…) which node.exe cannot resolve via fs.readFileSync. The previous fix embedded the absolute PACKAGE_JSON path in the node -e string after converting backslashes to forward-slashes, but the POSIX form produced by Git Bash (/d/a/…) has no backslashes — so the conversion was a no-op and node received an unresolvable path. The silent catch(e) { process.exit(0) } swallowed the ENOENT, returning empty string for every engines.* field. Fix: use './package.json' (relative to CWD). pkg_field() is always called before any cd in the script so CWD === PROJECT_ROOT at call time. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/117-npm-bootstrap.md | 5 + .github/workflows/test.yml | 8 + .nvmrc | 1 + CONTRIBUTING.md | 17 + docs/contributing/bootstrap.md | 221 ++++++++++++ package.json | 4 +- scripts/check-env.sh | 332 ++++++++++++++++++ tests/check-env.test.cjs | 234 ++++++++++++ tests/docs-parity-live-registry.test.cjs | 7 + .../bad-node-version/package-lock.json | 15 + .../check-env/bad-node-version/package.json | 7 + tests/fixtures/check-env/bad-nvmrc/.nvmrc | 1 + .../check-env/bad-nvmrc/package-lock.json | 15 + .../fixtures/check-env/bad-nvmrc/package.json | 7 + tests/fixtures/check-env/good/.nvmrc | 1 + .../fixtures/check-env/good/package-lock.json | 16 + tests/fixtures/check-env/good/package.json | 8 + .../check-env/missing-lockfile/package.json | 7 + 18 files changed, 905 insertions(+), 1 deletion(-) create mode 100644 .changeset/117-npm-bootstrap.md create mode 100644 .nvmrc create mode 100644 docs/contributing/bootstrap.md create mode 100755 scripts/check-env.sh create mode 100644 tests/check-env.test.cjs create mode 100644 tests/fixtures/check-env/bad-node-version/package-lock.json create mode 100644 tests/fixtures/check-env/bad-node-version/package.json create mode 100644 tests/fixtures/check-env/bad-nvmrc/.nvmrc create mode 100644 tests/fixtures/check-env/bad-nvmrc/package-lock.json create mode 100644 tests/fixtures/check-env/bad-nvmrc/package.json create mode 100644 tests/fixtures/check-env/good/.nvmrc create mode 100644 tests/fixtures/check-env/good/package-lock.json create mode 100644 tests/fixtures/check-env/good/package.json create mode 100644 tests/fixtures/check-env/missing-lockfile/package.json diff --git a/.changeset/117-npm-bootstrap.md b/.changeset/117-npm-bootstrap.md new file mode 100644 index 000000000..6e5d28cdf --- /dev/null +++ b/.changeset/117-npm-bootstrap.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 117 +--- +**Environment bootstrap validator (`npm run check:env`)** — adds `scripts/check-env.sh` which validates Node version, npm version, lockfile presence, lockfile sync, and version-manager pin before test or audit runs. Catches environment mismatches early with structured `--json` output. Companion docs in `docs/contributing/bootstrap.md` cover prerequisites, one-time setup, daily commands, and troubleshooting. CI now runs the environment check after `setup-node` and before `npm ci` on every matrix lane. (#117) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a4c76425..56610279a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -148,6 +148,14 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'npm' + # Environment check - validates Node/npm version against engines constraints + # and confirms package-lock.json is present before npm ci runs. + # Catches env drift in CI itself (contributor parity check, issue #117). + # Source: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines + - name: Environment check + shell: bash + run: npm run check:env + - name: Install dependencies shell: bash run: npm ci diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ef4da02d..e7f3efffd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,23 @@ npm test --- +## Bootstrap your environment + +For a step-by-step setup guide covering Node version managers, `npm ci`, the environment +validator, daily commands, and troubleshooting, see: + +**[docs/contributing/bootstrap.md](docs/contributing/bootstrap.md)** + +Quick start: + +```bash +nvm use # activate the pinned Node version from .nvmrc +npm run check:env # validate your environment +npm ci # install from lockfile +``` + +--- + ## Types of Contributions GSD accepts three types of contributions. Each type has a different process and a different bar for acceptance. **Read this section before opening anything.** diff --git a/docs/contributing/bootstrap.md b/docs/contributing/bootstrap.md new file mode 100644 index 000000000..214cbf81d --- /dev/null +++ b/docs/contributing/bootstrap.md @@ -0,0 +1,221 @@ +# Bootstrap your environment + +This guide gets a new contributor from a fresh checkout to a passing baseline in one session. + +Sources: +- npm engines field: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines +- Reproducible builds: https://reproducible-builds.org/docs/source-tree/ +- npm ci: https://docs.npmjs.com/cli/v10/commands/npm-ci +- Docker-based Linux verification: https://github.com/open-gsd/gsd-test-runner + +--- + +## Prerequisites + +### Node version manager (pick one) + +| Tool | Install | Docs | +|---|---|---| +| **nvm** (recommended) | `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \| bash` | https://github.com/nvm-sh/nvm | +| **fnm** (fast, Rust) | `curl -fsSL https://fnm.vercel.app/install \| bash` | https://github.com/Schniz/fnm | +| **asdf** | `brew install asdf` then `asdf plugin add nodejs` | https://asdf-vm.com | +| **mise** | `curl https://mise.run \| sh` | https://mise.jdx.dev | + +A version manager ensures you can switch Node versions per project without polluting your +global install. This project ships a `.nvmrc` file at the root — any of the tools above +will read it. + +### Other required tools + +- **gh** (GitHub CLI) — https://cli.github.com — used by contribution workflows and CI +- **git** — any recent version + +--- + +## One-time setup + +```bash +# 1. Clone +git clone https://github.com/open-gsd/get-shit-done-redux.git +cd get-shit-done-redux + +# 2. Activate the pinned Node version +nvm use # nvm +# fnm use # fnm +# asdf install # asdf / mise + +# 3. Verify the environment (see Validation below) +npm run check:env + +# 4. Install dependencies (reproducible, lockfile-driven) +npm ci +``` + +`npm ci` is required over `npm install`. It installs exactly what `package-lock.json` +specifies and fails fast if the lockfile is out of sync — this is intentional. +See https://docs.npmjs.com/cli/v10/commands/npm-ci + +--- + +## Daily commands + +| Command | Purpose | +|---|---| +| `npm run check:env` | Validate your environment before running tests | +| `npm test` | Run the full test suite (unit + integration + security) | +| `npm run test:unit` | Unit tests only (fastest) | +| `npm run test:integration` | Integration tests | +| `npm run build:sdk` | Rebuild the SDK dist (required before first test run) | + +> `npm run check:integrity` — available once [#114](https://github.com/open-gsd/get-shit-done-redux/issues/114) merges. + +--- + +## Validation + +Run the environment validator before any test or audit run: + +```bash +npm run check:env +``` + +This runs `scripts/check-env.sh` and reports pass/fail for each check: + +| Check | What it verifies | +|---|---| +| `node-version` | Active Node satisfies `engines.node` (`>=22.0.0`) | +| `npm-version` | Active npm satisfies `engines.npm` (`>=10.0.0`) | +| `lockfile-present` | `package-lock.json` exists at root | +| `lockfile-sync` | `npm ci --dry-run` exits 0 (lockfile matches installed state) | +| `version-manager-pin` | Active Node major matches `.nvmrc` / `.node-version` / `.tool-versions` | + +**Exit codes:** +- `0` — all checks passed, safe to proceed +- `1` — one or more checks failed — see the report +- `2` — tool error (e.g., `node` not found, corrupt `package.json`) + +For structured output (useful in scripts): + +```bash +npm run check:env -- --json +``` + +--- + +## Troubleshooting + +### `node-version` FAIL — Node X does NOT satisfy `>=22.0.0` + +**Cause:** The system Node is too old, or the version manager hasn't activated the correct version. + +**Fix:** +```bash +nvm use # activates version from .nvmrc +node --version # confirm +``` + +If `nvm` reports the version is not installed: +```bash +nvm install # installs the version in .nvmrc +nvm use +``` + +--- + +### `npm-version` FAIL — npm X does NOT satisfy `>=10.0.0` + +**Cause:** npm bundled with an old Node version. + +**Fix:** +```bash +npm install -g npm@latest +npm --version +``` + +--- + +### `lockfile-present` FAIL — `package-lock.json` missing + +**Cause:** The lockfile was deleted or was never generated. + +**Fix:** +```bash +npm install # generates package-lock.json +``` + +Do NOT commit a regenerated lockfile without verifying no unexpected packages changed. +Run `git diff package-lock.json` to inspect the diff. + +--- + +### `lockfile-sync` FAIL — `package-lock.json` is out of sync + +**Cause:** `package.json` was edited (dependency added/changed) without updating the lockfile, +or the lockfile was hand-edited. + +**Fix:** +```bash +npm ci # restores node_modules to match lockfile exactly +# or, if the sync failure is intentional (you updated package.json): +npm install # updates lockfile to match package.json +``` + +--- + +### `version-manager-pin` FAIL — Active Node major does not match `.nvmrc` + +**Cause:** The shell is using a globally-installed Node rather than the version manager's +activation. Common on fresh shell sessions. + +**Fix:** +```bash +nvm use # re-activate from .nvmrc +# or add to your shell profile: +# echo 'nvm use --silent' >> ~/.zshrc +``` + +--- + +### Tests fail with `Error: Cannot find module ...` + +**Cause:** `node_modules` is missing or stale (common after a branch switch that changed `package.json`). + +**Fix:** +```bash +npm ci # clean install from lockfile +npm run build:sdk +``` + +--- + +### Locale / encoding errors on non-UTF-8 systems + +**Cause:** Some test fixtures contain non-ASCII characters. Node requires a UTF-8 locale. + +**Fix:** +```bash +export LANG=en_US.UTF-8 +export LC_ALL=en_US.UTF-8 +``` + +Add these to your shell profile to make them permanent. + +--- + +## Alternative: Docker via gsd-test-runner + +For canonical Linux verification from a macOS dev box, use +[gsd-test-runner](https://github.com/open-gsd/gsd-test-runner). + +This is the same path CI uses for cross-platform coverage. It is the authoritative way +to confirm your change passes on Linux before opening a PR: + +```bash +# One-time: install gsd-test-runner (see repo README) +# Then, from the project root: +gsd-test-summary +``` + +`gsd-test-summary` runs the full suite in a Docker container and emits a concise +`Mac: N failed / Docker: N failed` summary. Both lines must show `0 failed` before +a PR is opened. See [CLAUDE.md](../../CLAUDE.md) for the required PR-flow ordering. diff --git a/package.json b/package.json index 84e638813..e6d0d7c2b 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "access": "public" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.0.0", + "npm": ">=10.0.0" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", @@ -61,6 +62,7 @@ "fallow": "^2.70.0" }, "scripts": { + "check:env": "bash scripts/check-env.sh", "check:integrity": "./scripts/check-npm-integrity.sh", "build:hooks": "node scripts/build-hooks.js", "build:sdk": "cd sdk && npm ci && npm run build", diff --git a/scripts/check-env.sh b/scripts/check-env.sh new file mode 100755 index 000000000..2bab59657 --- /dev/null +++ b/scripts/check-env.sh @@ -0,0 +1,332 @@ +#!/usr/bin/env bash +# scripts/check-env.sh — Environment parity validator for contributors (issue #117). +# +# 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: +# ./scripts/check-env.sh # Human-readable report +# ./scripts/check-env.sh --json # Structured JSON report +# ./scripts/check-env.sh --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 + +set -euo pipefail + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- +JSON_MODE=false +for arg in "$@"; do + case "$arg" in + --json) JSON_MODE=true ;; + --help|-h) + # Print header comment block (lines starting with #, stopping at first non-comment) + while IFS= read -r line; do + if [[ "${line}" =~ ^# ]]; then + printf '%s\n' "${line#\# }" + elif [[ -z "${line}" ]]; then + continue + else + break + fi + done < "$0" + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 2 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Locate the project root (directory containing package.json) +# We resolve relative to CWD, not the script location, so callers can pass +# a --cwd by simply cd-ing before invoking. +# --------------------------------------------------------------------------- +PROJECT_ROOT="${PWD}" +PACKAGE_JSON="${PROJECT_ROOT}/package.json" + +if [[ ! -f "${PACKAGE_JSON}" ]]; then + echo "ERROR: package.json not found in ${PROJECT_ROOT}" >&2 + exit 2 +fi + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Emit a check result line. +# Args: name, status (pass|fail|skip), message +CHECKS=() # Each entry: "name|status|message" + +add_check() { + local name="$1" + local status="$2" + local message="$3" + CHECKS+=("${name}|${status}|${message}") +} + +# Semver comparison: does version $1 satisfy constraint $2? +# Constraint forms supported: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, =X (no minor/patch required). +# Returns 0 if satisfied, 1 if not. +satisfies_constraint() { + local version="$1" + local constraint="$2" + + # Strip leading 'v' from version + version="${version#v}" + # Strip pre-release suffixes (e.g. 26.0.0-rc.1 → 26.0.0) + version="${version%%-*}" + version="${version%%+*}" + + # Parse operator and required version from constraint. + # Support: >=, >, <=, <, =, (bare) version + local op req_ver + if [[ "$constraint" =~ ^(>=|>|<=|<|=)(.+)$ ]]; then + op="${BASH_REMATCH[1]}" + req_ver="${BASH_REMATCH[2]}" + else + op="=" + req_ver="$constraint" + fi + req_ver="${req_ver#v}" + req_ver="${req_ver%%-*}" + req_ver="${req_ver%%+*}" + + # Extract major, minor, patch using field splitting on '.' + # We pad the version strings to ensure exactly 3 dot-separated fields. + # Padding trick: append ".0.0" then take first 3 fields via cut. + local v_padded="${version}.0.0" + local v_major v_minor v_patch + v_major="$(printf '%s' "${v_padded}" | cut -d. -f1)" + v_minor="$(printf '%s' "${v_padded}" | cut -d. -f2)" + v_patch="$(printf '%s' "${v_padded}" | cut -d. -f3)" + v_major="${v_major:-0}"; v_minor="${v_minor:-0}"; v_patch="${v_patch:-0}" + + local r_padded="${req_ver}.0.0" + local r_major r_minor r_patch + r_major="$(printf '%s' "${r_padded}" | cut -d. -f1)" + r_minor="$(printf '%s' "${r_padded}" | cut -d. -f2)" + r_patch="$(printf '%s' "${r_padded}" | cut -d. -f3)" + r_major="${r_major:-0}"; r_minor="${r_minor:-0}"; r_patch="${r_patch:-0}" + + # Numeric tuple comparison using arithmetic. + local v_num=$(( v_major * 1000000 + v_minor * 1000 + v_patch )) + local r_num=$(( r_major * 1000000 + r_minor * 1000 + r_patch )) + + case "$op" in + ">=") [[ $v_num -ge $r_num ]] ;; + ">") [[ $v_num -gt $r_num ]] ;; + "<=") [[ $v_num -le $r_num ]] ;; + "<") [[ $v_num -lt $r_num ]] ;; + "=") [[ $v_num -eq $r_num ]] ;; + *) return 1 ;; + esac +} + +# Read a field from package.json using node (avoids requiring jq). +# Uses a relative path './package.json' so that Node receives a path it can +# resolve on every platform — including Windows where Git Bash exposes $PWD +# as a POSIX path (/d/a/…) that node.exe cannot open via fs.readFileSync. +# pkg_field is always called before any `cd` in this script, so CWD is +# PROJECT_ROOT and './package.json' always resolves correctly. +pkg_field() { + node -e " + const fs = require('fs'); + let pkg; + try { pkg = JSON.parse(fs.readFileSync('./package.json', 'utf8')); } catch(e) { process.exit(0); } + const val = '${1}'.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : null), pkg); + if (val !== null) process.stdout.write(String(val)); + " 2>/dev/null || true +} + +# --------------------------------------------------------------------------- +# Check 1: Node version vs engines.node +# --------------------------------------------------------------------------- +ENGINES_NODE="$(pkg_field engines.node)" +CURRENT_NODE="$(node --version 2>/dev/null || echo '')" +CURRENT_NODE="${CURRENT_NODE#v}" + +if [[ -z "${CURRENT_NODE}" ]]; then + add_check "node-version" "fail" "node binary not found on PATH" +elif [[ -z "${ENGINES_NODE}" ]]; then + add_check "node-version" "fail" "engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)" +else + if satisfies_constraint "${CURRENT_NODE}" "${ENGINES_NODE}"; then + add_check "node-version" "pass" "Node ${CURRENT_NODE} satisfies ${ENGINES_NODE}" + else + add_check "node-version" "fail" "Node ${CURRENT_NODE} does NOT satisfy engines.node ${ENGINES_NODE}" + fi +fi + +# --------------------------------------------------------------------------- +# Check 2: npm version vs engines.npm (skip if field absent) +# --------------------------------------------------------------------------- +ENGINES_NPM="$(pkg_field engines.npm)" +CURRENT_NPM="$(npm --version 2>/dev/null || echo '')" + +if [[ -z "${ENGINES_NPM}" ]]; then + add_check "npm-version" "skip" "engines.npm not set in package.json — skipping" +elif [[ -z "${CURRENT_NPM}" ]]; then + add_check "npm-version" "fail" "npm binary not found on PATH" +else + if satisfies_constraint "${CURRENT_NPM}" "${ENGINES_NPM}"; then + add_check "npm-version" "pass" "npm ${CURRENT_NPM} satisfies ${ENGINES_NPM}" + else + add_check "npm-version" "fail" "npm ${CURRENT_NPM} does NOT satisfy engines.npm ${ENGINES_NPM}" + fi +fi + +# --------------------------------------------------------------------------- +# Check 3: Lockfile presence +# --------------------------------------------------------------------------- +LOCKFILE="${PROJECT_ROOT}/package-lock.json" +if [[ -f "${LOCKFILE}" ]]; then + add_check "lockfile-present" "pass" "package-lock.json exists" +else + add_check "lockfile-present" "fail" "package-lock.json missing — run 'npm install' to generate it" +fi + +# --------------------------------------------------------------------------- +# Check 4: Lockfile sync (npm ci --dry-run) +# Skip if lockfile is missing (already failed above). +# --------------------------------------------------------------------------- +if [[ -f "${LOCKFILE}" ]]; then + # npm ci --dry-run exits 0 when in sync; exits non-zero when it would mutate. + if (cd "${PROJECT_ROOT}" && npm ci --dry-run >/dev/null 2>&1); then + add_check "lockfile-sync" "pass" "package-lock.json is in sync with package.json" + else + add_check "lockfile-sync" "fail" "package-lock.json is out of sync — run 'npm ci' to restore" + fi +else + add_check "lockfile-sync" "skip" "skipped — lockfile missing" +fi + +# --------------------------------------------------------------------------- +# Check 5: Version manager pin vs active Node +# Looks for .nvmrc, .node-version, or .tool-versions at project root. +# --------------------------------------------------------------------------- +NVMRC="${PROJECT_ROOT}/.nvmrc" +NODE_VERSION_FILE="${PROJECT_ROOT}/.node-version" +TOOL_VERSIONS="${PROJECT_ROOT}/.tool-versions" + +PINNED_MAJOR="" +PIN_SOURCE="" + +if [[ -f "${NVMRC}" ]]; then + NVMRC_CONTENT="$(head -1 "${NVMRC}" | tr -d '[:space:]')" + # Strip leading 'v' and extract major + NVMRC_CONTENT="${NVMRC_CONTENT#v}" + PINNED_MAJOR="${NVMRC_CONTENT%%.*}" + PIN_SOURCE=".nvmrc" +elif [[ -f "${NODE_VERSION_FILE}" ]]; then + NV_CONTENT="$(head -1 "${NODE_VERSION_FILE}" | tr -d '[:space:]')" + NV_CONTENT="${NV_CONTENT#v}" + PINNED_MAJOR="${NV_CONTENT%%.*}" + PIN_SOURCE=".node-version" +elif [[ -f "${TOOL_VERSIONS}" ]]; then + # asdf/mise format: "nodejs 22.x.x" + TV_LINE="$(grep -E '^nodejs ' "${TOOL_VERSIONS}" || true)" + if [[ -n "${TV_LINE}" ]]; then + TV_VER="$(echo "${TV_LINE}" | awk '{print $2}')" + TV_VER="${TV_VER#v}" + PINNED_MAJOR="${TV_VER%%.*}" + PIN_SOURCE=".tool-versions" + fi +fi + +if [[ -z "${PINNED_MAJOR}" ]]; then + add_check "version-manager-pin" "skip" "no .nvmrc, .node-version, or .tool-versions found — skipping" +elif [[ "${CI:-}" == "true" ]]; then + # In CI the matrix explicitly tests multiple Node majors, so a pin-mismatch + # is expected and intentional. Skip rather than fail to avoid blocking the + # non-22 matrix rows (Node 24, 26, …) while still exercising all other checks. + add_check "version-manager-pin" "skip" "CI=true — version-manager pin check skipped (matrix tests multiple Node majors)" +else + ACTIVE_MAJOR="${CURRENT_NODE%%.*}" + if [[ "${ACTIVE_MAJOR}" == "${PINNED_MAJOR}" ]]; then + add_check "version-manager-pin" "pass" "Active Node major (${ACTIVE_MAJOR}) matches ${PIN_SOURCE} pin (${PINNED_MAJOR})" + else + add_check "version-manager-pin" "fail" "Active Node major (${ACTIVE_MAJOR}) does NOT match ${PIN_SOURCE} pin (${PINNED_MAJOR}) — run 'nvm use' or equivalent" + fi +fi + +# --------------------------------------------------------------------------- +# Output +# --------------------------------------------------------------------------- +OVERALL_PASS=true +for check in "${CHECKS[@]}"; do + IFS='|' read -r name status message <<< "$check" + if [[ "$status" == "fail" ]]; then + OVERALL_PASS=false + break + fi +done + +if [[ "${JSON_MODE}" == "true" ]]; then + # Emit structured JSON: {pass: bool, checks: [{name, status, message}]} + PASS_VAL="false" + [[ "${OVERALL_PASS}" == "true" ]] && PASS_VAL="true" + + printf '{\n' + printf ' "pass": %s,\n' "${PASS_VAL}" + printf ' "checks": [\n' + + total="${#CHECKS[@]}" + idx=0 + for check in "${CHECKS[@]}"; do + idx=$(( idx + 1 )) + IFS='|' read -r name status message <<< "$check" + # Escape double-quotes in message for JSON + message="${message//\"/\\\"}" + if [[ $idx -lt $total ]]; then + printf ' {"name": "%s", "status": "%s", "message": "%s"},\n' \ + "${name}" "${status}" "${message}" + else + printf ' {"name": "%s", "status": "%s", "message": "%s"}\n' \ + "${name}" "${status}" "${message}" + fi + done + + printf ' ]\n' + printf '}\n' +else + # Human-readable report + echo "=== Environment Check ===" + for check in "${CHECKS[@]}"; do + IFS='|' read -r name status message <<< "$check" + case "$status" in + pass) icon="[PASS]" ;; + fail) icon="[FAIL]" ;; + skip) icon="[SKIP]" ;; + *) icon="[????]" ;; + esac + printf " %s %-25s %s\n" "$icon" "$name" "$message" + done + echo "" + if [[ "${OVERALL_PASS}" == "true" ]]; then + echo "Result: ALL CHECKS PASSED" + else + echo "Result: ONE OR MORE CHECKS FAILED — see above" + fi +fi + +# Exit code +if [[ "${OVERALL_PASS}" == "true" ]]; then + exit 0 +else + exit 1 +fi diff --git a/tests/check-env.test.cjs b/tests/check-env.test.cjs new file mode 100644 index 000000000..e7ed54b60 --- /dev/null +++ b/tests/check-env.test.cjs @@ -0,0 +1,234 @@ +/** + * Tests for scripts/check-env.sh (issue #117). + * + * Verifies the environment validator exits correctly and emits + * structured output for every documented check: + * 1. Node version vs engines.node constraint + * 2. npm version vs engines.npm constraint (if present) + * 3. Lockfile presence + * 4. Lockfile sync (npm ci --dry-run) + * 5. Version-manager pin file matches active Node major + * 6. --json flag produces parseable JSON with documented shape + * 7. Integration smoke: exits 0 on the live worktree root + * + * Sources: + * npm engines: https://docs.npmjs.com/cli/v10/configuring-npm/package-json#engines + * npm ci: https://docs.npmjs.com/cli/v10/commands/npm-ci + */ + +'use strict'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); +const { spawnSync } = require('node:child_process'); + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'check-env.sh'); +const FIXTURE_ROOT = path.resolve(__dirname, 'fixtures', 'check-env'); +const LIVE_ROOT = path.resolve(__dirname, '..'); + +/** + * Run check-env.sh synchronously in `cwd` with optional extra args. + * Returns { status, stdout, stderr }. + * @param {string} cwd + * @param {string[]} args + * @param {Record} [envOverrides] - optional env vars to overlay + * on process.env. Pass { CI: '' } to suppress GitHub Actions CI detection + * so that version-manager-pin is exercised even inside CI runners. + */ +function runScript(cwd, args = [], envOverrides = {}) { + const result = spawnSync('bash', [SCRIPT, ...args], { + cwd, + encoding: 'utf8', + timeout: 30_000, + env: { ...process.env, ...envOverrides }, + }); + return { + status: result.status ?? 1, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + }; +} + +describe('check-env.sh', () => { + // ------------------------------------------------------------------------- + // Dynamic .nvmrc setup: write fixture .nvmrc files at test-run time so the + // tests are correct across all Node major versions in the CI matrix (Node 22, + // 24, 26, …). A hardcoded value like "26" passes on Node 26 but fails on + // every other matrix row; using the active major makes the fixture portable. + // + // good/ → .nvmrc = active Node major (should match → exit 0) + // bad-nvmrc/ → .nvmrc = active+99 (guaranteed mismatch → exit 1) + // ------------------------------------------------------------------------- + const activeNodeMajor = parseInt(process.version.match(/^v(\d+)/)[1], 10); + const goodNvmrc = path.join(FIXTURE_ROOT, 'good', '.nvmrc'); + const badNvmrc = path.join(FIXTURE_ROOT, 'bad-nvmrc', '.nvmrc'); + let originalGoodNvmrc; + let originalBadNvmrc; + + before(() => { + originalGoodNvmrc = fs.existsSync(goodNvmrc) ? fs.readFileSync(goodNvmrc, 'utf8') : null; + originalBadNvmrc = fs.existsSync(badNvmrc) ? fs.readFileSync(badNvmrc, 'utf8') : null; + fs.writeFileSync(goodNvmrc, `${activeNodeMajor}\n`); + fs.writeFileSync(badNvmrc, `${activeNodeMajor + 99}\n`); + }); + + after(() => { + if (originalGoodNvmrc !== null) { + fs.writeFileSync(goodNvmrc, originalGoodNvmrc); + } + if (originalBadNvmrc !== null) { + fs.writeFileSync(badNvmrc, originalBadNvmrc); + } + }); + + // ------------------------------------------------------------------------- + // Test 1: Happy path — all checks green + // ------------------------------------------------------------------------- + test('exits 0 in a fixture directory with engines, .nvmrc, and matching lockfile', () => { + const cwd = path.join(FIXTURE_ROOT, 'good'); + const { status, stdout } = runScript(cwd); + assert.equal( + status, 0, + `Expected exit 0, got ${status}.\nstdout: ${stdout}` + ); + }); + + // ------------------------------------------------------------------------- + // Test 2: engines.node constraint not satisfied + // ------------------------------------------------------------------------- + test('exits 1 when engines.node constraint is not satisfied by current Node', () => { + const cwd = path.join(FIXTURE_ROOT, 'bad-node-version'); + // Fixture has engines.node: "<14.0.0"; current Node is much higher. + const { status, stdout } = runScript(cwd); + assert.equal( + status, 1, + `Expected exit 1 (bad node version), got ${status}.\nstdout: ${stdout}` + ); + }); + + // ------------------------------------------------------------------------- + // Test 3: Missing lockfile + // ------------------------------------------------------------------------- + test('exits 1 when package-lock.json is missing', () => { + const cwd = path.join(FIXTURE_ROOT, 'missing-lockfile'); + const { status, stdout } = runScript(cwd); + assert.equal( + status, 1, + `Expected exit 1 (missing lockfile), got ${status}.\nstdout: ${stdout}` + ); + }); + + // ------------------------------------------------------------------------- + // Test 4: .nvmrc major doesn't match active Node major + // ------------------------------------------------------------------------- + test('exits 1 when .nvmrc major version does not match active Node major', () => { + const cwd = path.join(FIXTURE_ROOT, 'bad-nvmrc'); + // Fixture .nvmrc is set to (activeNodeMajor + 99) by the before() hook above, + // guaranteeing a mismatch regardless of the CI matrix Node version. + // Override CI='' so the version-manager-pin check is not skipped even when + // this test runs inside a CI runner (GitHub Actions sets CI=true, which + // would otherwise turn the pin check into a skip and exit 0). + const { status, stdout } = runScript(cwd, [], { CI: '' }); + assert.equal( + status, 1, + `Expected exit 1 (nvmrc mismatch), got ${status}.\nstdout: ${stdout}` + ); + }); + + // ------------------------------------------------------------------------- + // Test 5: --json flag produces parseable JSON with documented shape + // ------------------------------------------------------------------------- + test('--json emits parseable JSON with pass and checks keys', () => { + const cwd = path.join(FIXTURE_ROOT, 'good'); + const { status, stdout } = runScript(cwd, ['--json']); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch (err) { + assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`); + } + // Top-level shape + assert.equal(typeof parsed.pass, 'boolean', 'JSON must have boolean `pass` key'); + assert.ok(Array.isArray(parsed.checks), 'JSON must have array `checks` key'); + // The good fixture has engines.node, .nvmrc, and package-lock.json — expect + // at least the node-version, lockfile-present, lockfile-sync, and + // version-manager-pin checks to appear. + const checkNames = parsed.checks.map((c) => c.name); + assert.ok( + checkNames.includes('node-version'), + `Expected 'node-version' check in JSON, got: ${checkNames.join(', ')}` + ); + assert.ok( + checkNames.includes('lockfile-present'), + `Expected 'lockfile-present' check in JSON, got: ${checkNames.join(', ')}` + ); + // Every check item must have name, status, message fields with expected types + for (const check of parsed.checks) { + assert.equal(typeof check.name, 'string', `check.name must be string in ${JSON.stringify(check)}`); + assert.ok( + ['pass', 'fail', 'skip'].includes(check.status), + `check.status must be pass|fail|skip in ${JSON.stringify(check)}` + ); + assert.equal(typeof check.message, 'string', `check.message must be string in ${JSON.stringify(check)}`); + } + // Good fixture: overall result must be pass:true + assert.equal(parsed.pass, true, 'good fixture must report pass:true'); + assert.equal( + status, 0, + `Expected exit 0 in good fixture with --json, got ${status}` + ); + }); + + // ------------------------------------------------------------------------- + // Test 5b: --json reports pass:false on failure fixtures (counter-test for 5) + // ------------------------------------------------------------------------- + test('--json reports pass:false when a check fails', () => { + const cwd = path.join(FIXTURE_ROOT, 'bad-node-version'); + const { status, stdout } = runScript(cwd, ['--json']); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch (err) { + assert.fail(`--json output was not valid JSON: ${err.message}\nstdout: ${stdout}`); + } + assert.equal(parsed.pass, false, 'failure fixture must report pass:false'); + assert.equal(status, 1, `Expected exit 1 with --json on failure fixture, got ${status}`); + // The node-version check must be present and marked fail + const nodeCheck = parsed.checks.find((c) => c.name === 'node-version'); + assert.ok(nodeCheck, 'node-version check must appear in JSON output'); + assert.equal(nodeCheck.status, 'fail', `Expected node-version status=fail, got ${nodeCheck.status}`); + }); + + // ------------------------------------------------------------------------- + // Test 6: Integration smoke — script runs without tool-error on live root + // + // Verifies the script executes against a real repo without a tool error (exit 2). + // Exit 0 or 1 are acceptable — local Node may differ from the .nvmrc pin (22). + // Uses --json for structured assertion, avoiding raw output-grep. + // ------------------------------------------------------------------------- + test('script runs without tool error on the live worktree root (--json)', () => { + const { status, stdout, stderr } = runScript(LIVE_ROOT, ['--json']); + assert.notEqual( + status, 2, + `Expected exit 0 or 1 on live repo, got exit 2 (tool error).\nstdout: ${stdout}\nstderr: ${stderr}` + ); + let parsed; + try { + parsed = JSON.parse(stdout); + } catch (err) { + assert.fail(`Live repo --json was not valid JSON: ${err.message}\nstdout: ${stdout}`); + } + assert.equal(typeof parsed.pass, 'boolean', 'Live repo JSON must have boolean pass'); + assert.ok(Array.isArray(parsed.checks), 'Live repo JSON must have checks array'); + // Node version check must be present and pass (Node >=22 is installed) + const nodeCheck = parsed.checks.find((c) => c.name === 'node-version'); + assert.ok(nodeCheck, 'node-version check must be present in live repo output'); + assert.equal(nodeCheck.status, 'pass', `node-version should pass on live repo, got: ${nodeCheck.status} — ${nodeCheck.message}`); + // Lockfile checks must pass on the live repo + const lockfileCheck = parsed.checks.find((c) => c.name === 'lockfile-present'); + assert.ok(lockfileCheck, 'lockfile-present check must appear in live output'); + assert.equal(lockfileCheck.status, 'pass', `lockfile-present should pass on live repo`); + }); +}); diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index 6dad3a829..dd10b4299 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -153,6 +153,13 @@ const INTERNAL_COMPONENT_SLUGS = new Set([ // docs/discussions/grok-build-support-2026-05.md. The regex captures // "/gsd-sync-skills" from the path. Invoked via Skill(skill="gsd-sync-skills"). 'sync-skills', + + // gsd-test-runner — GitHub repository name: "github.com/open-gsd/gsd-test-runner". + // docs/contributing/bootstrap.md references it as a hyperlink target: + // [gsd-test-runner](https://github.com/open-gsd/gsd-test-runner) + // The regex captures "/gsd-test-runner" from the URL path component. This is + // an external tool repo, not a user-typable slash command in this product. + 'test-runner', ]); /** diff --git a/tests/fixtures/check-env/bad-node-version/package-lock.json b/tests/fixtures/check-env/bad-node-version/package-lock.json new file mode 100644 index 000000000..5761af4e5 --- /dev/null +++ b/tests/fixtures/check-env/bad-node-version/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "check-env-fixture-bad-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "check-env-fixture-bad-node", + "version": "1.0.0", + "engines": { + "node": "<14.0.0" + } + } + } +} diff --git a/tests/fixtures/check-env/bad-node-version/package.json b/tests/fixtures/check-env/bad-node-version/package.json new file mode 100644 index 000000000..62ce5b0d3 --- /dev/null +++ b/tests/fixtures/check-env/bad-node-version/package.json @@ -0,0 +1,7 @@ +{ + "name": "check-env-fixture-bad-node", + "version": "1.0.0", + "engines": { + "node": "<14.0.0" + } +} diff --git a/tests/fixtures/check-env/bad-nvmrc/.nvmrc b/tests/fixtures/check-env/bad-nvmrc/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/tests/fixtures/check-env/bad-nvmrc/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/tests/fixtures/check-env/bad-nvmrc/package-lock.json b/tests/fixtures/check-env/bad-nvmrc/package-lock.json new file mode 100644 index 000000000..97f7e724f --- /dev/null +++ b/tests/fixtures/check-env/bad-nvmrc/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "check-env-fixture-bad-nvmrc", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "check-env-fixture-bad-nvmrc", + "version": "1.0.0", + "engines": { + "node": ">=22.0.0" + } + } + } +} diff --git a/tests/fixtures/check-env/bad-nvmrc/package.json b/tests/fixtures/check-env/bad-nvmrc/package.json new file mode 100644 index 000000000..8c3ca6a54 --- /dev/null +++ b/tests/fixtures/check-env/bad-nvmrc/package.json @@ -0,0 +1,7 @@ +{ + "name": "check-env-fixture-bad-nvmrc", + "version": "1.0.0", + "engines": { + "node": ">=22.0.0" + } +} diff --git a/tests/fixtures/check-env/good/.nvmrc b/tests/fixtures/check-env/good/.nvmrc new file mode 100644 index 000000000..6f4247a62 --- /dev/null +++ b/tests/fixtures/check-env/good/.nvmrc @@ -0,0 +1 @@ +26 diff --git a/tests/fixtures/check-env/good/package-lock.json b/tests/fixtures/check-env/good/package-lock.json new file mode 100644 index 000000000..20b6d5ac7 --- /dev/null +++ b/tests/fixtures/check-env/good/package-lock.json @@ -0,0 +1,16 @@ +{ + "name": "check-env-fixture-good", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "check-env-fixture-good", + "version": "1.0.0", + "engines": { + "node": ">=22.0.0", + "npm": ">=9.0.0" + } + } + } +} diff --git a/tests/fixtures/check-env/good/package.json b/tests/fixtures/check-env/good/package.json new file mode 100644 index 000000000..aa8cdce34 --- /dev/null +++ b/tests/fixtures/check-env/good/package.json @@ -0,0 +1,8 @@ +{ + "name": "check-env-fixture-good", + "version": "1.0.0", + "engines": { + "node": ">=22.0.0", + "npm": ">=9.0.0" + } +} diff --git a/tests/fixtures/check-env/missing-lockfile/package.json b/tests/fixtures/check-env/missing-lockfile/package.json new file mode 100644 index 000000000..11453fcde --- /dev/null +++ b/tests/fixtures/check-env/missing-lockfile/package.json @@ -0,0 +1,7 @@ +{ + "name": "check-env-fixture-missing-lockfile", + "version": "1.0.0", + "engines": { + "node": ">=22.0.0" + } +}