jscd 0.2.2

Reverse bytenode-compiled .jsc (V8 code cache) back to JavaScript
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
# jscd

Reverse [bytenode](https://github.com/bytenode/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 11 and 19 → 45**
(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** (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** · [中文](README.zh.md)

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Rust 1.96+](https://img.shields.io/badge/rust-1.96%2B-orange.svg)](Cargo.toml)
[![Node 8.0 – 26.12](https://img.shields.io/badge/Node-8.0%20%E2%80%93%2026.12-green.svg)](#supported-versions)
[![V8 5.8 – 14.6](https://img.shields.io/badge/V8-5.8%20%E2%80%93%2014.6-green.svg)](#supported-versions)
[![release](https://github.com/ejfkdev/jscd/actions/workflows/release.yml/badge.svg)](https://github.com/ejfkdev/jscd/actions/workflows/release.yml)
[![crates.io](https://img.shields.io/crates/v/jscd.svg)](https://crates.io/crates/jscd)

**Contents:** [Highlights](#highlights) · [Quick start](#quick-start) · [Install](#install) ·
[Usage](#usage) · [CLI reference](#cli-reference) · [Supported versions](#supported-versions) ·
[How it works](#how-it-works) · [Verification](#verification) · [Repository layout](#repository-layout) ·
[Limitations](#limitations) · [Contributing](CONTRIBUTING.md)

## 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** — `--runtime` adds the `__runtime` stubs so the result runs under `node`;
  `--verify` puts it through `node --check`.
- **Readable output** — two optimization layers fold bytecode register shuffling back into
  expressions (register folding → swc copy propagation); `JSCD_NO_OPT=1` shows 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]#verification).

## Quick start

```console
$ 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](#usage)); `JSCD_NO_OPT=1` prints the
raw, pre-optimization form instead.

## Install

**macOS / Linux — Homebrew**

```sh
brew install ejfkdev/tap/jscd
```

**Windows — Scoop**

```powershell
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket
scoop install jscd
```

Straight from the manifest URL, without adding the bucket first:

```powershell
scoop install https://raw.githubusercontent.com/ejfkdev/scoop-bucket/main/bucket/jscd.json
```

**Prebuilt binaries** — [Releases](https://github.com/ejfkdev/jscd/releases/latest).

**cargo** (any platform with Rust 1.96+)

```sh
cargo install jscd        # build from crates.io
cargo binstall jscd       # or fetch the release binary instead of compiling (cargo-binstall)
```

**From source**

```sh
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/bin
```

Requires Rust 1.96+ (swc, the JS optimizer, needs a recent rustc). No system dependencies.

<details>
<summary>Releasing (maintainers)</summary>

`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](https://github.com/ejfkdev/homebrew-tap) and the
[Scoop bucket](https://github.com/ejfkdev/scoop-bucket) pick the new release up on their daily
auto-update run.

</details>

## Usage

```sh
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 `.jsc` file, 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 `.jsc` from an unsupported V8 is **rejected with the detected version and the supported range**
  in the message (`jscd info` prints `supported: 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-map` and pass `--ro-map`
  (`JSCD_NO_RO_MAP=1` disables 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` (then `LC_ALL` / `LC_MESSAGES` / `LANGUAGE` / `LANG` /
  `LC_CTYPE`): `zh*` is Chinese, anything else English; `JSCD_LANG=zh|en` forces 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`).

<!-- BEGIN help:cli -->
<details>
<summary>Full CLI help — <code>jscd --help</code> and every subcommand, verbatim</summary>

**`jscd --help`**

```console
$ jscd --help
jscd 0.2.2 — 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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`**

```console
$ 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.
```

</details>
<!-- END help:cli -->


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`.

<details>
<summary><code>jscd info</code> sample output</summary>

```console
$ 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
```

</details>

### Two optimization layers

`JSCD_NO_OPT=1` disables both, for diffing against the raw translation.

1. **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 write `r2 = new r2(a0)`).
2. **AST layer** (`src/opt_js.rs`): the text then goes through [swc]https://swc.rs — terser's Rust
   port — for **copy propagation**, unused-variable elimination and constant folding:

   ```js
   let r0, r1, r2, r3;        // as decompiled            // after both layers
   r0 = greet;                console.log(greet("world"));
   r2 = console;       →      function greet(a0) {
   r1 = r2.log;                 return "hello " + a0;
   r3 = r0("world");          }
   r2.log(r3);
   ```

   Pipeline: `paren_remover → resolver → optimize → hygiene → fixer` (skipping `paren_remover`
   miscompiles); the snippet is wrapped in a function first (swc only propagates copies inside a
   function scope — and the payload *is* a `Module.wrap` body 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: **11 and 19 → 45** (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.

**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** |

<details>
<summary>Node mapping: 36 version tables → 517 releases</summary>

| 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` |

</details>

<details>
<summary>The 31 Node lines running the full matrix</summary>

`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`

</details>

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:

```sh
python3 scripts/codegen.py --all-from 8.0.0 --donors tables/ --per-minor --keep-existing
```

Version-by-version engineering notes: [docs/VERSIONS.md](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

1. **header** — parse `SerializedCodeData` (24 bytes for V8 ≤ 11, 32 bytes for V8 ≥ 12), detect
   Brotli, locate the payload.
2. **identify** — map `version_hash` to a V8 version with the embedded index; fall back to
   recomputing the hash when the index misses.
3. **deserialize** — walk the V8 serialization stream (tags, backrefs, the hot-object ring, plus
   6.x's `kBackrefWithSkip` and variable-length raw) into an object graph.
4. **disassemble** — decode bytecode against the per-version table; operand sizes, scaling and
   implicit-register tables come from `bytecodes.h`.
5. **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:

1. **Behavior matrix** (`scripts/verify_behavior.sh`) — every fixture is compiled to `.jsc` with 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.
2. **Whole-script diffing** (`scripts/script_diff.js`) — a complete script is run under `node` and
   stdout/exit code compared (covers top-level statements / `const` / `class` / `async`, which
   `target`-style fixtures cannot reach).
3. **Corpus sweeps** (`scripts/verify_samples.py`) — large sample sets × many versions: compile →
   decompile → `node --check` → load in a `vm` (a `ReferenceError` naming anything we generate —
   `__*`, `_anon_*`, `ctx*`, `phi*` — is a bug).
4. **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 smoke
   `scripts/ci_smoke.sh`.

```sh
cargo test                                              # 66 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, --samples
```

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 | **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 | 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** |

<details>
<summary>How the matrix stays honest</summary>

- `skip` (8 cells) marks fixtures whose source syntax needs a newer parser (`?.`/`??` on Node
  8/10/12.15, BigInt literals on 8.17), declared as `min_node` in `tests/fixtures/behav/cases.json`
  and counted separately from failures.
- `compile-fail` cells in the corpus are sample *sources* the version's own parser rejects (modern
  syntax — 64 of them on 8.17); the `0 syntax / 0 decompile` claim is about the products.
- **No known-fail remains.** `iter_no_extra_close` covers a case whose *native* semantics differ
  between V8 versions (`for (const v of [1,1,2]) { if (v === 2) continue; }` calls
  `iterator.return()` once on 6.2/6.8 and never on 7.8+): such fixtures are marked
  `same_node_original` in `cases.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/finally`
  completion-code gaps, are fixed — `iter_close` and `finally_return` pass.

</details>

<details>
<summary>The 41 fixture sources</summary>

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:

```sh
mise install node@8.17.0 node@12.5.0 node@12.9.0 node@12.15.0 node@13.14.0 node@14.0.0 \
  node@14.4.0 node@14.5.0 node@14.6.0 node@15.14.0 node@16.3.0 node@16.5.0 node@16.8.0 \
  node@16.9.0 node@17.1.0 node@17.9.1 node@18.2.0 node@19.1.0 node@19.9.0 node@21.7.3 \
  node@23.11.1 node@25.9.0 node@26.12.1
```

</details>

## 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]#usage), so on
  Linux/Windows build your own (`jscd ro-map`) or live with `<ro…>` placeholders.
- `decompile` targets readable, runnable output, not byte-identical round-tripping.

## License

MIT — see [LICENSE](LICENSE).

## Acknowledgements

[bytenode](https://github.com/bytenode/bytenode) for the packaging this tool targets;
[View8](https://github.com/suleram/View8) for the disassembly format conventions;
[swc](https://swc.rs) for the AST-level cleanup (a Rust port of terser's copy propagation).

<div align="center"><sub><a href="README.zh.md"> 简体中文</a> · 友情链接 https://linux.do</sub></div>