jscd
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.12.1 (517 releases / 36 V8 minors) and Electron 19 → 45
(V8 10.2 – 15.6, pointer-compressed and sandboxed builds included; every major from 20 to 45
passes the same 9-fixture behavior check, 9/9 each); ≈ 25k .jsc files pass the
tests with 0 failures (31 Node lines × 41 fixtures, compiled with bytenode 1.7.0's flags); two
optimization layers (register folding → swc copy propagation) make the decompiled source readable.
English · 中文
Contents: Highlights · Quick start · Install · Usage · CLI reference · Supported versions · How it works · Verification · Repository layout · Limitations · Contributing
Highlights
- Coverage — every Node release from 8.0.0 → 26.12.1 (517 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 → swc copy propagation);
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 — 31 Node lines × 41 fixtures compared case by case, plus corpus sweeps; every number rerunnable (see Verification).
Quick start
$ 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 world
jscd info hello.jsc decodes that header (sample in Usage); JSCD_NO_OPT=1 prints the
raw, pre-optimization form instead.
Install
macOS / Linux — Homebrew
Windows — Scoop
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket
scoop install jscd
Straight from the manifest URL, without adding the bucket first:
scoop install https://raw.githubusercontent.com/ejfkdev/scoop-bucket/main/bucket/jscd.json
Prebuilt binaries — Releases.
cargo (any platform with Rust 1.96+)
From source
&&
Requires Rust 1.96+ (swc, the JS optimizer, needs a recent rustc). No system dependencies.
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.
Usage
- 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. A mismatched table is ignored: you get<ro0_…>placeholders, never a wrong name. - 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 |
CLI reference
Everything the CLI prints for --help and help <SUBCOMMAND>, verbatim (regenerate with python3 scripts/update_readme_help.py).
jscd --help
$ jscd --help
jscd 0.2.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 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: yes
Two optimization layers
JSCD_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 swc — terser's Rust port — for copy propagation, unused-variable elimination and constant folding:let r0 r1 r2 r3; // as decompiled // after both layers r0 = greet; console.log; r2 = console; → r2.;Pipeline:
paren_remover → resolver → optimize → hygiene → fixer(skippingparen_removermiscompiles); the snippet is wrapped in a function first (swc only propagates copies inside a function scope — and the payload is aModule.wrapbody at runtime) and the wrapper is stripped afterwards. No renaming, no statement merging, no arrow conversion; unparseable or suspicious output → the text-layer result is kept.
Supported versions
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 (517 Node releases + 26 Electron builds), and one table covers each
V8 minor — identical tables are stored once.
Electron is covered the same way: 19 → 45 (V8 10.2 – 15.6), with a dedicated table for 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.
Every Node release from 8.0.0 through 26.12.1 is identified (517 releases, 90 V8 versions),
and 29 of the 36 V8 minors pass the full behavior matrix — 31 Node lines, all green
(1,108 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 | 31 lines, 8.17 → 26.10 | all green — 1,108 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 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.3 | 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.21.0 | 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.12.1 | 14.6.202.34 | tables/v14_6_26.10.0.json |
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
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:
Version-by-version engineering notes: docs/VERSIONS.md (Chinese) — including pointer-compressed builds (Electron / Chrome), whose cache layout differs from Node's.
How it works
What a .jsc holds
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.
Pipeline
- 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.
Verification
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(66 tests) +cargo clippy --all-targets(0 warnings) +cargo build(0 warnings) + the dependency-free end-to-end smokescripts/ci_smoke.sh.
Fixtures 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 | 517 (v8.0.0 → v26.12.1), spanning 90 V8 versions (5.8.283.41 → 14.6.202.34) |
| Version tables / read-only-heap name maps | 61 tables (36 Node + 25 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 | 31 Node lines × 36 fixtures = 1,116 cells + 155 script cells ⇒ 3,751 case-by-case comparisons and 1,271 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,108 pass / 0 fail / 8 skip / 0 known-fail + 155 script-pass / 0 fail |
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 31 lines. - The IteratorClose epilogue that used to swallow loop-body exceptions, and both
try/finallycompletion-code gaps, are fixed —iter_closeandfinally_returnpass.
Behavior fixtures (each runs on all 31 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:
Repository layout
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
Limitations
- 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.
License
MIT — see LICENSE.
Acknowledgements
bytenode for the packaging this tool targets; View8 for the disassembly format conventions; swc for the AST-level cleanup (a Rust port of terser's copy propagation).