From efbe8a84ebc79d0c41a2f8067316349ec96c3c4c Mon Sep 17 00:00:00 2001 From: jdalton Date: Thu, 30 Jul 2026 11:51:26 -0400 Subject: [PATCH 1/2] feat(test): add Node builtin-module compatibility matrix harness Breadth sweep over every require("module").builtinModules entry, both import forms (M and node:M), against a pinned SRI-verified Node oracle (26.5.1). Compares Perry's export-SHAPE fingerprint (sorted name:typeof over the namespace + default export typeof) to Node's. Systematic form of tracker #812; behavioral parity stays in the node-suite. - external-tools.json: pin tools.node 26.5.1 with per-platform sha512 SRI (darwin/linux/win, arm64+x64), each cross-checked vs SHASUMS256.txt. scripts/soak/external-tools.mts learns a node-dist release type so the pin gate stays green without treating nodejs.org as a github release. - scripts/node_compat_matrix.mjs: resolver (download+verify+cache under .cache/node-pin/) + matrix runner. Modes: default table, --check gate, --update-baseline, and fast-loop selectors --module/--method/--only. - test-parity/node-compat-matrix.baseline.json: real darwin-arm64 baseline (58 builtins; 21 both-forms match, 35 shape-diff, 0 perry-unresolved, 0 prefix-parity divergences) + a curated skip list. - .github/workflows/node-compat-matrix.yml: own nightly --check job. - docs + CLAUDE.md + llms.txt: fast-loop-first usage and how to bump the pin. Findings recorded (not fixed here, infra PR): sea/sqlite/test/test/reporters are node:-prefix-only in Node 26 yet Perry resolves the bare form too; trace_events matches Node fully but is absent from the manifest module lists. --- .github/workflows/node-compat-matrix.yml | 84 ++ .gitignore | 4 + CLAUDE.md | 20 + changelog.d/0000-node-compat-matrix.md | 6 + docs/src/SUMMARY.md | 1 + docs/src/testing/node-compat-matrix.md | 115 ++ external-tools.json | 40 + llms.txt | 5 + scripts/node_compat_matrix.mjs | 702 ++++++++++++ scripts/soak/external-tools.mts | 15 + test-parity/node-compat-matrix.baseline.json | 1008 ++++++++++++++++++ test-parity/node-compat-matrix.skip.json | 10 + 12 files changed, 2010 insertions(+) create mode 100644 .github/workflows/node-compat-matrix.yml create mode 100644 changelog.d/0000-node-compat-matrix.md create mode 100644 docs/src/testing/node-compat-matrix.md create mode 100644 scripts/node_compat_matrix.mjs create mode 100644 test-parity/node-compat-matrix.baseline.json create mode 100644 test-parity/node-compat-matrix.skip.json diff --git a/.github/workflows/node-compat-matrix.yml b/.github/workflows/node-compat-matrix.yml new file mode 100644 index 0000000000..fbe74b0297 --- /dev/null +++ b/.github/workflows/node-compat-matrix.yml @@ -0,0 +1,84 @@ +name: Node Compat Matrix Guard + +# Gates scripts/node_compat_matrix.mjs --check: a breadth sweep over every +# require("module").builtinModules entry, both import forms (M and node:M), +# against a PINNED, SRI-verified Node oracle (external-tools.json tools.node, +# currently 26.5.1). It compares Perry's export-SHAPE fingerprint per module +# to the oracle's and FAILS if any baselined cell got strictly worse or a +# prefix-parity invariant broke (test-parity/node-compat-matrix.baseline.json). +# The systematic form of tracker #812. Behavioral parity stays in the +# node-suite (node-suite-guard.yml) — this is shape breadth, one pinned oracle. +# +# Its OWN job on purpose: the runner downloads the pinned Node dist tarball +# (~40MB) and verifies it, which must not slow the main cargo-test job. The +# node used by setup-node here only EXECUTES the .mjs; the oracle Node is the +# pinned dist the runner fetches + SRI-verifies itself. +# +# Decoupled from the (not-yet-enabled) merge queue like node-suite-guard.yml: +# runs nightly + on demand today, and the merge_group trigger is INERT until a +# maintainer enables the merge queue in branch protection. +on: + workflow_dispatch: + schedule: + # Nightly, offset from node-suite-guard (37 4) and the Core Subset Radar + # (17 3) to avoid overlap. + - cron: "57 4 * * *" + merge_group: + +permissions: + contents: read + +concurrency: + group: node-compat-matrix-${{ github.ref }} + cancel-in-progress: false + +env: + RUSTC_WRAPPER: sccache + SCCACHE_GHA_ENABLED: "true" + SCCACHE_CACHE_SIZE: "2G" + CARGO_INCREMENTAL: "0" + +jobs: + node-compat-matrix: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@v7 + with: + # Read-only job (build + verify); don't leave the GITHUB_TOKEN in + # the local git config (least privilege — OWASP / CodeRabbit). + persist-credentials: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Start sccache + uses: mozilla-actions/sccache-action@v0.0.10 + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: "${{ runner.os }}-perry" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Setup Node.js (executes the runner; oracle is the pinned dist) + uses: actions/setup-node@v7 + with: + # Single source of truth: .node-version at the repo root. This node + # only RUNS node_compat_matrix.mjs; the compat oracle is the pinned + # Node the runner downloads + SRI-verifies from external-tools.json. + node-version-file: .node-version + + - name: Build Perry release binary + run: cargo build --release -p perry -p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static + + - name: Node builtin compat matrix guard (--check, pinned oracle) + # Fails (exit 1) if any baselined cell got strictly worse or a + # prefix-parity invariant broke; improvements are accepted. The runner + # downloads + SRI-verifies the pinned Node dist under .cache/node-pin/. + run: | + set -euo pipefail + echo '### Node builtin compat matrix guard' >> "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" + node scripts/node_compat_matrix.mjs --check \ + | tee -a "$GITHUB_STEP_SUMMARY" + echo '```' >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index db73367ad2..906d536f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ target/ __pycache__/ *.py[cod] +# Pinned Node oracle for the builtin-module compat matrix +# (scripts/node_compat_matrix.mjs downloads + SRI-verifies + caches here). +.cache/node-pin/ + # Android Gradle: caches and build outputs are regenerable. Source under # android-build/ that we DO track: build.gradle.kts files, gradle wrapper, # AndroidManifest.xml, Kotlin sources under app/src/main/java, and resources diff --git a/CLAUDE.md b/CLAUDE.md index 355e1e5b8d..d8c735029c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,26 @@ Two workflows are deliberately exempt and say so inline: `node-core-subset.yml` - **Test/CI mechanics** — `#794` (per-category parity thresholds), `#796` (gap-suite output truncation + O(n²) `normalize_output`), `#812` (42-module behavioral matrix), `#806/#807/#808` (test harnesses for mixins / async context / ≥300-init scale). - **Skip-list audit** — `#797` covers `test-parity/known_failures.json` provenance (issue # + date per entry). +### Node builtin compatibility matrix (`scripts/node_compat_matrix.mjs`) + +Breadth sweep over EVERY `require("module").builtinModules` entry, both import forms (`M` and `node:M`), against a **pinned, SRI-verified Node** (the "latest stable" oracle, pinned in `external-tools.json` `tools.node.version` — currently **26.5.1**, independent of the `.node-version` gap-suite oracle). It compares Perry's export-SHAPE fingerprint (sorted `name:typeof` over the module namespace + the default export's typeof) to the oracle's. This is the systematic version of the #812 "42-module behavioral matrix" — shape, not deep behavior (behavioral cases stay in the node-suite). + +```bash +# FAST LOOP — reach for this first when iterating on ONE builtin: +node scripts/node_compat_matrix.mjs --module fs # one module, both forms +node scripts/node_compat_matrix.mjs --module fs,path,crypto # a few +node scripts/node_compat_matrix.mjs --module fs --method readFileSync,promises # only these exports +node scripts/node_compat_matrix.mjs --only fs.readFileSync,path.join # combined mod.export form +# (the pinned Node download is skipped once cached under .cache/node-pin/) + +# FULL SWEEP + GATE: +node scripts/node_compat_matrix.mjs # whole matrix + summary table +node scripts/node_compat_matrix.mjs --check # CI gate: exit 1 on regressions vs the baseline +node scripts/node_compat_matrix.mjs --update-baseline # rewrite test-parity/node-compat-matrix.baseline.json +``` + +A `--module` selector scopes `--check`/`--update-baseline` to just that slice (a single-module refresh never rewrites the whole baseline). A `--method`/`--only` subset is a print-only fast diagnostic (it narrows the fingerprint, so it is refused for `--check`/`--update-baseline`). **Bump the pinned Node** by editing `tools.node.version` in `external-tools.json` (add per-platform sha512 SRI), then `--update-baseline` and review the diff. Needs the release binary (`cargo build --release -p perry`). Full page: `docs/src/testing/node-compat-matrix.md`. + **Known categorical gaps**: `console.dir`/`console.group*` formatting, lone surrogate handling (WTF-8). (Lookbehind regex is NOT a gap anymore: `perry-runtime/src/regex.rs` falls back from the `regex` crate to `fancy-regex` for lookbehind/backreferences, with capture-group translation and replacement expansion.) ## Workflow Requirements diff --git a/changelog.d/0000-node-compat-matrix.md b/changelog.d/0000-node-compat-matrix.md new file mode 100644 index 0000000000..c46000d544 --- /dev/null +++ b/changelog.d/0000-node-compat-matrix.md @@ -0,0 +1,6 @@ +**Node builtin-module compatibility matrix** (`scripts/node_compat_matrix.mjs`) — a breadth harness that sweeps every `require("module").builtinModules` entry, both import forms (`M` and `node:M`), against a pinned, SRI-verified Node oracle and compares Perry's export-SHAPE fingerprint (sorted `name:typeof` over the module namespace + the default export's typeof) to Node's. This is the systematic version of tracker #812; deep behavioral parity stays in the hand-authored node-suite (`run_parity_tests.sh`). + +- **Pinned oracle**: Node **26.5.1** (latest CURRENT stable) added to `external-tools.json` under `tools.node` with a per-platform `sha512` SRI for darwin/linux/win (arm64+x64), each cross-checked against nodejs.org `SHASUMS256.txt` at pin time. The runner downloads + verifies + caches it under `.cache/node-pin/` (gitignored); `scripts/soak/external-tools.mts` learns a `node-dist` release type so its pin gate stays green without treating nodejs.org as a github release host. +- **Fast loop for LLMs/humans**: `node scripts/node_compat_matrix.mjs --module fs` (both forms, one module); `--module a,b,c`, `--method`, and `--only mod.export` narrow to a sub-second inner loop. `--check` gates against `test-parity/node-compat-matrix.baseline.json` (own CI job, `.github/workflows/node-compat-matrix.yml`), `--update-baseline` refreshes it; a `--module` selector scopes both to that slice. +- **Prefixed/unprefixed invariant** verified per module (Perry's `is_native_module` strips `node:`, so `M` and `node:M` must agree). +- **Baseline on darwin-arm64**: 58 builtins probed — 21 both-forms exact-shape match, 35 shape-diff (partial surfaces; recorded, not regressions), 0 perry-unresolved, **0 prefix-parity divergences**. Findings recorded (not fixed here, per infra-PR scope): `sea`/`sqlite`/`test`/`test/reporters` are `node:`-prefix-only in Node 26 (the bare specifier throws) yet Perry resolves the unprefixed form too; `trace_events` matches Node fully but is absent from `perry-api-manifest::{NATIVE_MODULES,NODE_SUBMODULES}` (works-but-unclaimed). diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index d6ca8147b5..8706376d86 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -147,6 +147,7 @@ # Testing - [Geisterhand (UI Fuzzer)](testing/geisterhand.md) +- [Node Compatibility Matrix](testing/node-compat-matrix.md) # CLI Reference diff --git a/docs/src/testing/node-compat-matrix.md b/docs/src/testing/node-compat-matrix.md new file mode 100644 index 0000000000..9fb3d07e45 --- /dev/null +++ b/docs/src/testing/node-compat-matrix.md @@ -0,0 +1,115 @@ +# Node builtin-module Compatibility Matrix + +Perry reimplements the `node:*` module surface natively. The **compatibility +matrix** (`scripts/node_compat_matrix.mjs`) measures — against a *pinned, +verified* Node — how faithfully Perry reproduces each builtin's **export +shape**, for **both** import forms (`M` and `node:M`). + +Its value is **breadth**: every builtin, both forms, one pinned oracle. It is +the systematic version of tracker +[#812](https://github.com/skelpo/perry/issues/812) ("42-module behavioral +matrix"). Deep *behavioral* parity lives in the hand-authored node-suite +(`run_parity_tests.sh`); this harness is a wide, shallow **shape** sweep. + +## To check one module fast + +```bash +node scripts/node_compat_matrix.mjs --module fs +``` + +That is the command to reach for while iterating on a single builtin. The +pinned Node download happens once and is cached under `.cache/node-pin/`, so +subsequent runs are just Perry compile + run. Narrow further: + +```bash +node scripts/node_compat_matrix.mjs --module fs,path,crypto # a few modules +node scripts/node_compat_matrix.mjs --module fs --method readFileSync,promises # only these exports +node scripts/node_compat_matrix.mjs --only fs.readFileSync,path.join # combined mod.export form +``` + +A `--method`/`--only` subset narrows the fingerprint to those exports for a +sub-second "did my change fix `node:fs.readFileSync`?" loop. Because it changes +the fingerprint semantics, it is a **print-only diagnostic** — it is refused +for `--check`/`--update-baseline`. + +## Full sweep and the CI gate + +```bash +node scripts/node_compat_matrix.mjs # whole matrix + summary table +node scripts/node_compat_matrix.mjs --check # exit 1 on regressions vs the baseline +node scripts/node_compat_matrix.mjs --update-baseline # rewrite the committed baseline +``` + +The harness needs the release binary (`cargo build --release -p perry`). The +baseline lives at `test-parity/node-compat-matrix.baseline.json`; the CI job +`.github/workflows/node-compat-matrix.yml` runs `--check` nightly in its own +job (so the pinned-Node download never slows the main test job). + +`--check` fails when a baselined cell got **strictly worse** (per a severity +order: `match` → `shape-diff` → `perry-unresolved`) or when a **prefix-parity** +invariant that previously held broke. Improvements are always accepted and are +folded in by `--update-baseline`. A `--module` selector scopes +`--check`/`--update-baseline` to just that slice — a single-module refresh +never rewrites the whole baseline. + +## The pin + +The oracle is the **official nodejs.org dist tarball** for the host platform, a +*binary* pin (not a source checkout — we measure shape against the runtime Node +actually ships). It is recorded in `external-tools.json` under `tools.node` +with a `sha512` SRI per platform, matching that file's existing convention. The +runner downloads it, verifies the SRI, and caches it under `.cache/node-pin/` +(gitignored). nodejs.org also publishes `SHASUMS256.txt` (sha256), which is +cross-checked against every pinned asset at pin time. + +Currently pinned: **Node 26.5.1** (the latest CURRENT stable). This is a +separate concern from `.node-version` (26.5.0), which pins the gap-suite / +node-suite oracle; the compat matrix carries its own "latest stable" pin. + +## The fingerprint + +For a module `M` and a form, the probe does: + +```ts +import * as ns from "M" // or "node:M" +// sorted list of `:` over Object.keys(ns), +// plus `default:`, wrapped in a __FP__...__FP__ sentinel. +``` + +Two fingerprints are **equal** iff the two module namespaces have the same +export names with the same `typeof` for each, and the same default-export +`typeof`. It is a **shape** fingerprint (names + typeofs), not deep behavior. +The sentinel line means environmental warnings on stdout/stderr never touch the +compare — no output-normalization needed. + +Each `(module, form)` cell gets a status: + +| status | meaning | +| --- | --- | +| `match` | Perry fingerprint == oracle fingerprint | +| `shape-diff` | both resolved, fingerprints differ (a real shape gap) | +| `perry-unresolved` | Node resolved, Perry did not compile/run | +| `perry-extra` | Perry resolved a form the oracle did not | +| `both-unresolved` | neither resolved (neutral) | +| `skip` | curated skip — see `test-parity/node-compat-matrix.skip.json` | + +Modules that cannot be meaningfully fingerprinted by a bare `import * as m` +(side-effectful on import, or shape depends on constructor args) go in the skip +JSON **with a reason** rather than silently passing. + +## The prefixed / unprefixed invariant + +Node treats `M` and `node:M` identically for builtins, and Perry's +`is_native_module` strips the `node:` prefix — so the two forms **must** agree. +The runner computes both and records `prefixParity`. A `false` there is a **real +Perry bug** and fails `--check` if it previously held. + +## Bumping the pinned Node + +1. Edit `tools.node.version` in `external-tools.json` and refresh the + per-platform `sha512` SRI (download each dist tarball, verify its sha256 + against that version's `SHASUMS256.txt`, then record the recomputed sha512). +2. `node scripts/node_compat_matrix.mjs --update-baseline`. +3. **Review the diff.** A Node bump legitimately changes fingerprints (new + exports, typeof changes); confirm the deltas are Node's, not Perry + regressions, before committing. diff --git a/external-tools.json b/external-tools.json index 77799d445f..b12d3a5f72 100644 --- a/external-tools.json +++ b/external-tools.json @@ -1,5 +1,45 @@ { "tools": { + "node": { + "description": "Node.js — the pinned oracle for the builtin-module compatibility matrix (scripts/node_compat_matrix.mjs).", + "version": "26.5.1", + "release": "node-dist", + "repository": "github:nodejs/node", + "distBaseUrl": "https://nodejs.org/dist", + "notes": [ + "Latest CURRENT stable at pin time. Bump via the NODE_PIN.version constant in scripts/node_compat_matrix.mjs, then run --update-baseline and review the diff.", + "Assets are the official nodejs.org dist tarballs. Integrity is sha512 recomputed from the downloaded bytes (matching this file's sha512 SRI convention); nodejs.org also publishes sha256 in SHASUMS256.txt, cross-checked at pin time against every asset here.", + "Installed by scripts/node_compat_matrix.mjs via its own resolver (download + SRI-verify + cache under .cache/node-pin/), NOT the shared tool rack: nodejs.org is not a github release host, and the tarball is a full node tree rather than a single bin.", + "Official dist ships no musl build (musl lives on unofficial-builds.nodejs.org); alpine/musl runners fall back to a system node.", + "LTS alternate: v24.18.1 (Krypton). Pass --node-version to fetch a non-pinned line — it is verified against that version's SHASUMS256.txt (sha256) since no sha512 pin exists here for it." + ], + "platforms": { + "darwin-arm64": { + "asset": "node-v26.5.1-darwin-arm64.tar.gz", + "integrity": "sha512-P8PFV7IyfNm+w2PXZt20GGHf9PvQ2/UDH4toqqJwgAGMzsqAdfJQWeAO1TTpsBuz5JBetwagXhhlUl1XyvH93A==" + }, + "darwin-x64": { + "asset": "node-v26.5.1-darwin-x64.tar.gz", + "integrity": "sha512-w15Ah75azmsKod8iVXqAZrxYFHl/zgw2eHgC62+o8L0e3CX7lJTCa+NBzmdXDo9Vz/RgkM1rHIzo8lsk7q3VjA==" + }, + "linux-arm64": { + "asset": "node-v26.5.1-linux-arm64.tar.gz", + "integrity": "sha512-ZBxARZ/lB1q3WNr3ptUUFQX2kv84dOpVlb1f9oAA5g8F6ZmK3ZTIf871Ml1Xb6o1Yi+LWzF1ukrlDe0tfen3Nw==" + }, + "linux-x64": { + "asset": "node-v26.5.1-linux-x64.tar.gz", + "integrity": "sha512-gP57AfiLlsia+9NJ0G99EACtsN5l0nBrTfDkupo8lzhOfjLX9aZ9WIeOxYy+nGisfpnfUxOLNzz3AqIBRsWB9A==" + }, + "win-arm64": { + "asset": "node-v26.5.1-win-arm64.zip", + "integrity": "sha512-JJyyH4I6cHQq38iw1gWxcR7VVl+YBrRtGYVEgRH14OUJzSh1JT4RMddH/7Z9MMvgPDH04yFLnp9dHub5YQiWzw==" + }, + "win-x64": { + "asset": "node-v26.5.1-win-x64.zip", + "integrity": "sha512-fkmRHaGoDDQeFPpnpasE7sGwF9xKy55Qg7E8wDrDsCymXKBh+I41nOu0Y/v1ectAHP+AOXv41ysVx/aTiWMPdg==" + } + } + }, "pnpm": { "description": "pnpm — the fleet's package manager.", "version": "11.15.1", diff --git a/llms.txt b/llms.txt index 05ba0e275c..7f9ad41505 100644 --- a/llms.txt +++ b/llms.txt @@ -32,6 +32,11 @@ perry doctor # Check environment perry update # Self-update ``` +## Testing / node parity + +- `./run_parity_tests.sh` — hand-authored node-suite (behavioral parity vs `node --experimental-strip-types`). +- `node scripts/node_compat_matrix.mjs --module fs` — fast per-module export-shape check against a pinned Node oracle (26.5.1); drop `--module` for the full builtin sweep, add `--check` for the CI gate (systematic form of tracker #812). + ## Documentation Full documentation: see the `docs/` directory (mdBook format), or build with `mdbook serve docs`. diff --git a/scripts/node_compat_matrix.mjs b/scripts/node_compat_matrix.mjs new file mode 100644 index 0000000000..63cf8e930f --- /dev/null +++ b/scripts/node_compat_matrix.mjs @@ -0,0 +1,702 @@ +#!/usr/bin/env node +/** + * @file Node.js builtin-module COMPATIBILITY MATRIX harness. + * + * Perry reimplements the `node:*` module surface natively. This harness + * measures — against a PINNED, verified Node (the oracle) — how faithfully + * Perry reproduces each builtin's EXPORT SHAPE, for BOTH import forms + * (`M` and `node:M`). Its value is BREADTH: every builtin, both forms, + * one pinned oracle. Behavioral parity lives in the hand-authored + * node-suite (run_parity_tests.sh); this is a wide, shallow shape sweep. + * + * Design choices (see docs/src/testing/node-compat-matrix.md): + * - BINARY pin, not a source checkout: the oracle is the official + * nodejs.org dist tarball for the host platform, pinned in + * external-tools.json with a sha512 SRI and download-verified here. + * We measure shape against the SHIPPED runtime, not a self-build. + * - Node ESM runner, not bash: the matrix is data-structured (per + * module x per form, a JSON baseline, SRI download/verify, a printed + * table). run_parity_tests.sh streams a single node-vs-perry compare + * beautifully; this needs records, not streams. We DO reuse its + * compile+run contract (PERRY_ALLOW_UNIMPLEMENTED=1, the + * --enable-js-runtime retry, PERRY_STUB_DIAG=off) — and sidestep its + * output-normalization entirely with a sentinel-wrapped fingerprint + * line, so environmental warnings never touch the compare. + * + * FINGERPRINT (per module, per form): the probe does `import * as ns`, + * sorts `Object.keys(ns)`, and emits `name:typeof` for each key plus the + * default export's typeof, wrapped in `__FP__...__FP__`. Equal + * fingerprints == equal export shape. It is a SHAPE fingerprint (names + + * typeofs), not deep behavior. + * + * MODES: + * (default) run the full matrix, print a table + summary + * --check compare to the committed baseline; exit 1 on + * regressions (a cell that got strictly worse, or a + * prefix-parity invariant that broke) + * --update-baseline rewrite test-parity/node-compat-matrix.baseline.json + * --node-version use a different Node line (e.g. the LTS row); + * verified via that version's SHASUMS256.txt + * --json also print the raw result object as JSON + * + * FAST-LOOP SELECTORS (narrow to a sub-second inner loop — the pinned + * node download is skipped once cached): + * --module restrict to a comma-separated set of base modules + * --method with --module, fingerprint ONLY those exports of + * the selected module(s) — e.g. + * `--module fs --method readFileSync,promises` + * --only combined form (comma-separated), e.g. + * `--only fs.readFileSync,path.join` + * A module selector makes --check / --update-baseline operate on JUST + * that slice (never silently rewriting the whole baseline). A method + * subset changes the fingerprint semantics, so it is a print-only fast + * diagnostic and is refused for --check / --update-baseline. + * + * The prefixed/unprefixed INVARIANT: Node treats `M` and `node:M` + * identically for builtins, and Perry's is_native_module strips the + * `node:` prefix, so the two forms MUST agree. Any divergence is a real + * Perry bug and is flagged (prefixParity=false). + */ + +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const EXTERNAL_TOOLS_JSON = path.join(REPO_ROOT, 'external-tools.json') +const BASELINE_PATH = path.join(REPO_ROOT, 'test-parity', 'node-compat-matrix.baseline.json') +const SKIP_PATH = path.join(REPO_ROOT, 'test-parity', 'node-compat-matrix.skip.json') +const CACHE_ROOT = path.join(REPO_ROOT, '.cache', 'node-pin') +const MANIFEST_ENTRIES = path.join( + REPO_ROOT, + 'crates', + 'perry-api-manifest', + 'src', + 'entries.rs', +) +const PERRY_BIN = path.join(REPO_ROOT, 'target', 'release', 'perry') + +// Compile can be slow on the FIRST call (it builds the auto-optimized +// runtime once), then warm calls are sub-second. Runs are tiny. +const COMPILE_TIMEOUT_MS = 300_000 +const RUN_TIMEOUT_MS = 15_000 +const NODE_TIMEOUT_MS = 15_000 + +// --- CLI ------------------------------------------------------------------- + +function parseArgs(argv) { + // moduleSet: null = all builtins; otherwise the selected base modules. + // methodMap: base -> [export names] to fingerprint (subset mode). + const args = { + mode: 'run', + moduleSet: null, + methodMap: new Map(), + globalMethods: [], + nodeVersion: null, + json: false, + } + const modules = new Set() + const splitList = s => (s || '').split(',').map(x => x.trim()).filter(Boolean) + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (a === '--check') args.mode = 'check' + else if (a === '--update-baseline') args.mode = 'update' + else if (a === '--json') args.json = true + else if (a === '--module') { + for (const m of splitList(argv[++i])) modules.add(m.replace(/^node:/, '')) + } else if (a === '--method') { + args.globalMethods.push(...splitList(argv[++i])) + } else if (a === '--only') { + // mod.export pairs: fs.readFileSync,path.join + for (const pair of splitList(argv[++i])) { + const dot = pair.indexOf('.') + if (dot < 0) { + console.error(`[node-compat] --only expects mod.export, got: ${pair}`) + args.mode = 'help' + continue + } + const base = pair.slice(0, dot).replace(/^node:/, '') + const method = pair.slice(dot + 1) + modules.add(base) + if (!args.methodMap.has(base)) args.methodMap.set(base, []) + args.methodMap.get(base).push(method) + } + } else if (a === '--node-version') args.nodeVersion = argv[++i] + else if (a === '--help' || a === '-h') args.mode = 'help' + else { + console.error(`[node-compat] unknown argument: ${a}`) + args.mode = 'help' + } + } + if (modules.size > 0) args.moduleSet = modules + // Fold a global --method list onto each explicitly selected module. + if (args.globalMethods.length > 0 && args.moduleSet) { + for (const base of args.moduleSet) { + const cur = args.methodMap.get(base) || [] + args.methodMap.set(base, [...cur, ...args.globalMethods]) + } + } + args.subsetActive = args.methodMap.size > 0 + return args +} + +const HELP = `node_compat_matrix.mjs — Node builtin-module compatibility matrix + + # FAST LOOP (reach for this while iterating on one builtin): + node scripts/node_compat_matrix.mjs --module fs + node scripts/node_compat_matrix.mjs --module fs,path,crypto + node scripts/node_compat_matrix.mjs --module fs --method readFileSync,promises + node scripts/node_compat_matrix.mjs --only fs.readFileSync,path.join + + # FULL SWEEP + GATE: + node scripts/node_compat_matrix.mjs run + print table + node scripts/node_compat_matrix.mjs --check gate against baseline + node scripts/node_compat_matrix.mjs --update-baseline + node scripts/node_compat_matrix.mjs --node-version 24.18.1 + node scripts/node_compat_matrix.mjs --json + +A --module selector makes --check / --update-baseline touch only that slice. +A --method / --only subset is a print-only fast diagnostic (it changes the +fingerprint semantics, so it is refused for --check / --update-baseline). + +The pinned Node version lives in external-tools.json (tools.node.version). +Bump it there, run --update-baseline, and review the diff.` + +// --- pin + platform -------------------------------------------------------- + +function loadNodePin() { + const pin = JSON.parse(readFileSync(EXTERNAL_TOOLS_JSON, 'utf8')).tools.node + if (!pin) throw new Error('external-tools.json has no "node" pin') + return pin +} + +function platformKey() { + const okey = { darwin: 'darwin', linux: 'linux', win32: 'win' }[process.platform] + const akey = { arm64: 'arm64', x64: 'x64' }[process.arch] + if (!okey || !akey) throw new Error(`unsupported platform ${process.platform}-${process.arch}`) + return `${okey}-${akey}` +} + +function sriSha512(buf) { + return `sha512-${createHash('sha512').update(buf).digest('base64')}` +} + +function sha256hex(buf) { + return createHash('sha256').update(buf).digest('hex') +} + +async function fetchBuffer(url) { + const res = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(180_000) }) + if (!res.ok) throw new Error(`download failed ${res.status} ${url}`) + return Buffer.from(await res.arrayBuffer()) +} + +/** + * Resolve the pinned (or --node-version) Node to an executable path, + * downloading + verifying + caching under .cache/node-pin/ as needed. + * Pinned line: verify against the sha512 SRI in external-tools.json. + * Non-pinned line: verify against that version's SHASUMS256.txt (sha256). + */ +async function resolveNode(pin, versionOverride) { + const version = versionOverride || pin.version + const key = platformKey() + const [okey, akey] = key.split('-') + const ext = okey === 'win' ? 'zip' : 'tar.gz' + const asset = `node-v${version}-${okey}-${akey}.${ext}` + const dirName = `node-v${version}-${okey}-${akey}` + const versionDir = path.join(CACHE_ROOT, version) + const extractedDir = path.join(versionDir, dirName) + const nodeBin = + okey === 'win' + ? path.join(extractedDir, 'node.exe') + : path.join(extractedDir, 'bin', 'node') + + if (existsSync(nodeBin)) return nodeBin + + const base = pin.distBaseUrl || 'https://nodejs.org/dist' + const url = `${base}/v${version}/${asset}` + console.error(`[node-compat] resolving Node ${version} (${key}) ...`) + const buf = await fetchBuffer(url) + + const isPinned = version === pin.version + const platPin = pin.platforms?.[key] + if (isPinned && platPin) { + const actual = sriSha512(buf) + if (actual !== platPin.integrity) { + throw new Error( + `integrity mismatch for ${url}\n expected ${platPin.integrity}\n actual ${actual}`, + ) + } + console.error(`[node-compat] verified ${asset} against pinned sha512 SRI`) + } else { + // Non-pinned line (e.g. LTS row): verify sha256 against the dist's + // published SHASUMS256.txt. No sha512 pin exists for it in this repo. + const sums = (await fetchBuffer(`${base}/v${version}/SHASUMS256.txt`)).toString('utf8') + const want = sums + .split('\n') + .map(l => l.trim().split(/\s+/)) + .find(([, name]) => name === asset)?.[0] + if (!want) throw new Error(`${asset} not found in SHASUMS256.txt for v${version}`) + const got = sha256hex(buf) + if (got !== want) { + throw new Error(`sha256 mismatch for ${url}\n expected ${want}\n actual ${got}`) + } + console.error(`[node-compat] verified ${asset} against SHASUMS256.txt (sha256, unpinned line)`) + } + + mkdirSync(versionDir, { recursive: true }) + const archivePath = path.join(versionDir, asset) + writeFileSync(archivePath, buf) + const tarBin = + process.platform === 'win32' + ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') + : 'tar' + const flags = ext === 'zip' ? '-xf' : '-xzf' + const ex = spawnSync(tarBin, [flags, asset], { cwd: versionDir, stdio: 'inherit' }) + rmSync(archivePath, { force: true }) + if (ex.status !== 0) throw new Error('node dist extract failed') + if (!existsSync(nodeBin)) throw new Error(`node binary not found at ${nodeBin} after extract`) + console.error(`[node-compat] cached Node ${version} -> ${nodeBin}`) + return nodeBin +} + +// --- probe + fingerprint --------------------------------------------------- + +const FP_RE = /__FP__([\s\S]*?)__FP__/ + +function probeSource(spec, methods) { + // A single sentinel-wrapped line carries the shape fingerprint, so any + // node/perry warnings on stdout/stderr are simply ignored by the extractor. + // With a method subset, fingerprint ONLY those exports (a fast diagnostic + // loop); otherwise fingerprint the whole sorted namespace. + const keysExpr = + methods && methods.length > 0 + ? `const keys = ${JSON.stringify([...methods].sort())}` + : `const keys = Object.keys(rec).sort()` + return [ + `import * as ns from ${JSON.stringify(spec)}`, + `const rec = ns as unknown as Record`, + keysExpr, + `const parts: string[] = []`, + `for (const k of keys) parts.push(k + ":" + typeof rec[k])`, + `const dflt = (rec as unknown as { default?: unknown }).default`, + `console.log("__FP__" + parts.join(",") + "|default=" + typeof dflt + "__FP__")`, + ``, + ].join('\n') +} + +function extractFp(output) { + const m = FP_RE.exec(output) + return m ? m[1] : null +} + +function fpHash(fp) { + return fp === null ? '' : createHash('sha256').update(fp).digest('hex').slice(0, 12) +} + +/** Ask the pinned Node (oracle) for a module form's fingerprint. */ +function oracleFingerprint(nodeBin, probeFile) { + const res = spawnSync( + nodeBin, + ['--experimental-strip-types', probeFile], + { + encoding: 'utf8', + timeout: NODE_TIMEOUT_MS, + env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', NODE_DISABLE_COLORS: '1' }, + }, + ) + return extractFp(`${res.stdout || ''}\n${res.stderr || ''}`) +} + +/** Compile with Perry, run, return the fingerprint (or null on any failure). */ +function perryFingerprint(probeFile, outBin) { + const compileEnv = { ...process.env, PERRY_ALLOW_UNIMPLEMENTED: '1' } + let c = spawnSync(PERRY_BIN, [probeFile, '-o', outBin], { + encoding: 'utf8', + timeout: COMPILE_TIMEOUT_MS, + env: compileEnv, + }) + const cout = `${c.stdout || ''}\n${c.stderr || ''}` + // Mirror run_parity_tests.sh: retry once with the JS-runtime host opt-in + // when the failure names perry-jsruntime. + if (c.status !== 0 && /perry-jsruntime/.test(cout)) { + c = spawnSync(PERRY_BIN, ['--enable-js-runtime', probeFile, '-o', outBin], { + encoding: 'utf8', + timeout: COMPILE_TIMEOUT_MS, + env: compileEnv, + }) + } + if (c.status !== 0 || !existsSync(outBin)) return null + const r = spawnSync(outBin, [], { + encoding: 'utf8', + timeout: RUN_TIMEOUT_MS, + env: { ...process.env, PERRY_STUB_DIAG: 'off' }, + }) + rmSync(outBin, { force: true }) + if (r.status !== 0) return null + return extractFp(`${r.stdout || ''}\n${r.stderr || ''}`) +} + +// --- status model ---------------------------------------------------------- + +// Lower is better. --check flags a cell whose severity increased. +const SEVERITY = { + skip: -1, + match: 0, + 'perry-extra': 0, // Perry resolves a form Node's oracle didn't — not a regression. + 'both-unresolved': 1, // Node can't import it either; neutral. + 'shape-diff': 2, + 'perry-unresolved': 3, +} + +function cellStatus(oracleFp, perryFp, skipped) { + if (skipped) return 'skip' + const oracleOk = oracleFp !== null + const perryOk = perryFp !== null + if (!oracleOk && !perryOk) return 'both-unresolved' + if (oracleOk && !perryOk) return 'perry-unresolved' + if (!oracleOk && perryOk) return 'perry-extra' + return oracleFp === perryFp ? 'match' : 'shape-diff' +} + +// --- manifest cross-check -------------------------------------------------- + +function extractRustArray(src, name) { + const start = src.indexOf(`${name}: &[&str] = &[`) + if (start < 0) return [] + const end = src.indexOf('];', start) + const body = src.slice(start, end) + const out = new Set() + for (const m of body.matchAll(/"([^"]+)"/g)) out.add(m[1]) + return [...out] +} + +function loadManifestModules() { + const src = readFileSync(MANIFEST_ENTRIES, 'utf8') + const native = extractRustArray(src, 'NATIVE_MODULES') + const submodules = extractRustArray(src, 'NODE_SUBMODULES') + return new Set([...native, ...submodules]) +} + +// --- enumerate builtins ---------------------------------------------------- + +function enumerateBuiltins(nodeBin) { + const res = spawnSync( + nodeBin, + ['-e', "process.stdout.write(require('module').builtinModules.join('\\n'))"], + { encoding: 'utf8', timeout: NODE_TIMEOUT_MS }, + ) + if (res.status !== 0) throw new Error(`could not enumerate builtinModules: ${res.stderr}`) + const bases = new Set() + for (const raw of res.stdout.split('\n')) { + const name = raw.trim() + if (!name) continue + const base = name.replace(/^node:/, '') + // Skip internals never meant as public import specifiers. + if (base.startsWith('internal/') || base.startsWith('_')) continue + bases.add(base) + } + return [...bases].sort() +} + +function loadSkip() { + if (!existsSync(SKIP_PATH)) return {} + return JSON.parse(readFileSync(SKIP_PATH, 'utf8')).modules || {} +} + +// --- run the matrix -------------------------------------------------------- + +async function runMatrix(args) { + if (!existsSync(PERRY_BIN)) { + throw new Error( + `perry release binary missing at ${PERRY_BIN}\n build it: cargo build --release -p perry`, + ) + } + const pin = loadNodePin() + const nodeBin = await resolveNode(pin, args.nodeVersion) + const version = args.nodeVersion || pin.version + const skip = loadSkip() + + let bases = enumerateBuiltins(nodeBin) + if (args.moduleSet) { + const requested = [...args.moduleSet] + const known = new Set(bases) + const missing = requested.filter(b => !known.has(b)) + if (missing.length > 0) { + throw new Error(`not builtins of Node ${version}: ${missing.join(', ')}`) + } + bases = bases.filter(b => args.moduleSet.has(b)) + } + + const tmp = mkdtempSync(path.join(os.tmpdir(), 'perry-node-compat-')) + const results = {} + let done = 0 + for (const base of bases) { + done++ + process.stderr.write(`\r[node-compat] ${done}/${bases.length} ${base.padEnd(24)}`) + const entry = {} + const methods = args.methodMap.get(base) + for (const [form, spec] of [ + ['unprefixed', base], + ['prefixed', `node:${base}`], + ]) { + const skipRec = skip[base] + const skipped = Boolean(skipRec) + const probeFile = path.join(tmp, `probe_${base.replace(/[^\w]/g, '_')}_${form}.ts`) + writeFileSync(probeFile, probeSource(spec, methods)) + let oracleFp = null + let perryFp = null + if (!skipped) { + oracleFp = oracleFingerprint(nodeBin, probeFile) + const outBin = path.join(tmp, `bin_${base.replace(/[^\w]/g, '_')}_${form}`) + perryFp = perryFingerprint(probeFile, outBin) + } + entry[form] = { + status: cellStatus(oracleFp, perryFp, skipped), + node: oracleFp !== null ? 'ok' : skipped ? 'skip' : 'throw', + perry: perryFp !== null ? 'ok' : skipped ? 'skip' : 'unresolved', + nodeFp: fpHash(oracleFp), + perryFp: fpHash(perryFp), + } + } + // Prefix-parity invariant: Perry must produce the SAME result for M and + // node:M (it strips the node: prefix). Compare Perry's own two forms. + const u = entry.unprefixed + const p = entry.prefixed + entry.prefixParity = u.perry === p.perry && u.perryFp === p.perryFp + if (skip[base]) entry.skipReason = skip[base].reason + results[base] = entry + } + process.stderr.write('\r' + ' '.repeat(60) + '\r') + rmSync(tmp, { recursive: true, force: true }) + + return { version, platform: platformKey(), modules: results } +} + +// --- reporting ------------------------------------------------------------- + +const GLYPH = { + match: 'match', + 'shape-diff': 'SHAPE-DIFF', + 'perry-unresolved': 'UNRESOLVED', + 'perry-extra': 'perry-extra', + 'both-unresolved': 'both-none', + skip: 'skip', +} + +function summarize(matrix, manifestModules) { + const mods = Object.entries(matrix.modules) + const total = mods.length + let bothMatch = 0 + const perryUnresolved = [] + const shapeDiff = [] + const prefixDivergences = [] + const claimedButBroken = [] + const worksButUnclaimed = [] + const skipped = [] + + for (const [base, e] of mods) { + if (e.unprefixed.status === 'skip') { + skipped.push(base) + continue + } + const u = e.unprefixed.status + const p = e.prefixed.status + if (u === 'match' && p === 'match') bothMatch++ + if (u === 'perry-unresolved' || p === 'perry-unresolved') perryUnresolved.push(base) + if (u === 'shape-diff' || p === 'shape-diff') shapeDiff.push(base) + if (!e.prefixParity) prefixDivergences.push(base) + + const claimed = manifestModules.has(base) + const perryResolvesEither = e.unprefixed.perry === 'ok' || e.prefixed.perry === 'ok' + if (claimed && !perryResolvesEither) claimedButBroken.push(base) + if (!claimed && perryResolvesEither) worksButUnclaimed.push(base) + } + return { + total, + bothMatch, + perryUnresolved, + shapeDiff, + prefixDivergences, + claimedButBroken, + worksButUnclaimed, + skipped, + } +} + +function printTable(matrix) { + const rows = Object.entries(matrix.modules) + const w = Math.max(...rows.map(([b]) => b.length), 6) + console.log('') + console.log(`${'MODULE'.padEnd(w)} ${'unprefixed'.padEnd(11)} ${'node:'.padEnd(11)} parity`) + console.log('-'.repeat(w + 2 + 11 + 2 + 11 + 2 + 6)) + for (const [base, e] of rows) { + const parity = e.unprefixed.status === 'skip' ? '-' : e.prefixParity ? 'ok' : 'DIVERGE' + console.log( + `${base.padEnd(w)} ${GLYPH[e.unprefixed.status].padEnd(11)} ${GLYPH[e.prefixed.status].padEnd(11)} ${parity}`, + ) + } +} + +function printSummary(s, matrix) { + console.log('') + console.log(`Node oracle: v${matrix.version} (${matrix.platform})`) + console.log(`Builtins probed: ${s.total}`) + console.log(`Both-forms match: ${s.bothMatch}`) + console.log(`Shape-diff: ${s.shapeDiff.length}${s.shapeDiff.length ? ' (' + s.shapeDiff.join(', ') + ')' : ''}`) + console.log(`Perry-unresolved: ${s.perryUnresolved.length}${s.perryUnresolved.length ? ' (' + s.perryUnresolved.join(', ') + ')' : ''}`) + console.log(`Skipped (curated): ${s.skipped.length}${s.skipped.length ? ' (' + s.skipped.join(', ') + ')' : ''}`) + console.log(`Prefix divergences: ${s.prefixDivergences.length}${s.prefixDivergences.length ? ' (' + s.prefixDivergences.join(', ') + ')' : ''}`) + console.log(`Claimed-but-broken: ${s.claimedButBroken.length}${s.claimedButBroken.length ? ' (' + s.claimedButBroken.join(', ') + ')' : ''} (in NATIVE_MODULES/NODE_SUBMODULES yet perry-unresolved both forms)`) + console.log(`Works-but-unclaimed:${s.worksButUnclaimed.length}${s.worksButUnclaimed.length ? ' (' + s.worksButUnclaimed.join(', ') + ')' : ''} (perry resolves it but it is not in the manifest lists)`) +} + +// --- baseline read/write/check --------------------------------------------- + +const BASELINE_SCHEMA = { + description: + 'Per-module x per-form (unprefixed / node:-prefixed) export-shape status for the Node builtin-module compatibility matrix. Generated by scripts/node_compat_matrix.mjs against the pinned Node oracle (external-tools.json tools.node). --check fails on any cell that got strictly worse or any prefix-parity invariant that broke; improvements are accepted. Regenerate with --update-baseline and review the diff.', + statuses: { + match: 'perry export fingerprint == node oracle fingerprint', + 'shape-diff': 'both resolved, fingerprints differ (a real shape gap)', + 'perry-unresolved': 'node resolved, perry did not compile/run', + 'perry-extra': 'perry resolved a form the node oracle did not', + 'both-unresolved': 'neither node nor perry resolved the form (neutral)', + skip: 'curated skip (see test-parity/node-compat-matrix.skip.json)', + }, + fields: { + prefixParity: + 'true when perry produces the SAME result for M and node:M. false is a real perry bug (Node treats them identically; is_native_module strips the node: prefix).', + nodeFp: 'first 12 hex of sha256(oracle fingerprint)', + perryFp: 'first 12 hex of sha256(perry fingerprint)', + }, +} + +function toBaseline(matrix) { + return { + _schema: BASELINE_SCHEMA, + nodeVersion: matrix.version, + platform: matrix.platform, + modules: matrix.modules, + } +} + +function writeBaseline(matrix, partial) { + let doc = toBaseline(matrix) + if (partial && existsSync(BASELINE_PATH)) { + // Selector-scoped update: overlay ONLY the probed modules onto the + // committed baseline so a single-module refresh never rewrites the + // whole file. The version/platform header follows the probed run. + const prev = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) + const merged = { ...prev.modules, ...matrix.modules } + // Deterministic key order. + const modules = {} + for (const k of Object.keys(merged).sort()) modules[k] = merged[k] + doc = { ...doc, modules } + } + writeFileSync(BASELINE_PATH, `${JSON.stringify(doc, null, 2)}\n`) + const scope = partial ? ` (merged ${Object.keys(matrix.modules).length} module slice)` : '' + console.error(`[node-compat] wrote baseline ${path.relative(REPO_ROOT, BASELINE_PATH)}${scope}`) +} + +function checkAgainstBaseline(matrix) { + if (!existsSync(BASELINE_PATH)) { + console.error(`[node-compat] no baseline at ${BASELINE_PATH} — run --update-baseline first`) + return 1 + } + const base = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) + const regressions = [] + for (const [name, cur] of Object.entries(matrix.modules)) { + const prev = base.modules[name] + if (!prev) continue // new module (e.g. after a node bump) — not a regression + for (const form of ['unprefixed', 'prefixed']) { + const wasSev = SEVERITY[prev[form].status] ?? 0 + const nowSev = SEVERITY[cur[form].status] ?? 0 + if (nowSev > wasSev) { + regressions.push(`${name} [${form}]: ${prev[form].status} -> ${cur[form].status}`) + } + } + if (prev.prefixParity === true && cur.prefixParity === false) { + regressions.push(`${name}: prefix-parity broke (M and node:M now diverge)`) + } + } + // Modules dropped from the matrix that were passing before also count. + for (const [name, prev] of Object.entries(base.modules)) { + if (!matrix.modules[name] && !matrix.__partial) { + if ((SEVERITY[prev.unprefixed?.status] ?? 0) === 0 && (SEVERITY[prev.prefixed?.status] ?? 0) === 0) { + regressions.push(`${name}: was in baseline (matching) but no longer probed`) + } + } + } + if (regressions.length === 0) { + console.log(`[node-compat] OK — no regressions vs baseline (Node v${base.nodeVersion})`) + return 0 + } + console.error(`[node-compat] REGRESSIONS (${regressions.length}):`) + for (const r of regressions) console.error(` - ${r}`) + return 1 +} + +// --- main ------------------------------------------------------------------ + +async function main() { + const args = parseArgs(process.argv.slice(2)) + if (args.mode === 'help') { + console.log(HELP) + return 0 + } + const partial = Boolean(args.moduleSet) + // A method subset changes fingerprint semantics — it can never touch the + // committed baseline (its fingerprints are not comparable to full-shape + // ones). Refuse before doing the work. + if (args.subsetActive && (args.mode === 'check' || args.mode === 'update')) { + console.error( + '[node-compat] --method/--only is a print-only fast diagnostic; use a module-level selector (no --method/--only) for --check / --update-baseline', + ) + return 1 + } + + const matrix = await runMatrix(args) + if (partial) matrix.__partial = true + const manifestModules = loadManifestModules() + const summary = summarize(matrix, manifestModules) + + if (args.mode === 'update') { + writeBaseline(matrix, partial) + printTable(matrix) + if (!partial) printSummary(summary, matrix) + return 0 + } + + if (args.mode === 'check') { + if (args.json) console.log(JSON.stringify(toBaseline(matrix), null, 2)) + return checkAgainstBaseline(matrix) + } + + // default: run + report + printTable(matrix) + printSummary(summary, matrix) + if (args.json) console.log(JSON.stringify(toBaseline(matrix), null, 2)) + return 0 +} + +main().then( + code => { + process.exitCode = code + }, + err => { + console.error(`[node-compat] ${err.stack || err.message}`) + process.exitCode = 1 + }, +) diff --git a/scripts/soak/external-tools.mts b/scripts/soak/external-tools.mts index c79aec6220..09c462a36e 100644 --- a/scripts/soak/external-tools.mts +++ b/scripts/soak/external-tools.mts @@ -65,6 +65,7 @@ interface ToolPin { repository?: string release?: string binaryName?: string + distBaseUrl?: string purl?: string integrity?: string platforms?: Record @@ -513,6 +514,15 @@ export async function installTool(name: string, tools: Record): ) return } + if (pin.release === 'node-dist') { + // Official nodejs.org dist tarball (not a github release, not a single + // bin). Its download+verify+cache resolver lives in the compat runner, + // which needs a full node tree rather than a rack bin handle. + console.log( + `[external-tools] ${name} is a node-dist pin — installed by scripts/node_compat_matrix.mjs (its own resolver), not the tool rack`, + ) + return + } throw new Error(`${name}: no installable shape (release=${pin.release ?? 'none'})`) } @@ -647,6 +657,11 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise Date: Thu, 30 Jul 2026 11:56:29 -0400 Subject: [PATCH 2/2] docs: key changelog fragment to PR --- .../{0000-node-compat-matrix.md => 7074-node-compat-matrix.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{0000-node-compat-matrix.md => 7074-node-compat-matrix.md} (100%) diff --git a/changelog.d/0000-node-compat-matrix.md b/changelog.d/7074-node-compat-matrix.md similarity index 100% rename from changelog.d/0000-node-compat-matrix.md rename to changelog.d/7074-node-compat-matrix.md