jscd 0.1.0

Reverse bytenode-compiled .jsc (V8 code cache) back to JavaScript
Documentation
#!/usr/bin/env python3
"""黄金对拍:jscd disasm vs node --print-bytecode(同源、同 V8 flags)。

用法:
  scripts/golden_diff.py <fixture.js> <corpus.jsc> <node-version> [--jscd PATH]

流程:
  1. mise exec node@<ver> -- node --no-lazy --no-flush-bytecode --print-bytecode <fixture.js>
  2. jscd disasm <corpus.jsc>
  3. 按函数名对齐两边的块,比对 Bytecode length / Parameter count / Register count /
     Frame size / 指令序列(忽略地址前缀)。

退出码 0 = 全部一致(仅统计我们缺失/多余);1 = 有实际差异。
"""
import argparse
import re
import subprocess
import sys
from collections import OrderedDict

HEADER_RE = re.compile(r"\[generated bytecode for function: (.*?) \(")
# 十六进制列固定宽度;长指令(6 字节)与助记符之间可能只有 1 个空格
INSTR_RE = re.compile(r"@\s+(\d+) : ((?:[0-9a-f]{2} )*[0-9a-f]{2})\s+(\S.*)$")
META_RE = re.compile(r"^(Bytecode length|Parameter count|Register count|Frame size|OSR nesting level|Bytecode Age):?\s*(.*)$")


def parse_blocks(text):
    """把 --print-bytecode / jscd disasm 文本切成块列表(保序、允许重名)。"""
    blocks = []
    cur = None
    for line in text.splitlines():
        m = HEADER_RE.search(line)
        if m:
            name = m.group(1).strip()
            cur = {"name": name, "meta": {}, "instrs": [], "consts": []}
            blocks.append(cur)
            continue
        if cur is None:
            continue
        mm = META_RE.match(line.strip())
        if mm:
            cur["meta"][mm.group(1)] = mm.group(2).strip()
            continue
        im = INSTR_RE.search(line)
        if im:
            bytes_ = " ".join(im.group(2).split())
            body = im.group(3).strip()
            # 归一化跳转目标里的地址:(0xdeadbeef @ 12) / (jscd @ 12) → (@ 12)
            body = re.sub(
                r"\((?:0x[0-9a-fA-F]+|jscd) @ (\d+)\)",
                lambda mm: f"(@ {mm.group(1)})",
                body,
            )
            cur["instrs"].append((int(im.group(1)), bytes_, body))
            continue
        if line.strip().startswith("Constant pool"):
            cur["consts"].append(line.strip())
        elif cur["consts"] and line.strip():
            cur["consts"].append(line.strip())
    return blocks


def run(cmd, **kw):
    r = subprocess.run(cmd, capture_output=True, text=True, **kw)
    return r.stdout + r.stderr


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("fixture")
    ap.add_argument("corpus")
    ap.add_argument("node_version")
    ap.add_argument("--jscd", default="target/debug/jscd")
    ap.add_argument("--show", type=int, default=3, help="每个差异函数展示的差异行数")
    args = ap.parse_args()

    node_out = run([
        "mise", "exec", f"node@{args.node_version}", "--", "node",
        "--no-lazy", "--no-flush-bytecode", "--print-bytecode", args.fixture,
    ])
    jscd_out = run([args.jscd, "disasm", args.corpus])

    node_blocks = parse_blocks(node_out)
    jscd_blocks = parse_blocks(jscd_out)
    node_by_name = {}
    for b in node_blocks:
        node_by_name.setdefault(b["name"], []).append(b)
    jscd_by_name = {}
    for b in jscd_blocks:
        jscd_by_name.setdefault(b["name"], []).append(b)

    ok, diff, missing, extra, ambiguous = 0, [], [], [], []
    def block_key(b):
        return (
            b["meta"].get("Bytecode length"),
            b["meta"].get("Parameter count"),
            b["meta"].get("Register count"),
            b["meta"].get("Frame size"),
            tuple((i, by, body) for i, by, body in b["instrs"]),
        )

    anon_node = {block_key(b): b for b in node_blocks if not b["name"]}
    anon_matched = 0
    anon_unmatched = []

    for name, jlist in jscd_by_name.items():
        if not name:
            # 匿名函数(顶层包装等):按内容精确匹配(node 侧匿名内部函数太多,无法按名配对)
            for jb in jlist:
                if block_key(jb) in anon_node:
                    anon_matched += 1
                else:
                    anon_unmatched.append(jb)
            continue
        if name not in node_by_name:
            extra.append(name)
            continue
        nlist = node_by_name[name]
        used = set()
        for i, jb in enumerate(jlist):
            # 同名多块(多个 getter 等)配对优先级:
            #   1) 完整指令序列全等  2) meta + 首指令字节一致  3) 唯一候选且 meta 一致
            # 都不满足 → ambiguous(宁可标"无法配对",也不产生假差异)
            def same_meta(cand):
                return all(
                    cand["meta"].get(key) == jb["meta"].get(key)
                    for key in ("Bytecode length", "Parameter count", "Register count", "Frame size")
                )

            def same_instrs(cand):
                return list(cand["instrs"]) == list(jb["instrs"])

            head = jb["instrs"][0][1] if jb["instrs"] else ""
            free = [k for k in range(len(nlist)) if k not in used]
            nb = None
            for k in free:
                if same_instrs(nlist[k]):
                    nb, _hit = nlist[k], used.add(k)
                    break
            if nb is None:
                for k in free:
                    cand = nlist[k]
                    if same_meta(cand) and cand["instrs"] and cand["instrs"][0][1] == head:
                        nb, _hit = cand, used.add(k)
                        break
            if nb is None:
                cands = [k for k in free if same_meta(nlist[k])]
                if len(cands) == 1 and not nlist[cands[0]]["instrs"]:
                    nb, _hit = nlist[cands[0]], used.add(cands[0])
            problems = []
            for key in ("Bytecode length", "Parameter count", "Register count", "Frame size"):
                if nb["meta"].get(key) != jb["meta"].get(key):
                    problems.append(f"{key}: node={nb['meta'].get(key)} jscd={jb['meta'].get(key)}")
            if len(nb["instrs"]) != len(jb["instrs"]):
                problems.append(f"instr count: node={len(nb['instrs'])} jscd={len(jb['instrs'])}")
            else:
                for k, (ni, ji) in enumerate(zip(nb["instrs"], jb["instrs"])):
                    if ni[0] != ji[0] or ni[1] != ji[1] or ni[2] != ji[2]:
                        problems.append(
                            f"@{k} offset {ni[0]}/{ji[0]}\n      node: {ni[1]}  {ni[2]}\n      jscd: {ji[1]}  {ji[2]}"
                        )
                        if len(problems) > args.show:
                            break
            if problems:
                diff.append((f"{name}#{i}", problems))
            else:
                ok += 1

    for name, nlist in node_by_name.items():
        if name and name not in jscd_by_name:
            missing.append(f"{name} x{len(nlist)}")

    print(f"=== {args.fixture} (node {args.node_version}) ===")
    print(
        f"matched: {ok} (+{anon_matched} anonymous by-content)   mismatched: {len(diff)}   "
        f"jscd-extra: {len(extra)}   ambiguous: {len(ambiguous)}   node-only: {len(missing)}"
    )
    for name, problems in diff[:5]:
        print(f"\n--- MISMATCH: {name!r}")
        for p in problems:
            print(f"    {p}")
    if extra:
        print(f"\njscd-only functions (expected: they are inside our corpus but node printed under a different name/shape):")
        for n in extra[:10]:
            print(f"    {n!r}")
    return 1 if diff else 0


if __name__ == "__main__":
    sys.exit(main())