Reverse bytenode-compiled .jsc files back to JavaScript.
Static parsing only: one pure-Rust binary — no patched V8, no Node runtime, no network.
Supports Node 8.0.0 → 26.13.0 (519 releases / 36 V8 minors) and Electron 11 and 19 → 45.2
(V8 8.7 – 15.6, pointer-compressed and sandboxed builds included; Electron 11 and every
major from 20 to 45 pass the same 9-fixture behavior check, 9/9 each); ≈ 25k .jsc files pass the
tests with 0 failures (32 Node lines × 41 fixtures, compiled with bytenode 1.7.0's flags); two
optimization layers (register folding → cure simplification) make the decompiled source readable.
English · 中文
Contents: Highlights · Quick start · Install · Usage · CLI reference · Supported versions · How it works · Verification · Repository layout · Limitations · Contributing
- Coverage — every Node release from 8.0.0 → 26.13.0 (519 releases / 36 V8 minors) is identified; a V8 outside the tables is a clear error, not a guess.
- Runnable output —
--runtimeadds the__runtimestubs so the result runs undernode;--verifyputs it throughnode --check. - Readable output — two optimization layers fold bytecode register shuffling back into
expressions (register folding → cure simplification);
JSCD_NO_OPT=1shows the raw form. - Names recovered — scope slots are named, builtins resolved through a read-only-heap name
table; never an identifier that throws
ReferenceError. - One dependency-free binary — pure Rust: no Node, no network, no patched V8; the Linux build is a 2.8 MB static binary (UPX).
- Reproducible verification — 32 Node lines × 41 fixtures compared case by case, plus corpus sweeps; every number rerunnable (see Verification).
$ cat hello.js
function greet(name) {
return "hello " + name;
}
console.log(greet("world"));
$ npm i -g bytenode && bytenode -c hello.js # Node 20.20.2 in this transcript
$ xxd -l 48 hello.jsc
00000000: cc05 dec0 0bc2 e400 9200 0000 e521 2eaa .............!..
00000010: f002 0000 0000 0000 011c 5401 2006 a860 ..........T. ..`
00000020: 0000 0000 0600 0000 010c 4c60 0000 0000 ..........L`....
$ jscd hello.jsc
console.log(greet("world"));
function greet(a0) {
return "hello " + a0;
}
$ jscd hello.jsc --runtime > hello.out.js && node hello.out.js
hello worldjscd info hello.jsc decodes that header (sample in Usage); JSCD_NO_OPT=1 prints the
raw, pre-optimization form instead.
macOS / Linux — Homebrew
brew install ejfkdev/tap/jscdAny platform — npm (Node.js 18+)
npm install -g @ejfkdev/jscdThe package downloads the matching release binary and verifies its SHA-256, so no Rust toolchain is
needed. Set JSCD_BINARY_PATH to use a local binary instead.
Windows — WinGet
winget install --id ejfkdev.jscd -e
winget upgrade --id ejfkdev.jscd -eThe manifest lives in ejfkdev/release-automation; the current manifest submission is awaiting review in microsoft/winget-pkgs#453064. Until it is merged, use Scoop, npm, or the release binary.
Windows — Scoop
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket
scoop install jscdStraight from the manifest URL, without adding the bucket first:
scoop install https://raw.githubusercontent.com/ejfkdev/scoop-bucket/main/bucket/jscd.jsonPrebuilt binaries — Releases.
cargo (any platform with Rust 1.96+)
cargo install jscd # build from crates.io
cargo binstall jscd # or fetch the release binary instead of compiling (cargo-binstall)From source
cargo install --git https://github.com/ejfkdev/jscd # latest main
git clone https://github.com/ejfkdev/jscd && cd jscd
cargo build --release # -> target/release/jscd
cargo install --path . # ...or install that build into ~/.cargo/binRequires Rust 1.96+ (the workspace toolchain pin; cure itself needs 1.74). No system dependencies.
Releasing (maintainers)
scripts/release.sh vX.Y.Z runs the release gates (cargo test --release,
clippy --all-targets -- -D warnings, and the end-to-end smoke scripts/ci_smoke.sh — fixtures
compiled to real .jsc, decompiled, syntax-gated and run-compared), then pushes an annotated
tag whose message becomes the GitHub Release description. The tag triggers
.github/workflows/release.yml, which rebuilds the six bare binaries listed above. Then
cargo publish to crates.io (release.sh keeps the tag and the Cargo.toml version in sync), and
python3 scripts/update_readme_help.py to refresh the CLI help below; the
Homebrew tap and the
Scoop bucket pick the new release up on their daily
auto-update run.
jscd app.jsc # decompile one file to stdout
jscd app.jsc app.js # ...to a file
jscd dist/ # every .jsc under dist/ → dist-out/ (tree mirrored)
jscd dist/ out/ # ...into out/ instead
jscd info app.jsc # header fields, detected Node/V8 version
jscd disasm --filter main app.jsc
jscd ro-map probe.jsc > m.json # build a read-only-heap name table
jscd --help # bilingual help (-h, `help`, `help <SUBCOMMAND>`)- Input is one
.jscfile, or a directory scanned recursively for*.jsc; output defaults to stdout for a file and to<INPUT>-out/(tree mirrored) for a directory —-o/ the OUTPUT argument writes elsewhere,-means stdout. - A
.jscfrom an unsupported V8 is rejected with the detected version and the supported range in the message (jscd infoprintssupported: yes|no) — never a silent, runtime-only file. - Read-only-heap names (Node 22+) resolve automatically where an embedded table applies (macOS,
non-pointer-compressed builds); elsewhere build one with
jscd ro-mapand pass--ro-map(JSCD_NO_RO_MAP=1disables the embedded table). Embedded tables are never applied to pointer-compressed caches (Electron/Chrome): they share the V8 version string with Node but lay the read-only heap out differently. The same table also pins string roots (roots): their indices shift between builds, so a table from your own build turns"0"back into"0"instead of whatever another build stored at that index. Platform / tagged-size mismatches are ignored; even the same V8 version string can come from a different Node patch build (-node.NN) with shifted read-only-heap addresses, so if names show up as<ro…>placeholders, build a table with your own probe. - The interface language follows
JSCD_LANG(thenLC_ALL/LC_MESSAGES/LANGUAGE/LANG/LC_CTYPE):zh*is Chinese, anything else English;JSCD_LANG=zh|enforces one.
| Flag | Applies to | Effect |
|---|---|---|
-o, --output <PATH> |
all forms | output file / directory / - (same as the OUTPUT argument) |
--quiet |
directory input | suppress per-file progress |
--json |
all subcommands | machine-readable output |
--filter <SUBSTR> |
disasm |
only emit functions whose name contains SUBSTR |
--ro-map <PATH> |
decompile |
read-only-heap name table produced by jscd ro-map |
--verify |
decompile |
syntax gate: run the result through node --check (needs node on PATH) |
--runtime |
decompile |
keep the runnable preamble (__runtime stubs, flattened context vars, name aliases). Off by default — add it when you want to run the product with node |
-h, --help |
all forms | print help (jscd help <SUBCOMMAND> for one command) |
-v, -V, --version |
all forms | print name, version and repository |
Everything the CLI prints for --help and help <SUBCOMMAND>, verbatim (regenerate with python3 scripts/update_readme_help.py).
Full CLI help — jscd --help and every subcommand, verbatim
jscd --help
$ jscd --help
jscd 0.3.1 — V8 code cache → JavaScript
https://github.com/ejfkdev/jscd (MIT license)
Reconstructs JavaScript from bytenode-compiled .jsc files (V8 code caches).
Static parsing — no patched V8, no Node runtime. Directories are scanned
recursively.
Usage: jscd [OPTIONS] <INPUT> [OUTPUT] # decompile (default action)
jscd <SUBCOMMAND> [ARGS...] # stage-by-stage analysis
jscd help [SUBCOMMAND] | version | -h | -v | -V
INPUT a .jsc file, or a directory (scanned recursively for *.jsc).
OUTPUT a file, a directory, or `-` for stdout:
<file.js> one input file
<dir> directory input, tree mirrored
default: stdout for a file; <INPUT>-out/ next to it for a directory
Options:
-o, --output <path> output file / directory / - (same as OUTPUT)
--ro-map <path> read-only-heap name table (decompile)
--verify syntax-gate the result with `node --check` (decompile)
--runtime keep the runnable prelude (`__runtime` stubs) so the output
can be run with node; off by default — the output is
then just the reconstructed code
--filter <substr> only functions whose name contains substr (disasm)
--json machine-readable output (info/strings/functions/disasm/ro-map)
--quiet suppress per-file progress (directory input)
-h, --help print this help
-v, -V, --version print name, version, repository
JSCD_LANG=zh|en force the help/error language (default: auto-detect)
Subcommands:
decompile reconstruct JavaScript (default action: `jscd <INPUT>`)
info header fields and the detected Node/V8 version
strings string and symbol constants from the constant pools
functions SharedFunctionInfo tree (names, params, bytecode sizes)
disasm bytecode disassembly (View8-compatible text)
ro-map build a read-only-heap name table from a probe cache
help print help (`jscd help <SUBCOMMAND>`)
version print name, version and repository
Examples:
jscd app.jsc decompile to stdout
jscd app.jsc app.js decompile to a file
jscd dist/ dist/**.jsc → dist-out/**.js
jscd dist/ out/ ... into out/ instead
jscd decompile --ro-map m.json a.jsc with a name table
jscd info app.jsc header fields and V8 version
jscd disasm --filter main app.jsc one function's bytecode
jscd help disasm per-subcommand help with examples
jscd ro-map probe.jsc -o names.json
build a name table (see `jscd help ro-map`)jscd help decompile
$ jscd help decompile
jscd decompile — reconstruct JavaScript (default action: `jscd <INPUT>`)
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd decompile [OPTIONS] <INPUT> [OUTPUT]
jscd [OPTIONS] <INPUT> [OUTPUT] # same thing
INPUT is a .jsc file, or a directory (scanned recursively for *.jsc).
OUTPUT is a file, a directory, or `-` for stdout. Default: stdout for a
file input; <INPUT>-out/ next to it for a directory.
Options:
-o, --output <path> output file / directory / - (default: stdout)
--ro-map <path> read-only-heap name table from `jscd ro-map`;
restores builtin names (`a.length`, `o.push`)
--verify syntax-gate the result with `node --check`
--runtime keep the runnable prelude (`__runtime` stubs) so the
output runs under node; off by default
--quiet suppress per-file progress (directory input)
Examples:
jscd decompile app.jsc decompile to stdout
jscd decompile app.jsc app.js write to app.js
jscd decompile dist/ dist/**.jsc -> dist-out/**.js
jscd --ro-map names.json a.jsc > a.js with a read-only-heap name table
jscd decompile --runtime --verify a.jsc -o a.run.js
runnable, syntax-checked output
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help info
$ jscd help info
jscd info — header fields and the detected Node/V8 version
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd info [OPTIONS] <FILE>
Prints the code-cache header: magic, version hash, V8/Node version, flags,
payload size, and whether this build supports that V8 version.
Options:
-o, --output <path> write to a file ('-' = stdout; default stdout)
--json one JSON object: file, size_raw, brotli,
version_hash, v8, node, supported, ...
Examples:
jscd info app.jsc header fields and V8 version
jscd info --json app.jsc | jq .supported is this V8 version supported?
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help strings
$ jscd help strings
jscd strings — string and symbol constants from the constant pools
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd strings [OPTIONS] <FILE>
Dumps the string/symbol constants this cache can reference, one per line.
The JSON `from_roots` list is the subset reachable from the root function.
Options:
-o, --output <path> write to a file ('-' = stdout; default stdout)
--json {"count":N,"strings":[...],"from_roots":[...]}
Examples:
jscd strings app.jsc all constants, one per line
jscd strings --json app.jsc | jq .count how many constants
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help functions
$ jscd help functions
jscd functions — SharedFunctionInfo tree (names, params, bytecode sizes)
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd functions [OPTIONS] <FILE>
Lists the SharedFunctionInfo tree: id, name, parameter count, bytecode
length, frame size, whether it was compiled, and nesting depth.
Options:
-o, --output <path> write to a file ('-' = stdout; default stdout)
--json {"count":N,"functions":[{"id","name","params",
"bytecode_length","frame_size","compiled","depth"}]}
Examples:
jscd functions app.jsc the whole SFI tree
jscd functions --json app.jsc | jq '.functions[].name'
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help disasm
$ jscd help disasm
jscd disasm — bytecode disassembly (View8-compatible text)
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd disasm [OPTIONS] <FILE>
Prints View8-compatible bytecode disassembly for every function, or only
the ones whose name contains --filter.
Options:
--filter <substr> only functions whose name contains substr
-o, --output <path> write to a file ('-' = stdout; default stdout)
--json {"disassembly":"<the whole listing>"}
Examples:
jscd disasm app.jsc full bytecode listing
jscd disasm --filter main app.jsc only functions named *main*
jscd disasm --json app.jsc > d.json machine-readable listing
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help ro-map
$ jscd help ro-map
jscd ro-map — build a read-only-heap name table from a probe cache
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd ro-map [OPTIONS] <PROBE.jsc>
Walks a probe cache's read-only heap and writes a name table, so decompiled
output says `a.length` instead of `<ro0_1120>` and `"0"` instead of the name
a different build gave that root. Two sections: `entries` (read-only-heap
offset -> name) and `roots` (root index -> name, for string constants whose
indices shift between builds). Build one table per build (see
docs/VERSIONS.md for the probe recipe).
Options:
-o, --output <path> write the table to a file (default: stdout)
--json {"schema","v8","tags","entries":{"0/1120":"length"},"roots":{"571":"0"}}
Examples:
jscd ro-map probe.jsc -o names.json build a name table
jscd ro-map probe.jsc | head peek at the table
jscd ro-map --json probe.jsc | jq '.roots' just the string-root overrides
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help help
$ jscd help help
jscd help — print help (`jscd help <SUBCOMMAND>`)
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd help [SUBCOMMAND]
Prints the main help, or one subcommand's help (usage, options, examples).
Examples:
jscd help main help
jscd help disasm the disasm page with examples
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.jscd help version
$ jscd help version
jscd version — print name, version and repository
https://github.com/ejfkdev/jscd (MIT license)
Usage: jscd version
Prints name, version, repository and license (`-v` / `-V` / `--version` too).
Examples:
jscd version same as `jscd -v`
Global: --json works on every subcommand; `jscd --help` lists subcommands and options.By default the output is just the reconstructed code — no runtime preamble and no comments
(the AST layer strips even the structural markers; JSCD_NO_OPT=1 keeps them). --runtime
prepends the __runtime proxy (minimal stubs for the V8 builtins the bytecode calls), the
file-level declarations for flattened context variables, and the name-alias block, so the file
runs as-is with node.
jscd info sample output
$ jscd info hello.jsc
file: hello.jsc
size: 776 bytes (raw) / 776 bytes (decompressed)
brotli: no
magic: 0xc0de05cc (external refs: 0x5cc)
version_hash: 0x00e4c20b
source_hash: 0x00000092 (source length: 146 bytes, module: no)
flag_hash: 0xaa2e21e5
ro_checksum: (absent)
payload: 752 bytes at offset 24
checksum: 0x00000000
node: v20.20.2
v8: 11.3.244.8 (exact)
supported: yesJSCD_NO_OPT=1 disables both, for diffing against the raw translation.
-
Text layer (
src/opt.rs): folds bytecode register shuffling back into expressions — single-use values inlined into their use, stores overwritten before any read dropped, unused register declarations and pure bookkeeping (DeclareGlobals) removed,let r0; … r0 = E;lifted into a declarator, and receiver/argument loads rebuilt at the call site in JavaScript evaluation order (receiver → property → arguments), so nothing is reordered. It refuses to cross blocks, loop headers, braceless control bodies, or a value that is still used later (including the self-referencing writer2 = new r2(a0)). -
AST layer (
src/opt_js.rs): the text then goes through cure's TypeScript/JavaScript backend — a fault-tolerant, semantic-preserving simplifier built for decompiled code. It folds constants and pure expressions, removes dead stores and no-op statements, simplifies control flow, normalizes object/array/class literals, and prints statement-per-line (it does not merge statements into comma sequences or rename anything):// after both layers let r0 = console.log('hello world'); function greet(a0) { return 'hello ' + a0; }
Parse errors, opaque regions or an empty result → the text-layer result is kept unchanged, so a cache the simplifier does not understand still produces the previous output.
Version tables are generated from Node sources by scripts/codegen.py and embedded at build time.
Selection is keyed on the V8 version hash; every Node release from 8.0.0 on is listed in
tables/manifest.json (519 Node releases + 30 Electron builds), and one table covers each
V8 minor — identical tables are stored once.
Electron is covered the same way: 11 and 19 → 45.2 (V8 8.7 – 15.6), with a dedicated table
for 11 and every major 20–45 (22 shares Node's 10.8 table) and 9/9 fixtures per build
verified against that build's own bytecode/root traces. Bytecode produced inside an Electron
main or renderer process (compileElectronMainCode / compileElectronRendererCode, bytenode 2.x)
uses the same layout and version hash as ELECTRON_RUN_AS_NODE output; only the read-only-snapshot
checksum differs, and that checksum is not part of the structural match.
Every Node release from 8.0.0 through 26.13.0 is identified (519 releases, 90 V8 versions),
and 29 of the 36 V8 minors pass the full behavior matrix — 32 Node lines, all green
(1,144 pass / 0 fail / 8 skip / 0 known-fail). The remaining 7 minors (5.8, 6.0, 6.1, 6.6, 6.7,
7.0, 7.4) are identified by jscd info but their payload deserialization errors out loudly
instead of emitting garbage.
| V8 minors | Node lines | Result |
|---|---|---|
| 6.2 → 14.6, 29 of 36 | 32 lines, 8.17 → 26.13 | all green — 1,144 pass / 0 fail / 8 skip |
| 5.8, 6.0, 6.1, 6.6, 6.7, 7.0, 7.4 | 8.0–8.9, 10.0–10.8, 11.x, 12.0 (partial releases) | identified; payload explicitly rejected |
Node mapping: 36 version tables → 519 releases
| Node releases | V8 (newest) | table |
|---|---|---|
| 8.0.0–8.2.1 | 5.8.283.41 | tables/v5_8.json |
| 8.3.0–8.6.0 | 6.0.287.53 | tables/v6_0_8.6.0.json |
| 8.7.0–8.9.4 | 6.1.534.50 | tables/v6_1_8.9.4.json |
| 8.10.0–9.11.2 | 6.2.414.46 | tables/v6_2.json |
| 10.0.0–10.3.0 | 6.6.346.32 | tables/v6_6_10.3.0.json |
| 10.4.0–10.8.0 | 6.7.288.49 | tables/v6_7_10.8.0.json |
| 10.9.0–10.24.1 | 6.8.275.32 | tables/v6_8.json |
| 11.0.0–11.15.0 | 7.0.276.38 | tables/v7_0_11.15.0.json |
| 12.0.0–12.4.0 | 7.4.288.27 | tables/v7_4_12.4.0.json |
| 12.5.0–12.8.1 | 7.5.288.22 | tables/v7_5.json |
| 12.9.0–12.10.0 | 7.6.303.29 | tables/v7_6.json |
| 12.11.0–12.15.0 | 7.7.299.13 | tables/v7_7_12.15.0.json |
| 12.16.0–13.1.0 | 7.8.279.17 | tables/v7_8.json |
| 13.2.0–13.14.0 | 7.9.317.25 | tables/v7_9_13.14.0.json |
| 14.0.0–14.4.0 | 8.1.307.31 | tables/v8_1_14.4.0.json |
| 14.5.0 | 8.3.110.9 | tables/v8_3.json |
| 14.6.0–14.21.3 | 8.4.371.23 | tables/v8_4.json |
| 15.0.0–15.14.0 | 8.6.395.17 | tables/v8_6_15.14.0.json |
| 16.0.0–16.3.0 | 9.0.257.25 | tables/v9_0_16.3.0.json |
| 16.4.0–16.5.0 | 9.1.269.38 | tables/v9_1_16.5.0.json |
| 16.6.0–16.8.0 | 9.2.230.21 | tables/v9_2.json |
| 16.9.0–16.10.0 | 9.3.345.19 | tables/v9_3_16.10.0.json |
| 16.11.0–16.20.2 | 9.4.146.26 | tables/v9_4.json |
| 17.0.0–17.1.0 | 9.5.172.25 | tables/v9_5_17.1.0.json |
| 17.2.0–17.9.1 | 9.6.180.15 | tables/v9_6_17.9.1.json |
| 18.0.0–18.2.0 | 10.1.124.8 | tables/v10_1.json |
| 18.3.0–18.20.8 | 10.2.154.26 | tables/v10_2.json |
| 19.0.0–19.1.0 | 10.7.193.20 | tables/v10_7_19.1.0.json |
| 19.2.0–19.9.0 | 10.8.168.25 | tables/v10_8_19.9.0.json |
| 20.0.0–20.20.2 | 11.3.244.8 | tables/v11_3.json |
| 21.0.0–21.7.3 | 11.8.172.17 | tables/v11_8_21.7.3.json |
| 22.0.0–22.23.4 | 12.4.254.21 | tables/v12_4.json |
| 23.0.0–23.11.1 | 12.9.202.28 | tables/v12_9_23.11.1.json |
| 24.0.0–24.22.2 | 13.6.233.17 | tables/v13_6.json |
| 25.0.0–25.9.0 | 14.1.146.11 | tables/v14_1.json |
| 26.0.0–26.13.0 | 14.6.202.34 | tables/v14_6_26.10.0.json |
The 32 Node lines running the full matrix
8.17 10.24 12.5 12.9 12.15 12.22 13.14 14.0 14.4 14.5 14.6 14.21 15.14
16.3 16.5 16.8 16.9 16.20 17.1 17.9 18.2 18.20 19.1 19.9 20.20 21.7
22.12 23.11 24.12 25.9 26.10 26.13
Generation fetches only the ~30 V8 source files it needs per version, through a
blob-filtered partial clone plus an on-disk file cache (workspace/node-src/); no full Node
checkout is required:
python3 scripts/codegen.py --all-from 8.0.0 --donors tables/ --per-minor --keep-existingVersion-by-version engineering notes: docs/VERSIONS.md (Chinese) — including pointer-compressed builds (Electron / Chrome), whose cache layout differs from Node's.
It contains no source text, and the header's source_hash field stores the source length, not a
hash. Recoverable: header fields (version_hash, flag_hash, payload size, checksum, Brotli or
raw), the SharedFunctionInfo tree with scope info (parameter / context / stack slots), constant
pools, bytecode arrays, source-position and handler tables.
decompile prints names that exist only as scope slots as slot names (rN, __ctx.ctxN,
_anon_N) — never as identifiers that would throw ReferenceError.
- header — parse
SerializedCodeData(24 bytes for V8 ≤ 11, 32 bytes for V8 ≥ 12), detect Brotli, locate the payload. - identify — map
version_hashto a V8 version with the embedded index; fall back to recomputing the hash when the index misses. - deserialize — walk the V8 serialization stream (tags, backrefs, the hot-object ring, plus
6.x's
kBackrefWithSkipand variable-length raw) into an object graph. - disassemble — decode bytecode against the per-version table; operand sizes, scaling and
implicit-register tables come from
bytecodes.h. - decompile — register IR → structured control flow (if, loops, switch, try) → AST → print; generator/async state machines, completion-code dispatch and class assembly are folded version by version.
Four gates, each reproducible on its own:
- Behavior matrix (
scripts/verify_behavior.sh) — every fixture is compiled to.jscwith that Node version → decompiled → syntax-gated → run, and the product and the original function are compared case by case on their return values. Two structural gates ride along: the default product may not lose top-level declarations (AST-layer DCE guard), and known-fails are counted separately so they cannot mask regressions. - Whole-script diffing (
scripts/script_diff.js) — a complete script is run undernodeand stdout/exit code compared (covers top-level statements /const/class/async, whichtarget-style fixtures cannot reach). - Corpus sweeps (
scripts/verify_samples.py) — large sample sets × many versions: compile → decompile →node --check→ load in avm(aReferenceErrornaming anything we generate —__*,_anon_*,ctx*,phi*— is a bug). - Unit tests and static checks —
cargo test(65 tests) +cargo clippy --all-targets(0 warnings) +cargo build(0 warnings) + the dependency-free end-to-end smokescripts/ci_smoke.sh.
cargo test # 65 unit tests
bash scripts/verify_behavior.sh 8.17.0 10.24.1 12.22.12 14.21.3 16.20.2 18.20.8 20.20.2 22.12.0 24.12.0
python3 scripts/verify_samples.py 20.20.2 --limit 200 # corpus; also --offset, --samplesFixtures are compiled the same way bytenode does it (same flags, createCachedData() — see
scripts/mkcorpus.js); the transcript at the top uses real bytenode 1.7.0.
Numbers (all reproducible with the scripts above; the matrix rows are freshly measured):
| Metric | Value |
|---|---|
| Supported Node releases | 519 (v8.0.0 → v26.13.0), spanning 90 V8 versions (5.8.283.41 → 14.6.202.34) |
| Version tables / read-only-heap name maps | 62 tables (36 Node + 26 Electron) + 29 ro-map tables, indexed per release in tables/manifest.json |
| Behavior fixtures | 36 fixtures (121 assertion cases) + 5 whole-script fixtures — 41 files / 629 lines |
| One full matrix run | 32 Node lines × 36 fixtures = 1,152 cells + 160 script cells ⇒ 3,872 case-by-case comparisons and 1,312 structural gates |
| Standard corpus sweep | 408 real samples × 6 lines (8.17 / 12.22 / 16.20 / 20.20 / 24.12 / 26.10) |
| Full corpus sweep (cumulative) | 2,446 samples × 10 lines ≈ 25,000 compile → decompile → syntax/load checks |
| Input JavaScript processed | 2.40 MB / 76,246 lines / 2.4 M characters |
| Decompiled output produced | 25,029 files / 301 MB / 6.27 M lines |
| Real-world files | Node's own duplexpair.js, event_target.js, test-abortcontroller.js × 9 lines |
| Result | 1,144 pass / 0 fail / 8 skip / 0 known-fail + 160 script-pass / 0 fail |
How the matrix stays honest
skip(8 cells) marks fixtures whose source syntax needs a newer parser (?./??on Node 8/10/12.15, BigInt literals on 8.17), declared asmin_nodeintests/fixtures/behav/cases.jsonand counted separately from failures.compile-failcells in the corpus are sample sources the version's own parser rejects (modern syntax — 64 of them on 8.17); the0 syntax / 0 decompileclaim is about the products.- No known-fail remains.
iter_no_extra_closecovers a case whose native semantics differ between V8 versions (for (const v of [1,1,2]) { if (v === 2) continue; }callsiterator.return()once on 6.2/6.8 and never on 7.8+): such fixtures are markedsame_node_originalincases.json, so the original is run with the same Node version the artifact was compiled with — the product must reproduce its own version's behavior, which it does on all 32 lines. - The IteratorClose epilogue that used to swallow loop-body exceptions, and both
try/finallycompletion-code gaps, are fixed —iter_closeandfinally_returnpass.
The 41 fixture sources
Behavior fixtures (each runs on all 32 lines): arith branch loop_for loop_while strings
array_ops object_ops switch_case try_catch recursion closure class_basic
destructure template spread_rest for_of_in arrow_opt generator operators
string_regex yield_expr async_await async_try optional_chain twoclasses call_args3
call_once finally_return finally_solo delete_result num_literals try_catch_after
iter_close param_defaults typeof_flags iter_no_extra_close
Whole-script fixtures (stdout + exit code compared): top_stmt top_const top_var top_class
top_async
One corpus line in one run: ≈ 2,800 products / 33.5 MB / ≈ 700 k lines of decompiled code.
mise.toml pins the Node versions the harness uses by default (10.24.1 → 24.12.0); the matrix
also uses older and newer lines — install the remaining 23 with:
mise install [email protected] [email protected] [email protected] [email protected] [email protected] [email protected] \
[email protected] [email protected] [email protected] [email protected] [email protected] [email protected] [email protected] \
[email protected] [email protected] [email protected] [email protected] [email protected] [email protected] [email protected] \
[email protected] [email protected] [email protected]src/ header, serializer, bytecode, disasm, decompile, CLI (`src/help.rs` holds the help text)
tables/ per-V8 tables: opcode operands, roots, scope-info layout, runtime names,
read-only-heap name maps (`ro_map_*.json`) + manifest.json
scripts/ table codegen, corpus builders, verification harnesses, release.sh, ci_smoke.sh
tests/ unit + integration tests, and fixtures (compiled to .jsc on the fly)
docs/ VERSIONS.md (per-version engineering notes), ARCHITECTURE.md, DECOMPILE.md,
stream-format.md
.github/ tag-triggered release workflow: 6 platforms, bare binaries, described releases
CONTRIBUTING.md build, the gates a change must pass, how to add a fixture
- No source text and no original identifiers; names are reconstructed.
- V8 builtins appear as
__runtime.*/__intrinsic.*calls with minimal stubs. - Scope slots that cannot be named print as
__ctx.ctxN. Built-in names on Node 22+ need a read-only-heap name table: the embedded ones are macOS-only (see Usage), so on Linux/Windows build your own (jscd ro-map) or live with<ro…>placeholders. decompiletargets readable, runnable output, not byte-identical round-tripping.
MIT — see LICENSE.
bytenode for the packaging this tool targets; View8 for the disassembly format conventions; cure for the AST-level simplification/formatting.