fix(#431): enforce H1 shell policy (linux=bash, macOS=zsh, windows=pwsh) across PR + release gates (#434)

* test(#431): policy-shell-pinning linter — RED baseline (37 violations on origin/next)

Adds scripts/workflow-policy.cjs: H1 shell-policy linter with POLICY map,
VIOLATION enum, matrix expansion, effective-shell resolution order, and
runPolicyLint({ workflowsDir }) entry point.

Adds tests/policy-shell-pinning.test.cjs: 8 tests (baseline + 6 synthetic
counter-tests). Synthetic tests 2–7 pass; baseline test is intentionally RED
(37 violations: 28 in test.yml, 9 in install-smoke.yml — all macos/windows
lanes using shell: bash instead of native zsh/pwsh).

Adds js-yaml@4.1.1 as devDependency for YAML parsing.

* fix(#431): switch ubuntu/windows lanes to native shells; extract bash-isms to Node

Remove all explicit shell: bash pins from ubuntu-only jobs (changes, lint-tests,
coverage, required-tests, smoke-unpacked) — ubuntu runner default is bash, which
is both H1-compliant and the runner default, making the pin redundant.

For the test and test-full mixed-OS jobs (ubuntu+windows, windows+macos):
- Move bash-ism steps to shell-agnostic Node scripts:
    scripts/ci-guard-runner.cjs       — RUNNER_ENVIRONMENT check
    scripts/ci-rebase-check.cjs       — git fetch+merge PR base branch
    scripts/check-npm-integrity.cjs   — Node port of check-npm-integrity.sh
    scripts/ci-prepare-test-scope.cjs — write .ci-selected-tests.txt
    scripts/ci-smoke-skip.cjs         — set skip= output for full-only matrix entries
- Remove shell: bash from simple npm/node command steps (runner default applies)

This brings Windows violations from 19 to 0. Remaining 17 violations are all
MACOS_MISSING_EXPLICIT_ZSH in mixed-OS matrix jobs (test-full: windows+macos,
install-smoke smoke: ubuntu+macos) — these require job splitting to fix; see
BLOCKER in PR description.

* fix(#431): update workflow-shell-pinning test for H1 policy

The old test required all Windows-targeting npm steps to pin shell: bash
(to prevent pwsh stderr-swallow). Under H1, Windows runners must use
pwsh (native, no pin needed) — shell: bash on Windows is now the
violation, not the fix.

Update findViolations() to flag npm steps with effectiveShell === 'bash'
(rather than effectiveShell === null). Update synthetic tests to verify
the H1-inverted semantics: defaults.run.shell: bash on Windows is now 2
violations, not 0. Update test name and assertion messages to describe
the H1 constraint rather than the old missing-pin constraint.

* fix(#431): extend policy linter to resolve matrix.shell expressions

- expandRunsOn now captures all matrix.include row keys as realization
  context (os, node-version, shell, full_only, etc.) instead of only os
- effectiveShell now accepts a realizationContext and resolves
  ${{ matrix.<key> }} expressions against it before checking policy
- Unresolvable matrix key in shell expression emits UNRESOLVABLE_MATRIX
- Add 3 new tests: positive (zsh+pwsh per row → 0 violations),
  counter (bash in macOS row → WRONG_SHELL_FOR_OS), counter (missing
  shell key → UNRESOLVABLE_MATRIX)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): apply matrix.shell pattern to test-full and smoke jobs (clears BLOCKER)

test-full job (test.yml):
- Add shell: pwsh/zsh per matrix.include row (windows-latest→pwsh,
  macos-latest→zsh)
- Add job-level defaults.run.shell: ${{ matrix.shell }}
- No step-level shell pins existed to remove

smoke job (install-smoke.yml):
- Add shell: bash/zsh per matrix.include row (ubuntu→bash, macos→zsh)
- Add job-level defaults.run.shell: ${{ matrix.shell }}
- No step-level shell pins existed to remove

Policy linter now reports 0 violations across all workflow files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#431): migrate .sh check scripts to .cjs; remove .sh originals

- Add scripts/check-env.cjs: Node.js port of check-env.sh with
  identical exit codes (0/1/2), human-readable and --json output,
  --help flag, and all 5 checks (node-version, npm-version,
  lockfile-present, lockfile-sync, version-manager-pin)
- Migrate all callers:
  - package.json check:env → node scripts/check-env.cjs
  - package.json check:integrity → node scripts/check-npm-integrity.cjs
  - scripts/ci-test-scope.cjs path strings → .cjs equivalents
  - .github/workflows/release.yml rc+finalize jobs → node .cjs (drop chmod+x)
  - .github/workflows/security-scan.yml → node .cjs (drop chmod+x)
  - tests/check-env.test.cjs → spawn node process.execPath [.cjs]
  - tests/npm-integrity-gate.test.cjs → spawn node process.execPath [.cjs]
- Delete scripts/check-env.sh and scripts/check-npm-integrity.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(#431): update doc references from .sh to .cjs

Update SECURITY.md and docs/contributing/bootstrap.md to reference the
canonical Node invocation instead of the removed bash scripts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): use per-step shell:matrix.shell instead of defaults.run.shell (GHA compat)

GHA does not reliably resolve matrix expressions inside defaults.run.shell.
Per-step shell: always resolves correctly. Removed the defaults.run.shell block
from the test-full job (test.yml) and the smoke job (install-smoke.yml), and
added shell: \${{ matrix.shell }} directly on every run: step in both jobs.

Codex finding: defaults.run.shell with matrix expressions is not a
GHA-supported pattern; per-step shell: is the safe form.

* fix(#431): policy linter validates every matrix.include row independently

Removed runner-label-only dedup from expandRunsOn() in workflow-policy.cjs.
The prior guard (if !realizations.find(r => r.runner === runner)) collapsed
two macos-latest rows with different node-version/shell contexts into one,
hiding the second row's policy violation.

Each matrix.include row is a distinct CI realization with its own context;
validating it twice is harmless but skipping it causes false negatives.

Added counter-test (Test 8) in tests/policy-shell-pinning.test.cjs:
two macos-latest rows (shell:zsh compliant + shell:bash violation) must
produce exactly one WRONG_SHELL_FOR_OS violation on the second row.

* fix(#431): remove dedup-by-runner in Cartesian matrix.<key> expansion (Codex round 3)

The base-list path in expandRunsOn (matrix.<key> arrays, e.g. matrix.os)
previously guarded each push with `if (!realizations.find(r => r.runner === runner))`,
collapsing duplicate runner values into a single realization and hiding policy
violations on later rows of a Cartesian matrix.

Remove the guard unconditionally; each entry in the base-list array now produces
its own realization, matching the same fix already applied to the matrix.include path.

Add counter-test "Cartesian matrix os × shell — dedup must not collapse rows by
runner alone": matrix.os: [macos-latest, macos-latest] + shell: ${{ matrix.shell }}
now yields 2 realizations (not 1). Documents that Cartesian cross-product expansion
(carrying all keys into realization context) is a separate follow-up; current violations
are UNRESOLVABLE_MATRIX pending that work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): remove 60s timeout regression on npm ci --dry-run (parity with check-env.sh)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#431): ci-rebase-check.cjs — return truthy sentinel on success (Codex round 4)

run() used execFileSync with stdio:'inherit', which returns null on success.
Caller checked `result !== null`, always false → every successful fetch fell
through to "failed after 3 attempts" exit-1 path.

Fix: run() now returns true on success, false on failure.
Update caller from `result !== null` to `if (result)`.

Adds tests/ci-rebase-check.test.cjs (5 tests) covering the sentinel contract
and a local-bare-remote integration smoke that verifies the full fetch+merge
path exits 0 when fetch succeeds.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: CI Rebase Check <ci@gsd-redux>
This commit is contained in:
Tom Boucher
2026-05-28 09:23:59 -04:00
committed by GitHub
parent a5eceb1faf
commit 48b1e35187
23 changed files with 2060 additions and 955 deletions

297
scripts/check-env.cjs Normal file
View File

@@ -0,0 +1,297 @@
#!/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 { execFileSync, spawnSync } = require('child_process');
// ---------------------------------------------------------------------------
// 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'
);
process.exit(0);
} else {
process.stderr.write(`Unknown option: ${arg}\n`);
process.exit(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`);
process.exit(2);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */
const checks = [];
function addCheck(name, status, message) {
checks.push({ name, status, message });
}
/**
* 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) {
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 '';
}
}
// ---------------------------------------------------------------------------
// Check 1: Node version vs engines.node
// ---------------------------------------------------------------------------
const enginesNode = pkgField('engines.node');
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');
let currentNpm = '';
try {
const res = spawnSync('npm', ['--version'], { encoding: 'utf8', timeout: 10_000 });
if (res.status === 0 && res.stdout) {
currentNpm = res.stdout.trim();
}
} catch { /* ignore */ }
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');
} 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 {
const res = spawnSync('npm', ['ci', '--dry-run'], {
cwd: PROJECT_ROOT,
encoding: 'utf8',
});
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');
}
}
process.exit(overallPass ? 0 : 1);

View File

@@ -1,332 +0,0 @@
#!/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.Y.Z, =X.Y.Z, X.Y.Z
# Also supports npm range: >=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

View File

@@ -0,0 +1,209 @@
'use strict';
// check-npm-integrity.cjs — Node.js port of scripts/check-npm-integrity.sh
// Shell-agnostic replacement for the "Dependency integrity gate" CI step.
// Invoked as: node scripts/check-npm-integrity.cjs [--ignore-extraneous]
//
// Parses package-lock.json in cwd and exits non-zero if any package is:
// INVALID — resolved version does not satisfy declared semver range
// MISSING — declared in package.json but absent from lockfile packages map
// EXTRANEOUS — marked extraneous: true in lockfile (unless --ignore-extraneous)
//
// Exit codes:
// 0 = clean
// 1 = integrity drift detected
// 2 = tool error (lockfile missing, JSON parse failure, unknown arg)
const fs = require('fs');
const path = require('path');
// ---- Argument parsing -------------------------------------------------------
let ignoreExtraneous = false;
for (const arg of process.argv.slice(2)) {
if (arg === '--ignore-extraneous') {
ignoreExtraneous = true;
} else if (arg === '--help' || arg === '-h') {
process.stdout.write(
'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n'
);
process.exit(0);
} else {
process.stderr.write(`ERROR: Unknown argument: ${arg}\n`);
process.exit(2);
}
}
// ---- Locate lockfile --------------------------------------------------------
const lockfilePath = path.join(process.cwd(), 'package-lock.json');
if (!fs.existsSync(lockfilePath)) {
process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`);
process.exit(2);
}
// ---- Parse lockfile ---------------------------------------------------------
let lock;
try {
lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8'));
} catch (e) {
process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`);
process.exit(2);
}
const lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
process.stderr.write(
`ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` +
'Run `npm install` to upgrade to v3.\n'
);
process.exit(2);
}
const packages = lock.packages || {};
const rootEntry = packages[''] || {};
// Collect declared dependency ranges from root entry.
const declaredRanges = {};
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
for (const [name, range] of Object.entries(rootEntry[field] || {})) {
if (!declaredRanges[name]) declaredRanges[name] = range;
}
}
// ---- Minimal semver satisfies -----------------------------------------------
function parseVersion(v) {
const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
function cmpVersion(a, b) {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function satisfies(installed, range) {
range = String(range).trim();
if (!range || range === '*' || range === 'latest') return true;
if (/^\d/.test(range)) {
const iv = parseVersion(installed);
const rv = parseVersion(range);
if (!iv || !rv) return installed === range;
return cmpVersion(iv, rv) === 0;
}
if (range[0] === '^') {
const base = parseVersion(range.slice(1));
const inst = parseVersion(installed);
if (!base || !inst) return false;
if (cmpVersion(inst, base) < 0) return false;
if (base[0] > 0) return inst[0] === base[0];
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
}
if (range[0] === '~') {
const tbase = parseVersion(range.slice(1));
const tinst = parseVersion(installed);
if (!tbase || !tinst) return false;
if (cmpVersion(tinst, tbase) < 0) return false;
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
}
const opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
if (opMatch) {
const op = opMatch[1];
const ov = parseVersion(opMatch[2]);
const iv2 = parseVersion(installed);
if (!ov || !iv2) return false;
const c = cmpVersion(iv2, ov);
if (op === '>=') return c >= 0;
if (op === '<=') return c <= 0;
if (op === '>') return c > 0;
if (op === '<') return c < 0;
if (op === '=') return c === 0;
}
if (range.includes(' ')) {
return range.split(/\s+/).every(part => satisfies(installed, part));
}
return installed === range;
}
// ---- Walk packages map ------------------------------------------------------
const invalids = [];
const missings = [];
const extraneousFound = [];
for (const [key, entry] of Object.entries(packages)) {
if (!key.startsWith('node_modules/')) continue;
const rest = key.slice('node_modules/'.length);
const isScoped = rest[0] === '@';
const slashCount = (rest.match(/\//g) || []).length;
if (isScoped && slashCount > 1) continue;
if (!isScoped && slashCount > 0) continue;
const pkgName = rest;
const installedVersion = entry.version || '';
if (entry.extraneous) {
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
}
if (!declaredRanges[pkgName]) continue;
if (!satisfies(installedVersion, declaredRanges[pkgName])) {
invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] });
}
}
for (const name of Object.keys(declaredRanges)) {
if (!packages[`node_modules/${name}`]) {
missings.push({ name, required: declaredRanges[name] });
}
}
// ---- Verdict ----------------------------------------------------------------
const failInvalid = invalids.length > 0;
const failMissing = missings.length > 0;
const failExtra = !ignoreExtraneous && extraneousFound.length > 0;
if (!failInvalid && !failMissing && !failExtra) {
process.stderr.write('check-npm-integrity.cjs: clean\n');
process.exit(0);
}
const lines = ['FAIL: dependency integrity drift detected', ''];
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (const { name, declared, version } of invalids) {
lines.push(` ${name}: declared=${declared} installed=${version}`);
}
lines.push('');
}
if (failMissing) {
lines.push(' MISSING (declared but absent from lockfile packages map):');
for (const { name, required } of missings) {
lines.push(` ${name}@${required}`);
}
lines.push('');
}
if (failExtra) {
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (const { name, version } of extraneousFound) {
lines.push(` ${name}@${version}`);
}
lines.push('');
}
lines.push('Remediation: rm -rf node_modules && npm ci');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);

View File

@@ -1,369 +0,0 @@
#!/usr/bin/env bash
# check-npm-integrity.sh -- Enforce npm dependency integrity baseline
#
# Detects invalid, missing, and extraneous packages by parsing the
# package-lock.json in the current directory (lockfileVersion 2 or 3).
# Exits non-zero when any integrity problem is found, emitting a structured
# report to stderr listing every offender.
#
# Source: https://docs.npmjs.com/cli/v10/commands/npm-ls
# lockfileVersion 3 "packages" map records every resolved package under
# "node_modules/<name>" with its resolved version. The root entry "" holds
# the declared dependency ranges. This script compares the two without
# requiring node_modules to be present on disk, making it safe to run in
# CI environments before `npm ci`.
#
# Detection rules (all derived from package-lock.json):
# - MISSING : declared in root dependencies but absent from packages map
# - INVALID : in packages map but installed version does not satisfy the
# declared semver range
# - EXTRANEOUS : appears in packages map with "extraneous": true, OR present
# in packages map but not declared in any root dependency field
#
# Workspace behaviour: if the root package.json declares a "workspaces" field,
# npm ls traverses all workspace packages automatically (npm >=7). This script
# runs at the directory where it is invoked; for workspace repos, invoke from
# the root. The sdk/ sub-package in this repo is NOT a declared workspace and
# is therefore out of scope for this single invocation.
#
# Security context:
# NIST SSDF PW.4.1 -- use components from well-governed, secure sources:
# https://csrc.nist.gov/publications/detail/sp/800-218/final
# OpenSSF Scorecard "Pinned-Dependencies" rationale:
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#pinned-dependencies
#
# Usage:
# ./scripts/check-npm-integrity.sh [--ignore-extraneous] [--help]
#
# Exit codes:
# 0 = clean (no integrity problems, or only extraneous and --ignore-extraneous set)
# 1 = integrity drift detected (invalid, missing, or extraneous packages)
# 2 = tool error (node not found, JSON parse failure, missing lockfile, or usage error)
set -euo pipefail
SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")"
IGNORE_EXTRANEOUS=false
# ---- Usage ------------------------------------------------------------------
usage() {
cat >&2 <<'USAGE'
Usage: check-npm-integrity.sh [OPTIONS]
Verify that the npm lockfile in the current directory is internally consistent.
Parses package-lock.json and fails if any package is:
- invalid (resolved version does not satisfy the declared semver range)
- missing (declared in package.json / lockfile root but absent from packages map)
- extraneous (present in packages map but not declared as a dependency,
or marked extraneous: true in the lockfile)
Options:
--ignore-extraneous Allow extraneous packages; only fail on invalid/missing
--help Print this help message and exit 0
Exit codes:
0 Clean -- no integrity problems
1 Drift detected -- see stderr for details
2 Tool error -- node not found, JSON parse failure, missing lockfile, or usage error
Remediation:
rm -rf node_modules && npm ci
Sources:
npm lockfile docs: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json
NIST SSDF PW.4.1: https://csrc.nist.gov/publications/detail/sp/800-218/final
USAGE
}
# ---- Argument parsing -------------------------------------------------------
for arg in "$@"; do
case "$arg" in
--ignore-extraneous)
IGNORE_EXTRANEOUS=true
;;
--help|-h)
usage
exit 0
;;
*)
echo "ERROR: Unknown argument: $arg" >&2
usage
exit 2
;;
esac
done
# ---- Prerequisite check -----------------------------------------------------
if ! command -v node >/dev/null 2>&1; then
echo "ERROR: node not found on PATH" >&2
exit 2
fi
# ---- Locate lockfile --------------------------------------------------------
LOCKFILE="$(pwd)/package-lock.json"
if [ ! -f "$LOCKFILE" ]; then
echo "ERROR: package-lock.json not found in $(pwd)" >&2
exit 2
fi
# ---- Parse lockfile via Node.js ---------------------------------------------
#
# Write the parser to a temp file to avoid heredoc/stdin conflicts.
# The lockfile path and ignore-extraneous flag are passed as argv.
GATE_TMP=$(mktemp -d)
trap 'rm -rf "$GATE_TMP"' EXIT
cat > "$GATE_TMP/parser.js" << 'ENDPARSER'
'use strict';
// argv[2] = absolute path to package-lock.json
// argv[3] = "true"|"false" for --ignore-extraneous
var fs = require('fs');
var path = require('path');
var lockfilePath = process.argv[2];
var ignoreExtraneous = process.argv[3] === 'true';
var raw;
try {
raw = fs.readFileSync(lockfilePath, 'utf-8');
} catch (e) {
process.stderr.write('ERROR: Cannot read ' + lockfilePath + ': ' + e.message + '\n');
process.exit(2);
}
var lock;
try {
lock = JSON.parse(raw);
} catch (e) {
process.stderr.write('ERROR: Failed to parse package-lock.json: ' + e.message + '\n');
process.exit(2);
}
// Only lockfileVersion 2+ uses the "packages" map we rely on.
var lockVersion = lock.lockfileVersion || 1;
if (lockVersion < 2) {
process.stderr.write(
'ERROR: package-lock.json lockfileVersion ' + lockVersion +
' is not supported. Run `npm install` to upgrade to v3.\n'
);
process.exit(2);
}
var packages = lock.packages || {};
var rootEntry = packages[''] || {};
// Collect all declared dependency ranges from root entry.
// Include dependencies, devDependencies, optionalDependencies, peerDependencies.
var declaredRanges = {};
var depFields = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'];
for (var fi = 0; fi < depFields.length; fi++) {
var field = depFields[fi];
var deps = rootEntry[field] || {};
var depNames = Object.keys(deps);
for (var di = 0; di < depNames.length; di++) {
var dname = depNames[di];
if (!declaredRanges[dname]) {
declaredRanges[dname] = deps[dname];
}
}
}
// ---- Minimal semver satisfies implementation --------------------------------
// Supports: exact version ("1.2.3"), caret ("^1.2.3"), tilde ("~1.2.3"),
// comparison operators (">=1.0.0 <2.0.0"), and wildcards ("*", "").
// For the integrity gate use-case (comparing lockfile resolved vs declared),
// full semver is rarely needed — exact-version and simple ranges cover ~99%.
function parseVersion(v) {
var m = String(v).match(/^(\d+)\.(\d+)\.(\d+)/);
if (!m) return null;
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)];
}
function cmpVersion(a, b) {
for (var i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function satisfies(installed, range) {
range = String(range).trim();
// Wildcard / empty
if (!range || range === '*' || range === 'latest') return true;
// Exact version (no operator)
if (/^\d/.test(range)) {
var iv = parseVersion(installed);
var rv = parseVersion(range);
if (!iv || !rv) return installed === range;
return cmpVersion(iv, rv) === 0;
}
// Caret range: ^X.Y.Z → >=X.Y.Z <(X+1).0.0 (major must match for X>0)
if (range.charAt(0) === '^') {
var base = parseVersion(range.slice(1));
var inst = parseVersion(installed);
if (!base || !inst) return false;
if (cmpVersion(inst, base) < 0) return false;
if (base[0] > 0) return inst[0] === base[0];
if (base[1] > 0) return inst[0] === 0 && inst[1] === base[1];
return inst[0] === 0 && inst[1] === 0 && inst[2] === base[2];
}
// Tilde range: ~X.Y.Z → >=X.Y.Z <X.(Y+1).0
if (range.charAt(0) === '~') {
var tbase = parseVersion(range.slice(1));
var tinst = parseVersion(installed);
if (!tbase || !tinst) return false;
if (cmpVersion(tinst, tbase) < 0) return false;
return tinst[0] === tbase[0] && tinst[1] === tbase[1];
}
// Simple comparison operators: >=, <=, >, <, =
var opMatch = range.match(/^(>=|<=|>|<|=)\s*(.+)/);
if (opMatch) {
var op = opMatch[1];
var ov = parseVersion(opMatch[2]);
var iv2 = parseVersion(installed);
if (!ov || !iv2) return false;
var c = cmpVersion(iv2, ov);
if (op === '>=') return c >= 0;
if (op === '<=') return c <= 0;
if (op === '>') return c > 0;
if (op === '<') return c < 0;
if (op === '=') return c === 0;
}
// Compound range (space-separated, e.g. ">=1.0.0 <2.0.0")
if (range.indexOf(' ') !== -1) {
var parts = range.split(/\s+/);
for (var pi = 0; pi < parts.length; pi++) {
if (!satisfies(installed, parts[pi])) return false;
}
return true;
}
// Fallback: string equality
return installed === range;
}
// ---- Walk packages map ------------------------------------------------------
var invalids = [];
var missings = [];
var extraneousFound = [];
// Check each node_modules/<name> entry in the lockfile.
var pkgKeys = Object.keys(packages);
for (var ki = 0; ki < pkgKeys.length; ki++) {
var key = pkgKeys[ki];
if (!key.startsWith('node_modules/')) continue;
// Skip workspace sub-packages (contain a second slash after node_modules/)
var rest = key.slice('node_modules/'.length);
// Scoped packages (@scope/name) have one slash; skip nested like node_modules/a/node_modules/b
var slashCount = (rest.match(/\//g) || []).length;
var isScoped = rest.charAt(0) === '@';
if (isScoped && slashCount > 1) continue;
if (!isScoped && slashCount > 0) continue;
var pkgName = rest;
var entry = packages[key];
var installedVersion = entry.version || '';
// Explicitly marked extraneous by npm in the lockfile.
if (entry.extraneous) {
extraneousFound.push({ name: pkgName, version: installedVersion });
continue;
}
// Not declared in root deps → this is a transitive dependency (installed
// because a non-root package requires it). Transitive deps are valid even
// if absent from root declarations. Skip the range check too — we have no
// declared range to compare against. npm's own "extraneous: true" marker
// (handled above) is the authoritative signal for unwanted packages.
if (!declaredRanges[pkgName]) {
continue;
}
// Declared — check version satisfies range.
var declaredRange = declaredRanges[pkgName];
if (!satisfies(installedVersion, declaredRange)) {
invalids.push({
name: pkgName,
version: installedVersion,
declared: declaredRange,
});
}
}
// Check for declared deps that have no lockfile entry (MISSING).
var declaredNames = Object.keys(declaredRanges);
for (var mi = 0; mi < declaredNames.length; mi++) {
var mname = declaredNames[mi];
var lockKey = 'node_modules/' + mname;
if (!packages[lockKey]) {
missings.push({ name: mname, required: declaredRanges[mname] });
}
}
// ---- Verdict ----------------------------------------------------------------
var failInvalid = invalids.length > 0;
var failMissing = missings.length > 0;
var failExtra = !ignoreExtraneous && extraneousFound.length > 0;
if (!failInvalid && !failMissing && !failExtra) {
process.exit(0);
}
var lines = [];
lines.push('FAIL: dependency integrity drift detected');
lines.push('');
if (failInvalid) {
lines.push(' INVALID (installed version does not satisfy declared range):');
for (var j = 0; j < invalids.length; j++) {
var iv = invalids[j];
lines.push(' ' + iv.name + ': declared=' + iv.declared + ' installed=' + iv.version);
}
lines.push('');
}
if (failMissing) {
lines.push(' MISSING (declared but absent from lockfile packages map):');
for (var k = 0; k < missings.length; k++) {
var mv = missings[k];
lines.push(' ' + mv.name + '@' + mv.required);
}
lines.push('');
}
if (failExtra) {
lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):');
for (var l = 0; l < extraneousFound.length; l++) {
var ev = extraneousFound[l];
lines.push(' ' + ev.name + '@' + ev.version);
}
lines.push('');
}
lines.push('Remediation: rm -rf node_modules && npm ci');
process.stderr.write(lines.join('\n') + '\n');
process.exit(1);
ENDPARSER
PARSE_EXIT=0
node "$GATE_TMP/parser.js" "$LOCKFILE" "$IGNORE_EXTRANEOUS" 2>"$GATE_TMP/parse_err" || PARSE_EXIT=$?
# Forward parser stderr to our stderr
if [ -s "$GATE_TMP/parse_err" ]; then
cat "$GATE_TMP/parse_err" >&2
fi
if [ "$PARSE_EXIT" -eq 0 ]; then
echo "$SCRIPT_NAME: clean" >&2
fi
exit "$PARSE_EXIT"

View File

@@ -0,0 +1,16 @@
'use strict';
// ci-guard-runner.cjs — Assert the current runner is github-hosted.
// Replaces the inline bash "Guard — require GitHub-hosted runner" step.
// Shell-agnostic: invoked as `node scripts/ci-guard-runner.cjs` from any shell.
//
// Exit 0 = github-hosted runner confirmed.
// Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation).
const env = process.env.RUNNER_ENVIRONMENT || '';
if (env !== 'github-hosted') {
process.stderr.write(
`::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n`
);
process.exit(1);
}

View File

@@ -0,0 +1,46 @@
'use strict';
// ci-prepare-test-scope.cjs — Write .ci-selected-tests.txt for scoped CI runs.
// Replaces the inline bash "Prepare scoped test list" step.
// Shell-agnostic: invoked as `node scripts/ci-prepare-test-scope.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// TEST_SCOPE — "windows" | "targeted"
// TARGETED_TESTS — space-separated test file list (from ci-test-scope.cjs output)
// WINDOWS_TESTS — space-separated test file list for the windows lane
//
// Writes: .ci-selected-tests.txt (one file per line, no blanks)
// Exit 0 = success; exit 1 = unknown scope.
const fs = require('fs');
const path = require('path');
const scope = process.env.TEST_SCOPE || '';
const targeted = process.env.TARGETED_TESTS || '';
const windows = process.env.WINDOWS_TESTS || '';
const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
let selected;
if (scope === 'windows') {
selected = windows;
} else if (scope === 'targeted') {
selected = targeted;
} else {
process.stderr.write(`::error::Unknown test scope: ${scope}\n`);
process.exit(1);
}
// Trim and fall back to default set if empty.
if (!selected.trim()) {
selected = FALLBACK;
}
// Split on whitespace, filter blanks, join with newlines.
const lines = selected.split(/\s+/).filter(Boolean);
const content = lines.join('\n') + '\n';
const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
fs.writeFileSync(outPath, content, 'utf-8');
process.stdout.write('Scoped tests:\n');
process.stdout.write(content);

View File

@@ -0,0 +1,85 @@
'use strict';
// ci-rebase-check.cjs — Merge the PR base branch into the current PR head.
// Replaces the inline bash "Rebase check — merge PR base branch into PR head" step.
// Shell-agnostic: invoked as `node scripts/ci-rebase-check.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// GITHUB_TOKEN — access token for remote set-url
// GITHUB_BASE_REF — PR base branch name (set by GitHub Actions on pull_request events)
// GITHUB_REPOSITORY — owner/repo (set by GitHub Actions)
//
// Exit 0 = merged cleanly (or merge was a no-op).
// Exit 1 = merge conflict or fetch failure.
const { execFileSync, execSync } = require('child_process');
const path = require('path');
function run(cmd, args, opts) {
try {
execFileSync(cmd, args, { stdio: 'inherit', ...opts });
return true; // success sentinel; execFileSync returns null with stdio:'inherit'
} catch (e) {
return false;
}
}
function runOrThrow(cmd, args, label) {
try {
execFileSync(cmd, args, { stdio: 'inherit' });
} catch (e) {
process.stderr.write(`::error::${label} failed\n`);
process.exit(1);
}
}
const token = process.env.GITHUB_TOKEN || '';
const baseBranch = process.env.GITHUB_BASE_REF || 'main';
const repo = process.env.GITHUB_REPOSITORY || '';
// Configure git identity (needed for merge commit).
runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email');
runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name');
// Set authenticated remote URL.
if (token && repo) {
runOrThrow(
'git',
['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`],
'git remote set-url'
);
}
// Fetch base branch with retry.
let fetched = false;
for (let attempt = 1; attempt <= 3; attempt++) {
const result = run('git', ['fetch', 'origin', baseBranch]);
if (result) {
fetched = true;
break;
}
if (attempt === 3) {
process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`);
process.exit(1);
}
// Wait before retry: attempt * 4 seconds.
const waitMs = attempt * 4000;
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) { /* busy wait, acceptable in CI */ }
}
// Attempt merge.
try {
execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' });
} catch (e) {
process.stderr.write(
`::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n`
);
process.stderr.write('::error::Conflicting files:\n');
try {
execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
try {
execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' });
} catch (_) { /* ignore */ }
process.exit(1);
}

27
scripts/ci-smoke-skip.cjs Normal file
View File

@@ -0,0 +1,27 @@
'use strict';
// ci-smoke-skip.cjs — Set the "skip" output for full-only matrix entries on PR events.
// Replaces the inline bash "Skip full-only matrix entry on PR" step.
// Shell-agnostic: invoked as `node scripts/ci-smoke-skip.cjs` from any shell.
//
// Required environment variables (set by the workflow step's `env:` block):
// EVENT — github.event_name value
// FULL_ONLY — matrix.full_only value ("true" | "false")
//
// Writes to GITHUB_OUTPUT:
// skip=true if EVENT == "pull_request" AND FULL_ONLY == "true"
// skip=false otherwise
const fs = require('fs');
const event = process.env.EVENT || '';
const fullOnly = process.env.FULL_ONLY || '';
const output = process.env.GITHUB_OUTPUT || '';
const skip = (event === 'pull_request' && fullOnly === 'true') ? 'true' : 'false';
if (output) {
fs.appendFileSync(output, `skip=${skip}\n`, 'utf-8');
} else {
// Fallback for local testing without GITHUB_OUTPUT set.
process.stdout.write(`skip=${skip}\n`);
}

View File

@@ -29,8 +29,8 @@ const RULES = [
{
name: 'environment and dependency gates',
match: path => [
'scripts/check-env.sh',
'scripts/check-npm-integrity.sh',
'scripts/check-env.cjs',
'scripts/check-npm-integrity.cjs',
'package.json',
'package-lock.json',
].includes(path),

445
scripts/workflow-policy.cjs Normal file
View File

@@ -0,0 +1,445 @@
'use strict';
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
// ---------------------------------------------------------------------------
// Policy: native shell per OS
// ---------------------------------------------------------------------------
const POLICY = Object.freeze({
'ubuntu-latest': 'bash',
'ubuntu-22.04': 'bash',
'ubuntu-24.04': 'bash',
'macos-latest': 'zsh',
'macos-13': 'zsh',
'macos-14': 'zsh',
'macos-15': 'zsh',
'windows-latest': 'pwsh',
'windows-2022': 'pwsh',
'windows-2025': 'pwsh',
});
const VIOLATION = Object.freeze({
WRONG_SHELL_FOR_OS: 'wrong_shell_for_os',
MACOS_MISSING_EXPLICIT_ZSH: 'macos_missing_explicit_zsh',
UNKNOWN_RUNNER: 'unknown_runner',
UNRESOLVABLE_MATRIX: 'unresolvable_matrix',
});
// ---------------------------------------------------------------------------
// Runner default (GitHub Actions documented defaults, not policy)
// ---------------------------------------------------------------------------
function runnerDefault(runner) {
if (!runner) return null;
if (runner.startsWith('windows-')) return 'pwsh';
return 'bash'; // ubuntu-* and macos-* both default to bash on GHA
}
// ---------------------------------------------------------------------------
// Matrix expansion
// ---------------------------------------------------------------------------
/**
* Expand a runs-on expression against a job's strategy.matrix.
* Returns an array of { runner: string, resolvable: boolean, context: object }
* objects where `context` holds ALL key→value pairs for the realization row
* (so shell expressions like ${{ matrix.shell }} can be resolved against it).
* 'resolvable: false' means the expression was an unresolved matrix ref.
*/
function expandRunsOn(runsOnRaw, matrix) {
if (!runsOnRaw) return [];
const raw = String(runsOnRaw).trim();
// Detect matrix expression: ${{ matrix.X }} or ${{ matrix['X'] }}
const matrixExprRe = /\$\{\{\s*matrix\.(\w+)\s*\}\}/;
const match = raw.match(matrixExprRe);
if (!match) {
// Literal runner label
return [{ runner: raw, resolvable: true, context: {} }];
}
const key = match[1];
if (!matrix) {
return [{ runner: raw, resolvable: false, context: {} }];
}
const realizations = [];
// matrix.include entries carry complete row context — prefer them as they
// contain all keys (os, node-version, shell, full_only, etc.).
// Each include row is a distinct CI realization and must be validated
// independently — even if two rows share the same runner label, their
// contexts (and therefore effective shells) may differ.
if (Array.isArray(matrix.include)) {
for (const entry of matrix.include) {
if (entry && entry[key] != null) {
const runner = String(entry[key]);
// Clone all keys from include row as the realization context
const context = {};
for (const [k, v] of Object.entries(entry)) {
context[k] = v != null ? String(v) : '';
}
realizations.push({ runner, resolvable: true, context });
}
}
}
// Collect values from matrix.<key> list (e.g. matrix.os: [ubuntu, macos])
// These base-list entries have no extra context beyond the key itself.
// Each entry is pushed unconditionally — deduplicating by runner alone
// would collapse distinct Cartesian rows (e.g. duplicate os values paired
// with different shell values) and hide policy violations on later rows.
if (Array.isArray(matrix[key])) {
for (const val of matrix[key]) {
const runner = String(val);
realizations.push({ runner, resolvable: true, context: { [key]: runner } });
}
}
// matrix.exclude: remove matches
if (Array.isArray(matrix.exclude)) {
for (const excl of matrix.exclude) {
if (excl && excl[key] != null) {
const exclRunner = String(excl[key]);
const idx = realizations.findIndex(r => r.runner === exclRunner);
if (idx !== -1) realizations.splice(idx, 1);
}
}
}
if (realizations.length === 0) {
// Could not resolve — no concrete values found
return [{ runner: raw, resolvable: false, context: {} }];
}
return realizations;
}
// ---------------------------------------------------------------------------
// Matrix expression resolution
// ---------------------------------------------------------------------------
/**
* If `expr` is a `${{ matrix.<key> }}` expression, look up the value in
* `realizationContext` (a plain-object snapshot of one matrix.include row).
* Returns:
* { resolved: true, value: string } — expression resolved to a concrete value
* { resolved: false, key: string } — matrix key absent in this realization
* null — `expr` is not a matrix expression
*/
function resolveMatrixExpr(expr, realizationContext) {
if (!expr || typeof expr !== 'string') return null;
const m = expr.match(/^\s*\$\{\{\s*matrix\.(\w+)\s*\}\}\s*$/);
if (!m) return null;
const key = m[1];
if (!realizationContext || !(key in realizationContext)) {
return { resolved: false, key };
}
return { resolved: true, value: String(realizationContext[key]) };
}
// ---------------------------------------------------------------------------
// Effective-shell resolution
// ---------------------------------------------------------------------------
/**
* Given a step's shell, job defaults, workflow defaults, runner, and the
* current matrix realization context, return the effective shell that will
* actually execute.
*
* Matrix expressions (`${{ matrix.shell }}`) in any shell field are resolved
* against `realizationContext` (a plain object of key→value for the current
* matrix.include row).
*
* Returns:
* { shell: string, unresolvable: false } — concrete shell value
* { shell: null, unresolvable: true, key: string } — matrix expr present but key missing
*/
function effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext) {
for (const raw of [stepShell, jobDefaultsShell, workflowDefaultsShell]) {
if (!raw) continue;
const mx = resolveMatrixExpr(raw, realizationContext);
if (mx !== null) {
// It's a matrix expression
if (!mx.resolved) {
return { shell: null, unresolvable: true, key: mx.key };
}
return { shell: mx.value, unresolvable: false };
}
// Literal value
return { shell: raw, unresolvable: false };
}
// Nothing set at any level — use runner default
return { shell: runnerDefault(runner), unresolvable: false };
}
// ---------------------------------------------------------------------------
// Violation detection
// ---------------------------------------------------------------------------
/**
* Determines whether a step/runner combination violates shell policy.
*
* `rawStepShell`, `rawJobDefaultsShell`, `rawWorkflowDefaultsShell` are the
* raw (possibly matrix-expression) values before resolution. They're used
* only for the MACOS_MISSING_EXPLICIT_ZSH sub-classification: that violation
* fires only when nothing is set at any level (all three are null/empty AND
* the runner default is wrong).
*/
function detectViolation(runner, resolvedShell, rawStepShell, rawJobDefaultsShell, rawWorkflowDefaultsShell) {
if (!(runner in POLICY)) {
return VIOLATION.UNKNOWN_RUNNER;
}
const expected = POLICY[runner];
if (resolvedShell !== expected) {
// Specific subtype for macOS missing explicit zsh:
// fires only when no shell is set at any level (inherited runner default).
if (runner.startsWith('macos-') && !rawStepShell && !rawJobDefaultsShell && !rawWorkflowDefaultsShell) {
return VIOLATION.MACOS_MISSING_EXPLICIT_ZSH;
}
return VIOLATION.WRONG_SHELL_FOR_OS;
}
return null;
}
// ---------------------------------------------------------------------------
// Source map: find line numbers
// ---------------------------------------------------------------------------
/**
* Find the line number of a string in YAML text.
* Returns 1-based line number of the first occurrence at or after startLine.
*/
function findLineNumber(yamlText, searchStr, startLine) {
const lines = yamlText.split('\n');
const start = Math.max(0, (startLine || 1) - 1);
for (let i = start; i < lines.length; i++) {
if (lines[i].includes(searchStr)) {
return i + 1;
}
}
// Fall back to scanning from beginning
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes(searchStr)) {
return i + 1;
}
}
return 1;
}
// ---------------------------------------------------------------------------
// Core inspector
// ---------------------------------------------------------------------------
/**
* inspectWorkflow(yamlText, { filePath }) → structured inspection result
*/
function inspectWorkflow(yamlText, { filePath = '<unknown>' } = {}) {
let doc;
try {
doc = yaml.load(yamlText, { schema: yaml.DEFAULT_SCHEMA });
} catch (e) {
return {
filePath,
jobs: [],
workflowDefaultsShell: null,
parseError: e.message,
};
}
if (!doc || typeof doc !== 'object') {
return { filePath, jobs: [], workflowDefaultsShell: null };
}
const workflowDefaultsShell =
doc.defaults?.run?.shell ?? null;
const jobs = [];
for (const [jobId, jobDef] of Object.entries(doc.jobs || {})) {
if (!jobDef || typeof jobDef !== 'object') continue;
const runsOnRaw = jobDef['runs-on'];
const matrix = jobDef.strategy?.matrix ?? null;
const jobDefaultsShell = jobDef.defaults?.run?.shell ?? null;
const runsOnStr = runsOnRaw != null ? String(runsOnRaw) : '';
const runsOnExpressions = [runsOnStr];
const runnerRealizations = expandRunsOn(runsOnStr, matrix);
const steps = [];
for (const [stepIndex, step] of (jobDef.steps || []).entries()) {
if (!step || typeof step !== 'object') continue;
// Only check steps that actually run shell scripts (have `run:`)
if (!step.run) continue;
const stepShell = step.shell ?? null;
const stepName = step.name ?? `step-${stepIndex}`;
for (const { runner, resolvable, context: realizationContext } of runnerRealizations) {
if (!resolvable) {
// Can't resolve runner — emit UNRESOLVABLE_MATRIX
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: null,
runner,
violation: VIOLATION.UNRESOLVABLE_MATRIX,
evidence: {
line: lineNum,
snippet: `runs-on: ${runsOnStr} (unresolvable matrix expression)`,
},
});
continue;
}
const effResult = effectiveShell(stepShell, jobDefaultsShell, workflowDefaultsShell, runner, realizationContext);
// If a matrix expression referenced a key not present in this realization row
if (effResult.unresolvable) {
const lineNum = findLineNumber(yamlText, stepName !== `step-${stepIndex}` ? stepName : String(step.run).slice(0, 20));
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: null,
runner,
violation: VIOLATION.UNRESOLVABLE_MATRIX,
evidence: {
line: lineNum,
snippet: `matrix.${effResult.key} not present in realization for runner=${runner}`,
},
});
continue;
}
const eff = effResult.shell;
const violation = detectViolation(runner, eff, stepShell, jobDefaultsShell, workflowDefaultsShell);
// Find evidence line: prefer step name, then shell:, then run: content
let evidenceLine = 1;
let evidenceSnippet = '';
if (stepName !== `step-${stepIndex}`) {
evidenceLine = findLineNumber(yamlText, stepName);
evidenceSnippet = `name: ${stepName}`;
} else if (stepShell) {
evidenceLine = findLineNumber(yamlText, `shell: ${stepShell}`);
evidenceSnippet = `shell: ${stepShell}`;
} else {
const runSnippet = String(step.run).split('\n')[0].slice(0, 40);
evidenceLine = findLineNumber(yamlText, runSnippet);
evidenceSnippet = runSnippet;
}
steps.push({
index: stepIndex,
name: stepName,
stepShell,
effectiveShell: eff,
runner,
violation: violation ?? null,
evidence: {
line: evidenceLine,
snippet: evidenceSnippet,
},
});
}
}
const resolvedRunners = runnerRealizations
.filter(r => r.resolvable)
.map(r => r.runner);
jobs.push({
jobId,
runsOnExpressions,
resolvedRunners,
defaultsShell: jobDefaultsShell,
steps,
});
}
return {
filePath,
jobs,
workflowDefaultsShell,
};
}
/**
* inspectWorkflowFile(absPath) — reads file from disk and calls inspectWorkflow.
*/
function inspectWorkflowFile(absPath) {
const text = fs.readFileSync(absPath, 'utf8');
return inspectWorkflow(text, { filePath: absPath });
}
// ---------------------------------------------------------------------------
// runPolicyLint
// ---------------------------------------------------------------------------
/**
* runPolicyLint({ workflowsDir }) → { violations, summary }
*/
function runPolicyLint({ workflowsDir }) {
const absDir = path.resolve(workflowsDir);
const files = fs.readdirSync(absDir)
.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'))
.map(f => path.join(absDir, f))
.sort();
const violations = [];
for (const filePath of files) {
const result = inspectWorkflowFile(filePath);
for (const job of result.jobs) {
for (const step of job.steps) {
if (step.violation) {
violations.push({
filePath: result.filePath,
jobId: job.jobId,
stepIndex: step.index,
stepName: step.name,
runner: step.runner,
effectiveShell: step.effectiveShell,
stepShell: step.stepShell,
violation: step.violation,
evidence: step.evidence,
});
}
}
}
}
const perViolationType = {};
for (const v of violations) {
perViolationType[v.violation] = (perViolationType[v.violation] || 0) + 1;
}
return {
violations,
summary: {
total: violations.length,
perViolationType,
},
};
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
module.exports = {
POLICY,
VIOLATION,
inspectWorkflow,
inspectWorkflowFile,
runPolicyLint,
};