Files
msd-core/scripts/check-npm-integrity.cjs
Tom Boucher 48b1e35187 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>
2026-05-28 09:23:59 -04:00

210 lines
6.6 KiB
JavaScript

'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);