Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/node-compat-matrix.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +42 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not compare the Darwin baseline from an Ubuntu runner.

The committed baseline is darwin-arm64, but this job runs on ubuntu-latest. The supplied checkAgainstBaseline implementation compares module cells without rejecting a platform mismatch, so this can produce false regressions or miss platform-specific regressions. Use a matching Darwin arm64 runner or make the baseline/check platform-specific.

Based on the supplied PR objectives and harness baseline contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/node-compat-matrix.yml around lines 42 - 44, Update the
node-compat-matrix job so its runner matches the committed darwin-arm64
baseline, or otherwise make the baseline comparison platform-specific. Ensure
checkAgainstBaseline never compares Darwin baseline module cells from
ubuntu-latest and preserves accurate platform-specific regression detection.

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"
Comment on lines +74 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Assert that the full sweep actually ran.

set -euo pipefail propagates failures, but this step has no postcondition proving that --check processed the intended breadth. Add a machine-readable count/marker assertion, or make the harness fail when zero or partial modules are processed, so a future selector regression cannot leave a green but empty gate.

As per coding guidelines, the CI gate must assert that its subject actually ran.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/node-compat-matrix.yml around lines 74 - 84, Update the
Node builtin compatibility matrix guard step and its `node_compat_matrix.mjs
--check` harness to emit and validate a machine-readable completion marker or
processed-module count. Make the CI command fail when the sweep processes zero
or fewer than the intended modules, while preserving the existing summary output
and regression checks.

Source: Coding guidelines

4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/7074-node-compat-matrix.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@
# Testing

- [Geisterhand (UI Fuzzer)](testing/geisterhand.md)
- [Node Compatibility Matrix](testing/node-compat-matrix.md)

# CLI Reference

Expand Down
115 changes: 115 additions & 0 deletions docs/src/testing/node-compat-matrix.md
Original file line number Diff line number Diff line change
@@ -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 `<exportName>:<typeof export[name]>` over Object.keys(ns),
// plus `default:<typeof ns.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.
40 changes: 40 additions & 0 deletions external-tools.json
Original file line number Diff line number Diff line change
@@ -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 <ver> 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",
Expand Down
5 changes: 5 additions & 0 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
Loading