Building a JavaScript engine in Rust to full Test262 conformance
Zipp passes every execution of the pinned tc39/test262 corpus on a documented corrected profile, and 95,671 of 95,680 unmodified. This article is about how that happened between 30 May and 14 September 2026, with particular attention to the day the number went down.
Why Test262 first
A JavaScript engine that is fast and wrong is a fast way to be wrong. Test262 is the ECMAScript specification's own conformance suite, maintained by TC39, and it is large enough (tens of thousands of files, most run in two modes) that passing it means the language is actually implemented rather than demonstrated. Zipp's runner landed on 1 June 2026, one day after the pivot to JavaScript, and from then on every feature was judged by executions passed.
The runner design is the first decision that mattered. It runs each test through the real CLI, zipp js --script-goal <test> <harness>, with the harness as a separate script rather than concatenated source. It requires negative tests to exit with code exactly 1 and to print a diagnostic naming the expected error type; it treats a Rust panic as a failure; it treats any evidence that a $DONOTEVALUATE body ran as a failure; and it treats timeouts as failures rather than skips. Skips stay in the denominator so that breaking 500 tests can never raise the score.
The engine underneath
Everything runs on zipp-vm's explicit-frame register VM: a hand-written lexer and recursive-descent parser, a compiler to three-address register bytecode, an interpreter over NaN-boxed 64-bit values, shapes and inline caches, and native JIT tiers that bail to the exact bytecode instruction. The architecture page covers the design; what matters here is that JavaScript frames never live on the Rust stack. That is why a recursion test ends in a catchable RangeError instead of a crash, why eval and modules and generators and async functions are all runtime structures the same code handles, and why the JIT can be turned off (ZIPP_NOJIT=1) or forced on (ZIPP_JIT_THRESHOLD=1) and the suite run again in each mode to certify the native paths.
June: 1,039 commits of language
The June commit log reads like the table of contents of the specification. Async generators were "the biggest conformance lever". 2 June alone added regular expressions through a forked regress engine, the eleven TypedArray constructors, Proxy traps, Temporal one type at a time from Duration to Now, the whole Intl namespace, and a mark-sweep collector, and split the 17,400-line vm.rs into a module folder. eval was "the #1 conformance lever" on 3 June. ES modules with top-level await arrived on 8 June, blocking Atomics.wait with a cross-thread waiter registry on 11 June, WTF-8 strings with real lone-surrogate support and arbitrary-precision BigInt on 12 June, and on 13 June an adversarial audit of the JIT's own codegen found and fixed six soundness bugs.
Then the log stops for six weeks. When it resumes, the first thing it does is admit a mistake.
25 July: 96.97%, not 100%
Commit 1217d683, "Fix the test262 runner; real pass rate is 96.97%, not 100%". The runner had been reporting "PASS 48550 FAIL 6" on a single execution mode, 48,556 of the 96,029 executions the corpus actually required. Three scorer defects were fixed, and the honest baseline was 93,122 of 96,029. The same day the workspace was narrowed to the one crate that mattered, the docs were rewritten against measurements, and every number in the README got a commit hash.
The climb, execution by execution
| Date | Result | What changed |
|---|---|---|
| 2026-07-25 | 93,122 / 96,029 (96.97%) | Scorer fixed |
| 2026-07-26 | 93,835 (97.72%) | Regex literals validated at compile time: +712 executions |
| 2026-07-26 | 94,217 (98.1%) | A new in-house front end |
| 2026-07-27 | 95,091 (99.0%) | Parse-negative failures 607 files to 80 |
| 2026-07-28 | 95,732 / 95,848 (99.88%) | Intl.DurationFormat, the IANA time-zone database, non-ISO Temporal calendars |
| 2026-07-29 | 95,841 / 95,846 (99.995%) | 30 failures to 5; 22 of the 30 were repo or runner defects |
| 2026-07-29 | 95,939 / 95,942 (99.997%) | Corpus re-pinned; Iterator.prototype.join implemented (36 executions V8 does not pass either) |
| 2026-08-29 | 95,929 / 95,942 | A 24-row drift from a call-lowering change, root-caused and reversed |
| 2026-09-11 | 95,665 / 95,680 | Corpus re-pinned to 4249661; two JIT-only defects found and fixed |
| 2026-09-14 | 95,671 unmodified, 95,680 corrected | Annex B hidden defect fixed, CLDR 48 DateTimeFormat data, five corrections documented |
The hard corners
Annex B block functions
The longest saga. Annex B's web-compatibility semantics for function declarations in blocks were claimed as "more conformant than Node" on 29 July, retracted, re-fixed, and then on 14 September a real hidden defect was found behind an obsolete test expectation: a block function was not visible before its textual declaration. The block binding is now created at block entry, sloppy arrow functions get the same var setup, and the one test that still carries ES2017 wording deleted in ES2018 is corrected by a documented patch that Node 24.19 also needs.
Parser early errors
A probe of all 8,755 negative executions found 42 cases where the engine raised the wrong error type across 21 files: rest-parameter defaults, yield in arrow parameter initializers, private names in object literals and destructuring, and private access through super. Getting the type right, not merely failing, is what the runner demands.
Modules
[[CycleRoot]] had to be computed at depth-first-search time, errored cycles had to rethrow on re-import, deferred top-level-await ordering had to match, and module-level using declarations were never being disposed. Three module rows regressed in August and were recovered by fixing the cycle root.
The JIT-only failures
Two of the fifteen failures on the new corpus only appeared with the JIT on: a native array-builtin callback deoptimization re-ran side effects, and a named lookup stopped at an Array spliced into a prototype chain. This is why conformance is verified in four modes locally. Default conformance alone cannot certify the native helper paths.
Intl.DateTimeFormat
In July it could not be constructed at all. By 14 September it had generated CLDR 48.0.0 tables for English, German, Japanese, Chinese and Egyptian Arabic, calendar-specific patterns, related and cyclic years, leap-month labels, Islamic, Coptic and Japanese eras, all four hour cycles and Arabic digits, and passed its 488-execution shard. That is one shard, and the other Intl services still ship English data only; the project says so on every page that mentions it.
What "corrected" means, and why it is separate
Nine executions in the pinned corpus contradict themselves or a later edition of the specification: six where the pinned harness now lists Error among the native errors so the tests demand Reflect.getPrototypeOf(Error) === Error, two where an immutable-buffer factory was added to the harness but the test still expects a write to succeed, and one Annex B test with superseded wording. Zipp keeps five unified patches with before-and-after hashes and a reason for each, applies them in a fresh clone under a driver that verifies the changed paths equal the manifest and nothing else, and reports the corrected result beside the unmodified one. It never reports the corrected number alone. The Test262 page has the manifest and links to the hosted run.
Gating it
The conformance job builds the release CLI, asserts that the binary's recorded source commit equals HEAD and is not dirty, runs both suites with a 120-second timeout, then the DateTimeFormat shard, and uploads the reports. The release workflow calls it at the exact tagged commit and will not publish without it. The number on this site is the number that gate produced on 14 September 2026 at commit 1539eb4b.
What I would tell someone starting
- Write the runner before the features, and make it impossible for the runner to flatter you: exit codes, error types, skips in the denominator, byte-faithful file reads.
- Put the frame stack in the heap. Almost every hard feature (
eval, generators, async, modules, realms, exact deoptimization) became a data-structure problem instead of a control-flow problem. - Re-run the suite with the JIT off, forced, and with the nursery off. Two of the last fifteen failures were invisible in the default mode.
- When you change the denominator, say so in the commit subject and republish every number.
- When the suite is wrong, patch it in the open, with hashes, and report both numbers forever.