* 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>
210 lines
6.6 KiB
JavaScript
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);
|