Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ if [ -z "${NUB_SKIP_README_TABLE_CHECK:-}" ] && [ -n "$SKILLS_ROOT" ] && [ -f "$
*) base=$(git merge-base "$local_sha" origin/main 2>/dev/null) ;;
esac
[ -n "$base" ] || { table_touched=1; continue; }
changed=$(git diff --name-only "$base" "$local_sha" -- tests/cross-runtime/ 2>/dev/null) || table_touched=1
# The checker also validates figures hand-copied into the site and the
# wiki, so a push touching only those surfaces must run it too.
changed=$(git diff --name-only "$base" "$local_sha" -- tests/cross-runtime/ "site/src/app/(home)/page.tsx" site/content/blog/introducing-nub.mdx wiki/research/node-test-suite-leverage.md 2>/dev/null) || table_touched=1
[ -n "$changed" ] && table_touched=1
done <<PUSHREFS
$PUSH_REFS
Expand Down
2 changes: 1 addition & 1 deletion site/content/blog/introducing-nub.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Bun and Deno proved there's enormous appetite for an all-in-one toolkit that rep

## Introducing Nub

Nub embraces it instead. It's a single Rust binary that transpiles your code and executes it on the stock `node` your project pins — so the runtime underneath *is* Node. On Node 26.7's own test suite, scored the way Deno's compatibility corpus scores it, it clears 98.5% of what real Node passes, versus 74.2% for Deno 2.9 and 68.1% for Bun 1.4. It combines:
Nub embraces it instead. It's a single Rust binary that transpiles your code and executes it on the stock `node` your project pins — so the runtime underneath *is* Node. On Node 26.7's own test suite, scored the way Deno's compatibility corpus scores it, it clears 98.5% of what real Node passes, versus 74.2% for Deno 2.9 and 70.1% for Bun 1.4. It combines:

```console
$ nub index.ts # file runner — full TypeScript, on stock Node
Expand Down
2 changes: 1 addition & 1 deletion site/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -955,7 +955,7 @@ const COMPAT = [
{ name: 'Node 26.7', rate: 100, tests: '5,046 / 5,046', us: false, dim: false },
{ name: 'Nub', rate: 98.5, tests: '4,968 / 5,046', us: true, dim: false },
{ name: 'Deno 2.9', rate: 74.2, tests: '3,742 / 5,046', us: false, dim: true },
{ name: 'Bun 1.4', rate: 68.1, tests: '3,438 / 5,046', us: false, dim: true },
{ name: 'Bun 1.4', rate: 70.1, tests: '3,535 / 5,046', us: false, dim: true },
];

function Compatibility() {
Expand Down
20 changes: 10 additions & 10 deletions tests/cross-runtime/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Cross-runtime Node-compatibility benchmark

This harness runs Node's own test suite — the whole `test/` tree of a Node release, the same corpus Deno vendors as [`denoland/node_test`](https://github.com/denoland/node_test) — identically against `node`, `nub`, `bun`, and `deno`, and reports pass rates per runtime under several explicit scoring lenses. Nobody curates the file list: every runtime runs the same files, with the same flags, under the same pass criterion, and every runtime's failures are published by name in `results.json`.
This harness runs Node's own test suite — the whole `test/` tree of a Node release, the same corpus Deno vendors as [`denoland/node_test`](https://github.com/denoland/node_test) — identically against `node`, `nub`, `bun`, and `deno`, and reports pass rates per runtime under several explicit scoring lenses. Nobody curates the file list: every runtime runs the same files, with the same flags, under the same pass criterion, and every runtime's failures are published by name in `results.json`. One accommodation is symmetric by design: a `node:test`-based file runs under each runtime's own test mode where it needs one (`deno test`, `bun test`) — Node runs those files as plain scripts, Deno and Bun register their tests only inside their runners, and both runtimes' own compat suites make the same switch.

## What's pinned (so it reproduces forever)

Expand All @@ -23,7 +23,7 @@ This harness runs Node's own test suite — the whole `test/` tree of a Node rel

The nub binary is a release build of `main` at `e78a6701dd` plus the `NODE_OPTIONS` coverage-exclude change committed beside this results file. This table and the results table below are generated from `results.json` by [`readme-table.mjs`](./readme-table.mjs).

macOS arm64, 2026-08-22. The retry pass flipped 1 node, 1 nub, 4 bun, 2 deno and 0 node25 verdicts, which bounds the load effect.
macOS arm64, 2026-08-22. The retry pass flipped 1 node, 1 nub, 4 bun, 2 deno and 0 node25 verdicts, which bounds the load effect. Bun's verdicts were re-measured 2026-08-30 with the `bun test` accommodation and `BUN_TEST_DRAIN_EVENT_LOOP=1` (see `buildPlainCommand` in `run.mjs`), one full bun pass over the same v26.7.0 checkout: 99 files flipped to pass and none flipped to fail.

## Reproduce it yourself

Expand Down Expand Up @@ -80,12 +80,12 @@ Node-relative pass rate (raw in parentheses). The rows are generated from `resul
<!-- results-table -->
| Lens | files / node passes | nub | deno 2.9.5 | bun 1.4.0 | node 25.9.0 |
|------|---------------------|-----|------------|-----------|-------------|
| `denoExclusions` | 5,078 / 5,046 | **98.45%** (97.87) | 74.16% (73.89) | 68.13% (67.84) | 90.15% (89.62) |
| `bunUniverse` | 4,760 / 4,736 | **98.16%** (97.67) | 71.75% (71.49) | 69.83% (69.60) | 89.55% (89.12) |
| `fullCorpus` | 5,664 / 5,616 | **97.40%** (96.61) | 68.07% (67.67) | 63.82% (63.47) | 89.96% (89.23) |
| `fullCorpusNoEngine` | 4,946 / 4,904 | **97.37%** (96.58) | 71.66% (71.25) | 69.51% (69.15) | 90.03% (89.30) |
| `bunUniverseNoEngine` | 4,111 / 4,091 | **98.22%** (97.74) | 76.04% (75.80) | 76.83% (76.60) | 89.54% (89.13) |
| `engineSpecificOnly` | 718 / 712 | **97.61%** (96.80) | 43.40% (43.04) | 24.58% (24.37) | 89.47% (88.72) |
| `denoExclusions` | 5,078 / 5,046 | **98.45%** (97.87) | 74.16% (73.89) | 70.06% (69.75) | 90.15% (89.62) |
| `bunUniverse` | 4,760 / 4,736 | **98.16%** (97.67) | 71.75% (71.49) | 71.79% (71.55) | 89.55% (89.12) |
| `fullCorpus` | 5,664 / 5,616 | **97.40%** (96.61) | 68.07% (67.67) | 65.58% (65.22) | 89.96% (89.23) |
| `fullCorpusNoEngine` | 4,946 / 4,904 | **97.37%** (96.58) | 71.66% (71.25) | 71.41% (71.03) | 90.03% (89.30) |
| `bunUniverseNoEngine` | 4,111 / 4,091 | **98.22%** (97.74) | 76.04% (75.80) | 78.98% (78.74) | 89.54% (89.13) |
| `engineSpecificOnly` | 718 / 712 | **97.61%** (96.80) | 43.40% (43.04) | 25.42% (25.21) | 89.47% (88.72) |
<!-- /results-table -->

Per directory, the three that only run properly with the full checkout, the pty and the compiled fixture (node-relative passes / Node's passes): `pseudo-tty/` nub 28 / 31, deno 15, bun 12; `wpt/` nub 24 / 25, bun 6, deno 0 (see the caveat above); `ffi/` nub 11 / 13, bun 13, deno 13 (both skip every `ffi` test — `common.skip()` exits 0 — which counts as a pass under Node's own convention).
Expand Down Expand Up @@ -117,13 +117,13 @@ Don't report a single headline percentage as "nub's compatibility" without namin

[`results-prior-versions.json`](./results-prior-versions.json) is a historical run on the Node **25.8.1** corpus (bun 1.3.14, deno 2.8.1, 2026-08-20), kept for the before/after comparison recorded in git history; it is not comparable with the table above.

A regenerated `results.json` is only half the update. The published figures are hand-copied into the `COMPAT` array in `site/src/app/(home)/page.tsx` and into the compatibility sentence in `site/content/blog/introducing-nub.mdx`; nothing reads this file at build time, so both drift silently until someone copies them across.
A regenerated `results.json` is only half the update. The published figures are hand-copied into the `COMPAT` array in `site/src/app/(home)/page.tsx` and into the compatibility sentence in `site/content/blog/introducing-nub.mdx`; nothing reads this file at build time. `readme-table.mjs --check` (run by the pre-push hook, including for pushes that touch only those files) fails on a mismatch in any hand-copied figure: both site surfaces' rates, counts, runtime version labels, Node corpus labels and the miss-count prose against `scores.denoExclusions` and `meta`, plus the wiki research doc's lens figures and this README's own retry-flip sentence. The copy step can no longer be skipped silently.

## Wording differences vs behavior differences

Not every failure is a defect a user would feel. A runtime can throw the right error at the right moment and word it differently — JavaScriptCore and V8 phrase the same brand check differently, so an `assert.throws({ message })` fails on text alone. That is worth measuring rather than asserting, so we did: re-run the corpus with failure output retained (one change to `run.mjs` — keep `raw.out` in `judge()`; the pass criterion is untouched), then bucket each failure by which keys differ inside `node:assert`'s `Comparison { }` block.

Forgiving every failure whose *only* difference is the message text moves bun 1.4.0 from 66.8% to 68.1% and deno 2.9.5 from 78.4% to 78.8%. nub does not move at all: it executes on the stock Node binary, so its error messages *are* Node's, and the count is zero rather than small. Also forgiving a differing or absent `code`/`name` reaches 69.1% and 80.3%.
Forgiving every failure whose *only* difference is the message text moves bun 1.4.0 from 66.8% to 68.1% and deno 2.9.5 from 78.4% to 78.8% (measured on the 2026-08-29 verdicts, before the `bun test` accommodation shifted bun's baseline; the classification of individual failures is unaffected). nub does not move at all: it executes on the stock Node binary, so its error messages *are* Node's, and the count is zero rather than small. Also forgiving a differing or absent `code`/`name` reaches 69.1% and 80.3%.

**The published figures forgive neither, and the second is the reason.** Message text is cosmetic; error identity is not. bun frequently throws a raw JavaScriptCore `TypeError` carrying no `code` property at all, which breaks any program branching on `err.code === 'ERR_INVALID_ARG_TYPE'` — a real incompatibility that a text-only classifier reports as a wording nit.

Expand Down
100 changes: 99 additions & 1 deletion tests/cross-runtime/readme-table.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,107 @@ for (const blk of BLOCKS) {
console.log(expected + "\n");
}
}
// The published figures are hand-copied onto four surfaces and nothing else
// rechecks them — the homepage COMPAT array, the blog sentence and the wiki
// research doc all drifted when a re-measurement moved a score, and this
// README's own retry-flip sentence went stale twice in one PR. --check
// compares every one against results.json; a missing file exits 2
// (uncheckable), a mismatch exits 1 like table drift.
function checkHandCopies() {
const by = (lens) => {
const s = results.scores[lens];
const m = Object.fromEntries(s.runtimes.map((r) => [r.runtime, r]));
return { rate: (rt) => (m[rt].pass / s.nodePass * 100).toFixed(1), pass: (rt) => m[rt].pass, nodePass: s.nodePass };
};
const deno = by("denoExclusions"), bun = by("bunUniverse"), full = by("fullCorpus");
// major.minor from the measured binary, so a re-measure on a newer runtime
// also forces the hand-written 'Bun 1.4' / 'Deno 2.9' labels to move.
const mm = (rt) => version(rt).split(".").slice(0, 2).join(".");
const corpusMM = String(results.meta.corpusNodeVersion || "?").split(".").slice(0, 2).join(".");
const nubMisses = String(deno.nodePass - deno.pass("nub"));
const surfaces = [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The two surfaces listed here are the two that are currently correct. The two that actually drifted during this PR aren't covered: wiki/research/node-test-suite-leverage.md:161 carries the same three denoExclusions rates (plus six more from bunUniverse/fullCorpus) and went stale across two review rounds before 50bdd37 fixed it, and README.md:26's retry-flip counts went 4 → 1 → 4 across three commits. Neither is reachable by the BLOCKS loop, which only covers the marker-delimited tables.

Technical details
# The hand-copy gate covers 2 of 4 surfaces

## Affected sites
- `tests/cross-runtime/readme-table.mjs:97``surfaces` lists the homepage `COMPAT` array and the blog sentence only.
- `wiki/research/node-test-suite-leverage.md:161` — publishes `nub 98.5%, deno 74.2%, bun 70.1%` (`denoExclusions`), `98.2 / 71.7 / 71.8` (`bunUniverse`) and `97.4 / 68.1 / 65.6` (`fullCorpus`). Tracked and world-readable. Currently correct, ungated, and this PR had to hand-edit it after the re-measurement.
- `tests/cross-runtime/README.md:26``1 node, 1 nub, 4 bun, 2 deno and 0 node25` are `meta.retried.<rt>.flippedToPass`, hand-written prose. The same line's `99 files flipped to pass and none flipped to fail` is likewise hand-derived.
- `tests/cross-runtime/readme-table.mjs:88-92` — the explanatory comment states the same two-surface enumeration, so the code and the prose agree with each other but not with the repo.

## Required outcome
- Every surface carrying a figure derived from `results.json` is either asserted by `--check` or explicitly named as out of scope, so the next re-measurement has a complete checklist.

## Suggested approach (optional)
- `README.md:26`'s five counts are the cheapest to add: they are direct `meta.retried.<rt>.flippedToPass` reads, so one more `surfaces` entry pointing at `README` with a `/flipped (\d+) node, (\d+) nub/`-style pattern ends that flip-flop class.
- The wiki line spans three lenses rather than one, so it may be better served by dropping the per-runtime percentages and pointing at the README table — which is already generated — than by adding nine more assertions.

## Open questions for the human
- Is the wiki doc meant to carry standing figures at all, or is a pointer to the generated table the better long-term shape given it has now drifted twice?

{
file: path.join(HERE, "../../site/src/app/(home)/page.tsx"),
wants: [
{ re: /name: 'Nub', rate: ([\d.]+), tests: '([\d,]+) \/ ([\d,]+)'/, lens: deno, rt: "nub" },
{ re: /name: 'Deno [\d.]+', rate: ([\d.]+), tests: '([\d,]+) \/ ([\d,]+)'/, lens: deno, rt: "deno" },
{ re: /name: 'Bun [\d.]+', rate: ([\d.]+), tests: '([\d,]+) \/ ([\d,]+)'/, lens: deno, rt: "bun" },
Comment thread
pullfrog[bot] marked this conversation as resolved.
{ re: /name: 'Deno ([\d.]+)'/, value: mm("deno"), label: "Deno version label" },
{ re: /name: 'Bun ([\d.]+)'/, value: mm("bun"), label: "Bun version label" },
Comment thread
pullfrog[bot] marked this conversation as resolved.
{ re: /name: 'Node ([\d.]+)'/, value: corpusMM, label: "Node corpus label" },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

page.tsx carries two corpus labels and this want reaches only one. Line 1007's prose reads Node &#38;rsquo;s own test suite — literally Node 26.7&#38;rsquo;s own test suite, the same corpusMM value the blog's equivalent sentence is now checked against — but the name: ' prefix exists only at line 955. Verified: perturbing line 1007 alone to Node 22.1 leaves --check at exit 0.

Technical details
# The page.tsx prose corpus label is still ungated

## Affected sites
- `site/src/app/(home)/page.tsx:1007` — `Node 26.7&rsquo;s own test suite under Deno&rsquo;s
  compatibility lens`. Equals `meta.corpusNodeVersion` major.minor, the same quantity `corpusMM`
  already computes.
- `tests/cross-runtime/readme-table.mjs:115``/name: 'Node ([\d.]+)'/` anchors on the COMPAT
  array at page.tsx:955, so it cannot reach line 1007.
- `site/content/blog/introducing-nub.mdx:36` — the analogous prose sentence IS gated by the new
  `/On Node (\d+\.\d+)'s own test suite/` want, which is what makes the page.tsx omission stand out.

## Required outcome
- A corpus label on page.tsx that disagrees with `meta.corpusNodeVersion` fails `--check`, wherever
  on the page it appears.

## Suggested approach (optional)
- One more want on the surface the gate already reads:
  `{ re: /Node ([\d.]+)&rsquo;s own test suite/, value: corpusMM, label: "Node corpus label (prose)" }`.
  Note the label then appears twice in the file, so if these two ever need to differ the patterns
  must stay distinct — today both should read `26.7`.

{ re: /name: 'Node [\d.]+', rate: 100, tests: '([\d,]+) \/ ([\d,]+)'/, values: [n(deno.nodePass), n(deno.nodePass)], label: "Node row counts" },
{ re: /Most of Nub&rsquo;s (\d+) misses/, value: nubMisses, label: "Nub miss count" },
{ re: /Node (\d+\.\d+)&rsquo;s own test suite/, value: corpusMM, label: "Node corpus label (prose)" },
],
},
{
file: path.join(HERE, "../../site/content/blog/introducing-nub.mdx"),
wants: [
{ re: /clears ([\d.]+)% of what real Node passes/, lens: deno, rt: "nub" },
{ re: /([\d.]+)% for Deno/, lens: deno, rt: "deno" },
{ re: /([\d.]+)% for Bun/, lens: deno, rt: "bun" },
{ re: /for Deno (\d+\.\d+)/, value: mm("deno"), label: "Deno version label" },
{ re: /for Bun (\d+\.\d+)/, value: mm("bun"), label: "Bun version label" },
{ re: /On Node (\d+\.\d+)'s own test suite/, value: corpusMM, label: "Node corpus label" },
],
},
{
file: path.join(HERE, "../../wiki/research/node-test-suite-leverage.md"),
wants: [
{ re: /skip list: nub ([\d.]+)%, deno ([\d.]+)%, bun ([\d.]+)%/, lens: deno, rts: ["nub", "deno", "bun"] },
{ re: /nothing skipped\): nub ([\d.]+)%, deno ([\d.]+)%, bun ([\d.]+)%/, lens: bun, rts: ["nub", "deno", "bun"] },
{ re: /wrappers: nub ([\d.]+)%, deno ([\d.]+)%, bun ([\d.]+)%/, lens: full, rts: ["nub", "deno", "bun"] },
],
},
{
file: README,
wants: [
{
re: /flipped (\d+) node, (\d+) nub, (\d+) bun, (\d+) deno and (\d+) node25 verdicts/,
counts: ["node", "nub", "bun", "deno", "node25"].map((rt) => String(results.meta.retried?.[rt]?.flippedToPass ?? "?")),
label: "retry-flip counts vs meta.retried",
},
],
},
];
let bad = false;
for (const { file, wants } of surfaces) {
let src;
try { src = fs.readFileSync(file, "utf8"); } catch (e) { console.error(`cannot read ${file}: ${e.message}`); process.exit(2); }
for (const w of wants) {
const m = w.re.exec(src);
const name = w.label || w.rt || (w.rts || []).join("/");
if (!m) { console.error(`${path.basename(file)}: pattern for ${name} not found (${w.re})`); bad = true; continue; }
if (w.value !== undefined || w.values) {
const list = w.values || [w.value];
list.forEach((want, i) => {
if (m[1 + i] !== want) { console.error(`${path.basename(file)}: ${name} says ${m[1 + i]}, results.json says ${want}`); bad = true; }
});
continue;
}
const rts = w.counts ? [] : (w.rts || [w.rt]);
rts.forEach((rt, i) => {
const want = w.lens.rate(rt);
if (m[1 + i] !== want) { console.error(`${path.basename(file)}: ${rt} says ${m[1 + i]}, results.json says ${want}`); bad = true; }
});
if (w.counts) {
w.counts.forEach((want, i) => {
if (m[1 + i] !== want) { console.error(`${path.basename(file)}: ${name} — position ${i + 1} says ${m[1 + i]}, meta.retried says ${want}`); bad = true; }
});
}
if (w.rt && m[2] && (m[2] !== n(w.lens.pass(w.rt)) || m[3] !== n(w.lens.nodePass))) {
console.error(`${path.basename(file)}: ${w.rt} tests say ${m[2]} / ${m[3]}, results.json says ${n(w.lens.pass(w.rt))} / ${n(w.lens.nodePass)}`);
bad = true;
}
}
}
return bad;
}

if (process.argv.includes("--check")) {
if (checkHandCopies()) drifted = true;
if (drifted) process.exit(1);
console.log("README tables match results.json");
console.log("README tables and site figures match results.json");
} else if (process.argv.includes("--write")) {
fs.writeFileSync(README, readme);
console.log("README tables rewritten");
Expand Down
Loading
Loading