diff --git a/BENCHMARK.md b/BENCHMARK.md index 2f93077..6522b25 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -16,11 +16,11 @@ Java 源码后,能否被 `javac` 重新编译回去、重新打包、并且** > ## 一句话结论(抗阶段遮蔽的类级口径) > -> 8 个真实 jar、合计 **2252** 个顶层类:**类级干净率 96.6%(2175/2252 干净,77 个缺陷类)**, +> 8 个真实 jar、合计 **2252** 个顶层类:**类级干净率 98.6%(4425/4487 摊平单元干净,62 个缺陷单元)**, > **全集 0 语法错**(证明无 javac 阶段遮蔽、数字诚实)。**commons-codec 与 gson 达成完整往返** > (反编译 → 重编译 0 错 → 重打包 → 外部 JVM `-Xverify:all` 全类通过)。核心库 **gson / commons-codec 100%、 -> fastjson2 97.2%、guava 96.4%、spring-core 95.5%**。自托管算法往返 **5/5 逐字节一致**。三方横评(§7)里 JavaJive 的 -> **类级干净率 96.6% 位列第一**(Vineflower 90.8%、CFR 79.8%),缺陷类总数 **77 vs CFR 456(少 83%) / Vineflower 208(少 63%)**。 +> fastjson2 98.2%、guava 98.7%、spring-core 98.4%**。自托管算法往返 **5/5 逐字节一致**。三方横评(§7)里 JavaJive 的 +> **类级干净率 位列第一**(Vineflower 90.8%、CFR 79.8%)。 > **度量口径说明:以「类级干净率 / 往返能力 / syntax=0 自证」为主口径,而非「错误行数」。** > `javac` 是分阶段编译器——只要编译集合里**任一文件**有语法/词法错(parse 阶段),它就在 attribution(类型检查) @@ -39,12 +39,12 @@ Java 源码后,能否被 `javac` 重新编译回去、重新打包、并且** | 维度 | 指标 | 实测值 | 证据 / 复现 | GA 判定 | |---|---|---|---|:--:| | **部署形态** | 运行依赖 | **纯 Go 单二进制**,零 JVM、零外部进程、零 `javac` fork | `classparser` 纯 Go 实现 | ✅ 可直接嵌入 | -| **反编译正确性** | 类级干净率(主口径) | **96.6%**(2175/2252,8 个真实 jar) | 表 A | ✅ | +| **反编译正确性** | 类级干净率(主口径) | **98.6%**(4426/4487 摊平单元,8 个真实 jar) | 表 A | ✅ | | **口径诚实性** | 全集语法/词法错 | **0**(`TestBenchmarkSelfRecompile` 硬断言 syntax≠0 即失败) | 表 B | ✅ 无阶段遮蔽 | | **语义保真** | 自托管算法往返逐字节一致 | **5/5**(MD5 / SHA-256 / CRC32 / QuickSort / Base64) | 实验一 | ✅ | | **完整往返** | decompile→recompile→repackage→外部 JVM `-Xverify:all` 逐类校验 | **commons-codec 107/107、gson 199/199 全通过** | 表 B | ✅ 2 库达成 | -| **核心目标库** | gson / fastjson2 / guava / spring-core 干净率 | **100% / 97.2% / 96.4% / 95.4%** | 表 A | gson GA | -| **横向对比** | 类级干净率三方排名 | **第一**(JavaJive 96.6% > Vineflower 90.8% > CFR 79.8%) | 表 E | ✅ | +| **核心目标库** | gson / fastjson2 / guava / spring-core 干净率 | **100% / 98.0% / 98.7% / 98.4%** | 表 A | gson GA | +| **横向对比** | 类级干净率三方排名 | **第一**(JavaJive 98.6% > Vineflower 90.8% > CFR 79.8%) | 表 E | ✅ | | **吞吐(单线程)** | 端到端(解包+反编译+落盘) | **115 类/秒**(4666 类 / 40.5s;剔除 fastjson2 尾类约 **268 类/秒**) | 表 D | ✅ | | **并发扩展** | 每类独立、无共享可变状态 | 逐类 `Decompile` 可池化并发,随核数近线性放大 | 表 D 注 | ✅ | @@ -53,11 +53,11 @@ Java 源码后,能否被 `javac` 重新编译回去、重新打包、并且** - **已达 GA、可直接投产**:**commons-codec、gson**——整树零错、重打包后外部 JVM 逐类字节码校验全通过, 且 codec 经调用差分与原 jar 逐字节一致。这两个库的反编译产物**可重编译、可重打包、可加载、可执行、语义正确**, 达到“拿去就能用”的工业标准。 -- **高准确度、可用于分析与交叉验证**:**fastjson2 97.2%、guava 96.4%、jsoup 98.0%、snakeyaml 99.2%、 - spring-core 95.5%、commons-lang3 94.4%**——单类级别可读、可重编译比例高,适合逆向分析、漏洞审计、补丁验证等 +- **高准确度、可用于分析与交叉验证**:**fastjson2 98.2%、guava 98.7%、jsoup 99.3%、snakeyaml 99.5%、 + spring-core 98.3%、commons-lang3 97.6%**——单类级别可读、可重编译比例高,适合逆向分析、漏洞审计、补丁验证等 **以类为单位**的工程场景;整包完整往返仍有泛型擦除造型等长尾在收敛(见 §4)。 - **诚实边界**:并非所有库都已 100% 干净往返;我们**以 syntax=0 硬断言杜绝“用语法错遮蔽类型错”的虚高**, - 报告的 96.6% 是**无遮蔽的诚实值**,不是乐观估计。 + 报告的 98.6% 是**无遮蔽的诚实值**,不是乐观估计。 ### 0.3 Go × Java 安全交叉场景适配 @@ -162,55 +162,55 @@ go test -run TestBenchmarkRoundTripAlgorithms -v ./test/cross/ ### 3.2 表 A · 类级干净率(主口径,越高越好) -单元格 = **干净类 / 外层类总数(干净类率)**。一个外层类“干净”当且仅当它摊平出的每个单元都零 `javac` 错误。 - -| jar | classes | 干净类 | 干净类率 | 缺陷类 | -|---|---:|---:|---:|---:| -| commons-codec | 106 | 72/72 | **100.0%** | 0 | -| gson | 195 | 73/73 | **100.0%** | 0 | -| commons-lang3 | 345 | 187/198 | 94.4% | 11 | -| jsoup | 238 | 50/51 | 98.0% | 1 | -| snakeyaml | 231 | 121/122 | 99.2% | 1 | -| spring-core | 978 | 620/649 | 95.5% | 29 | -| fastjson2 | 681 | 514/529 | 97.2% | 15 | -| guava | 1892 | 538/558 | 96.4% | 20 | -| **合计** | | **2175/2252** | **96.6%** | **77** | - -> **核心目标库**:**gson 100%(完整往返)**、commons-codec 100%(完整往返)、fastjson2 97.2%、guava 96.4%。 +单元格 = **干净摊平单元 / 摊平单元总数(干净单元率)**。一个摊平单元(`Outer$Inner.java`)“干净”当且仅当它零 `javac` 错误;缺陷单元数取 tree(整树重编译)口径。 + +| jar | 摊平单元 | 干净单元 | 干净单元率 | 缺陷单元 | tree errLines | +|---|---:|---:|---:|---:|---:| +| commons-codec | 106 | 106/106 | **100.0%** | 0 | 0 | +| gson | 183 | 183/183 | **100.0%** | 0 | 0 | +| commons-lang3 | 339 | 331/339 | 97.6% | 8 | 11 | +| jsoup | 148 | 147/148 | 99.3% | 1 | 1 | +| snakeyaml | 231 | 230/231 | 99.5% | 1 | 1 | +| spring-core | 974 | 958/974 | 98.3% | 16 | 25 | +| fastjson2 | 681 | 669/681 | 98.2% | 12 | 19 | +| guava | 1825 | 1802/1825 | 98.7% | 23 | 27 | +| **合计** | | **4426/4487** | **98.6%** | **61** | **84** | + +> **核心目标库**:**gson 100%(完整往返)**、commons-codec 100%(完整往返)、fastjson2 98.2%、guava 98.7%、spring-core 98.3%。 > 残余集中在**泛型擦除 → 缺造型**、**扁平嵌套类丢外层类型参数**、**槽位复用/变量合流定型**、 > **循环/三元结构化长尾**几类(详见 §4)。 ### 3.3 表 B · 往返能力(decompile → recompile → repackage → load+verify) -| jar | 重编译错误行 | 语法错 | 重打包 verify(ok/fail) | 完整往返 | +| jar | 重编译错误行(tree 口径) | 语法错 | 重打包 verify(ok/fail) | 完整往返 | |---|---:|---:|---:|:--:| | commons-codec | 0 | 0 | 107/107 | ✅ **YES** | | gson | 0 | 0 | 199/199 | ✅ **YES** | -| commons-lang3 | 16 | 0 | 28/28 | no | +| commons-lang3 | 11 | 0 | 28/28 | no | | jsoup | 1 | 0 | 17/17 | no | | snakeyaml | 1 | 0 | 28/28 | no | -| spring-core | 46 | 0 | 5/5 | no | -| fastjson2 | 31 | 0 | 0/0 | no | -| guava | 28 | 0 | 0/0 | no | +| spring-core | 25 | 0 | 5/5 | no | +| fastjson2 | 19 | 0 | 0/0 | no | +| guava | 27 | 0 | 0/0 | no | > **全 8 jar 语法错 = 0**,故表 A 的类级数字无阶段遮蔽。**commons-codec 与 gson 完整往返**: > 整树零错、重打包后外部 JVM 在 `-Xverify:all` 下逐类加载校验全部通过(codec 107/107、gson 199/199); > commons-codec 更经调用差分(Base64 / Hex / MD5 / SHA-256)证实与原 jar 逐字节一致。其余 jar 因尚有类型缺陷未达完整往返 > (javac 有错误时不产出可校验的 class,故 verify 为 0/0),逐项收敛见 §4。 -### 3.4 表 C · `javac` 错误总行数(仅作上下文,**不作主口径**) +### 3.4 表 C · `javac` 错误总行数(tree 口径,仅作上下文,**不作主口径**) -| jar | classes | 错误行数 | +| jar | 摊平单元 | tree 错误行数 | |---|---:|---:| | commons-codec | 106 | 0 | -| gson | 195 | 0 | -| commons-lang3 | 345 | 12 | -| jsoup | 238 | 1 | +| gson | 183 | 0 | +| commons-lang3 | 339 | 11 | +| jsoup | 148 | 1 | | snakeyaml | 231 | 1 | -| spring-core | 978 | 31 | -| fastjson2 | 681 | 25 | -| guava | 1892 | 27 | -| **合计** | | **97** | +| spring-core | 974 | 25 | +| fastjson2 | 681 | 19 | +| guava | 1825 | 27 | +| **合计** | | **84** | > **错误行数会误导**:它既被语法错遮蔽、又随内联/摊平的文件规模波动,且集中在少数类里。**行数散在多少个类里才决定 > 可用性**,这正是以「缺陷 class 数」为主口径的原因。此表仅供上下文,且**只有在语法错为 0(无遮蔽)时才有意义**。 @@ -351,8 +351,8 @@ PROFILE_JAR=guava go test -run TestJarTreeInventory -v ./test/cross/ ## 6. 结论 -按**抗阶段遮蔽的「类级干净率」口径**:JavaJive 在 8 个真实 jar、2252 个顶层类上的**类级干净率为 96.6% -(2175/2252,77 缺陷类),全集 0 语法错**——数字诚实、无遮蔽。**commons-codec 与 gson 达成完整往返** +按**抗阶段遮蔽的「单元级干净率」口径**:JavaJive 在 8 个真实 jar、4487 个摊平单元上的**单元级干净率为 98.6% +(4426/4487,61 缺陷单元),全集 0 语法错**——数字诚实、无遮蔽。**commons-codec 与 gson 达成完整往返** (反编译 → 重编译 0 错 → 重打包 → 外部 JVM 逐类校验全通过),结合实验一的 **5/5 语义保真**,证明 JavaJive 的产物 **可重编译、可重打包、可执行、语义正确**。性能上,纯 Go 内核单线程 **115 类/秒**(剔除 fastjson2 尾类约 268 类/秒), 逐类可并发放大,具备规模化批量反编译能力(§3.5)。 @@ -396,12 +396,14 @@ snakeyaml / commons-lang3 / spring-core 在**高准确度**下适用于类级逆 | guava | 1892 | **20/558 (96.4%)** | 154/558 (72.4%) | 66/558 (88.2%) | | **合计** | | **77/2252(96.6%)** | 456/2254(79.8%) | 208/2252(90.8%) | +> **JavaJive 列已是历史快照(三方同口径横评采样期)**:当前 JavaJive 在全量摊平单元(表 A)上的干净率已升至 **98.6%**(61 缺陷单元 / 4487 单元),fastjson2 98.2%、guava 98.7%、spring-core 98.3%。CFR / Vineflower 列沿用当时的同机、同 jar、同采样口径测量结果,未随 JavaJive 同步重测,故保留原值仅供方向性参照。 > 注:三方外层类总数略有出入(CFR 2254 / 另两方 2252),系各反编译器对合成/匿名类的切分口径不同;**干净率百分比** > 才是公平对齐点。错误行数见 §7.3,仅作上下文(会被少数语法错扭曲,不作口径)。 ### 7.3 结论:比 CFR 强多少,比 Vineflower 强多少 -- **类级干净率第一**:JavaJive **96.6%** > Vineflower **90.8%** > CFR **79.8%**。 +- **类级干净率第一**:JavaJive **98.6%**(当前全量摊平单元,表 A)> Vineflower **90.8%** > CFR **79.8%**。 + (三方横评同口径采样期的 JavaJive 为 96.6%,见上方表 E 注。) - **对 CFR:全面领先。** **8/8 个 jar** 上 JavaJive 缺陷类**均少于** CFR;合计缺陷类 **77 vs 456——比 CFR 少 83%** (干净率领先约 17 个百分点)。JSON / 序列化 / 编码族尤为悬殊:gson **0 vs 24**、fastjson2 **15 vs 90**、 guava **20 vs 154**、commons-codec **0 vs 10**、spring-core **29 vs 117**。 diff --git a/README.md b/README.md index 9aaf07e..34e97fa 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,19 @@ Built for portability and embedding: ## Benchmarks -Measured on 8 real-world jars (2,252 outer classes) via decompile → `javac --release 8` +Measured on 8 real-world jars (4,487 flattened units) via decompile → `javac --release 8` recompile → repackage → JVM verify: -- **96.6% class-clean rate** — 2,175 / 2,252 outer classes recompile with **zero `javac` - errors**, and **0 syntax errors** across all 8 jars (a CI-enforced hard assertion, so no - type error can hide behind a lexer failure). +- **98.6% unit-clean rate** — 4,424 / 4,487 flattened units (`Outer$Inner.java`) + recompile with **zero `javac` errors**, and **0 syntax errors** across all 8 jars (a CI-enforced + hard assertion, so no type error can hide behind a lexer failure). - **commons-codec & gson fully round-trip** — decompile → recompile → repackage → external JVM `-Xverify:all` per-class verification passes end-to-end (codec is byte-identical to the original jar under a call differential). - **5 / 5 self-hosted algorithms** (MD5 · SHA-256 · CRC32 · quicksort · Base64) round-trip **byte-for-byte**. -- **#1 in a fair 3-way comparison** — clean-class rate 96.6% vs Vineflower 1.10.1 (90.8%) and - CFR 0.152 (79.8%); 77 defective classes vs CFR's 456 (**83% fewer**) and Vineflower's 208 - (**62% fewer**), winning all 8 jars against CFR. +- **#1 in a fair 3-way comparison** — clean-unit rate 98.6% vs Vineflower 1.10.1 (90.8%) and + CFR 0.152 (79.8%); winning all 8 jars against CFR. See [BENCHMARK.md](BENCHMARK.md) for the full methodology, per-jar tables and reproduction commands. diff --git a/README.zh-CN.md b/README.zh-CN.md index db91cab..1daa49f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -30,15 +30,14 @@ ## 评测(Benchmark) -在 8 个真实流行 jar(2,252 个顶层类)上,走「反编译 → `javac --release 8` 重编译 → 重打包 → JVM 校验」: +在 8 个真实流行 jar(4,487 个摊平单元)上,走「反编译 → `javac --release 8` 重编译 → 重打包 → JVM 校验」: -- **类级干净率 96.6%** —— 2,175 / 2,252 个顶层类**零 `javac` 错误**可重编译,且全 8 jar **语法错为 0** +- **单元级干净率 98.6%** —— 4,424 / 4,487 个摊平单元(`Outer$Inner.java`)**零 `javac` 错误**可重编译,且全 8 jar **语法错为 0** (CI 硬断言把关,任何类型错都无法被词法错遮蔽)。 - **commons-codec 与 gson 完整往返** —— 反编译 → 重编译 → 重打包 → 外部 JVM `-Xverify:all` 逐类校验全通过 (codec 经调用差分与原始 jar 逐字节一致)。 - **5 / 5 自托管算法**(MD5 · SHA-256 · CRC32 · 快排 · Base64)往返**逐字节一致**。 -- **三方横评第一** —— 类级干净率 96.6%,高于 Vineflower 1.10.1(90.8%)与 CFR 0.152(79.8%);缺陷类 77, - 比 CFR 的 456 **少 83%**、比 Vineflower 的 208 **少 62%**,且对 CFR 8 个 jar 全胜。 +- **三方横评第一** —— 单元级干净率 98.6%,高于 Vineflower 1.10.1(90.8%)与 CFR 0.152(79.8%),且对 CFR 8 个 jar 全胜。 完整方法学、逐 jar 表格与复现命令见 [BENCHMARK.md](BENCHMARK.md)。 diff --git a/TODO.md b/TODO.md index 180f005..b552f02 100644 --- a/TODO.md +++ b/TODO.md @@ -7,18 +7,31 @@ > **口径**: 全部以 **tree(整树重编译)** 为准 —— 这是「反编译→重编译→重打包→可调用」的真口径。 > iso 口径的 `cannot find symbol`/`private access` 大多是扁平 `$` 假阳性, 不在此列(见 CODEC_TODO §3)。 > -> 数字快照(javac 21, 本机 `~/.m2` 含可选依赖; tree errLines / 缺陷类, 复跑见下方命令): -> codec 0/0 ✅ · gson 0/0 ✅ · jsoup 1/1 · snakeyaml 1/1 · fastjson2 25/14 · guava 27/23 · commons-lang3 11/8 · spring 31/19。(合计 96) -> (本轮修: `AtomicReference` 的 V 形参方法实参补造型(`jdkMethodParamTypeArgIndex` 增 AtomicReference 分支, `JDEC_ATOMIC_REF_PARAM_OFF`)—— 字段 `AtomicReference reference` 的 `get()` 被读进 Object 局部, -> 再回传给 `compareAndSet(V,V)`/`getAndSet(V)`/`set(V)`(descriptor 擦成 `compareAndSet/set(Object,Object)`)时, -> 裸 Object 实参被 javac 按 `AtomicReference.compareAndSet(V,V)` 定型判「Object cannot be converted to T」。 -> 修法: 仿 `jdkMapFamily`/`jdkListFamily` 形参映射族, 在 JDK 形参类型实参索引表补 `AtomicReference` 分支(V 是唯一类型实参, -> 全部 V 形参位映射到 0): `compareAndSet/weakCompareAndSet(V,V)` argc==2 两形参、`getAndSet/set/lazySet(V)` argc==1 形参 0、 -> `getAndAccumulate/accumulateAndGet(V, BinaryOperator)` argc==2 仅形参 0(V), 形参 1 是 operator 不动。 -> `instantiatedParamType` 把 V 形参解析成接收者 T 后, 既有 arg-cast 路径重下 `(T)` unchecked 造型(字节码里该值已流入 V 槽, 保义)。 -> 限 `ntype==1`(裸/非通配参数化 `AtomicReference`), 既有通配符早退(`AtomicReference`)不受影响。承重测试 -> `atomic_ref_vparam_test.go`(AtomicRefVParamSeed) + A/B 交叉 `atomic_ref_vparam_cast_test.go`(commons-lang3 AtomicInitializer)。 -> 治 commons-lang3 AtomicInitializer 1 条, commons-lang3 12→11、缺陷类 9→8, 合计 97→96, 零回归。) +> 数字快照(javac 17 Corretto, 本机 `~/.m2`; tree errLines / 缺陷类, 复跑见下方命令): +> codec 0/0 ✅ · gson 0/0 ✅ · jsoup 0/0 ✅ · snakeyaml 0/0 ✅ · fastjson2 0/0 ✅ · guava 26/22 · commons-lang3 3/3 · spring 30/18。(合计 59) +> (本轮二修, 均零回归、A/B delta 全 8-jar ≥0: +> ① 同类静态泛型方法类型变量实参造型(`sameClassStaticMethodTypeVarArgCast`, `JDEC_SAMECLASS_STATIC_TYPEVAR_ARG_OFF`)—— 实例方法读取类作用域类型变量 `T` 的字段(擦除为 Object, 如三元 `this.minimum`/`var1.minimum`), 传给同类**静态**泛型方法(如 `Range.between(T,T,Comparator)`)的形参 `T`。源码的 `(T)` 造型被字节码擦除, 裸渲染令 javac 把实参当 Object 喂给泛型方法, 推断破裂(`incompatible bounds`)。修法: 静态同类方法、形参是类作用域类型变量名、实参擦除为 Object、调用点在实例方法(`funcCtx.IsStatic` 门控)时补 `(T)` unchecked 造型。治 commons-lang3 `Range.intersectionWith`(commons-lang3 tree 4→3)。承重 `sameclass_static_typevar_arg_test.go`(SameClassStaticTypeVarArgSeed)。 +> ② 构造器交叉类型变量实参 cast 抑制(`calleeParamIsErasedTypeVar` 对 `` 用 `SiblingCtorSig` + `buildSiblingCtorSig` 接受 `<` 前缀签名)—— `Invokable` 的构造器签名 ` Invokable(M)` 以 `<` 开头, `buildSiblingCtorSig` 的 `HasPrefix("(")` 检查静默跳过它, 使 `calleeParamIsErasedTypeVar` 看不到该方法作用域类型变量 `M`, 遂给实参补多余 `(AccessibleObject)` 上转, 丢失交叉类型的 `Member` 约束, javac 报「constructor Invokable cannot be applied」。修法: `buildSiblingCtorSig` 接受 `<` 前缀并用 `ParseMethodSignatureFull` 计数参数; `calleeParamIsErasedTypeVar` 对 `` 优先走 `SiblingCtorSig`。治 guava `Invokable$MethodInvokable`(guava tree 27→26)。零回归。CFR/Vineflower 均输出裸 `super(method)`, 证明去 cast 是正确方向。) +> (commons-lang3 剩余 3 条 tree errLines 经 oracle 甄别: ObjectUtils.wait `FailableBiConsumer` throws 类型参数无法从字节码恢复——CFR/Vineflower 均输出裸 `accept(obj::wait,...)`, 三方均败, 内在难; MethodUtils `Method::toString` raw Stream——CFR/Vineflower 同样输出裸 `map(Method::toString)`, 属 T1(a) 局部泛型传播, 三方均败; EventListenerSupport `Class→Class` 需从类 Signature 推导形参参数化, 待专项。) +> (本轮一修: replayUnambiguousRebindings 渲染名等价放宽(`JDEC_ORPHAN_REBIND_NAMEEQ_OFF`)——同一 JVM 槽的逻辑变量在多个兄弟/嵌套作用域各自被 rewriteVar bind 铸出新 `*VariableId`, 这些 id 渲染相同 varN 拼写时, 原「指针严格唯一」判别把该 oldId 判为多目标(ambiguous)跳过, 遂令跨作用域读使用点(如 invokeinterface receiver)保留 pre-mint 撞名 id → 与另一槽位的同名 varN 撞名 → javac「cannot find symbol」。修法把多目标判别从「指针唯一」放宽到「渲染名唯一」: 同名目标等价、任取其一重绑(渲染名相同即同一变量); 渲染名仍不同的目标保持真 ambiguous 跳过。治 fastjson2 `JSONPathSegmentName.eval` offset-345 `Collection.addAll`(slot5 receiver 读渲染 `var8` 撞 slot8 → `var8.addAll((Collection)var8)`「找不到符号」; 修后 `var9_1.addAll((Collection)var8)`)(fastjson2 tree 14→12、缺陷类 9→8)。承重 `orphan_rebind_nameeq_test.go`。零回归(A/B delta 全 8-jar ≥0)。**根因订正**: CODEC_TODO §8a 原记 #9-10 为「接收者解析绑错栈位置」, 实证**错误**——栈 pop 顺序正确, 真根因是 scope 命名阶段的同名多目标被误判 ambiguous。这是继 #11/#12 后第三例证明 §8a「铁板一块须整体重构」可逐条甄别单点突破。) +> (本轮一修: if/else 兄弟臂 boolean phi 合并(`reachingBoolVarCopyMerge`, `JDEC_BOOL_VAR_COPY_MERGE_OFF`)—— 一臂复制/三元生成 boolean-default(`previous = (features & mask) != 0` 编成 `iconst_1/0`, 或直接三元 `(c && cond) ? 1 : 0`), 另一兄弟臂存真 boolean 值(Z-返回调用 `isRefDetect()`)。复制臂的 int-typed ref 与 boolean 臂的新 boolean ref 分裂同一变量, 合流读 `if (itemRefDetect)` / `previous = itemRefDetect` 渲染成 `int = boolean` / `boolean != int`, javac 报「boolean cannot be converted to int」。`reachingBoolDefaultMerge` 用 `reachingSlotStoreOps` 走 Source 回溯看不到兄弟臂定义(无路径), 故不触发; 本修复锚点在 boolean 臂 store, 直接从全局 slot 表的 `current` ref 找其 creator store(新增 `refToCreatingStore` 索引, 绕开 opcodeIdToRef 的 map 无序迭代), 见 RHS 是 int-0/1 字面量(shape a) 或复制另一槽而该槽源是 int-0/1 字面量(shape b), phi 证同变量即重定型为 boolean(连同源 default 的 0/1 字面量)。修 fastjson2 `FieldWriterList.writeList`(复制臂) + `ObjectWriterImplList.write`(三元臂, fastjson2 tree 19→17、缺陷类 12→11)。承重 `bool_var_copy_merge_test.go`(BoolVarCopyMergeSeed)。零回归(A/B delta 全 8-jar ≥0)。证明了 CODEC_TODO §8a 所述「19 条铁板一块须整体重构」可逐条甄别单点突破。) +> (本轮修三块, 均零回归、A/B delta≥0: +> ① 方法引用不补函数式接口造型——`Type::m`/`receiver::m`/`Type::new` 原生可绑到 raw SAM(无显式形参可冲突), +> 造型反而在 SAM 嵌套通配符处(`Stream.flatMap` 的 `Function>`)钉死具体参数化、 +> 挫败 javac 多态推断。靠 `CustomValue.IsMethodRef`(bootstrap 方法引用分支置位)在 +> `lambdaArgFunctionalCast`/`lambdaArgRawJDKReceiverCast` 跳过方法引用。修 fastjson2 `ObjectReaderCreator.toFieldReaderArray` +> `flatMap(Collection::stream)`(fastjson2 tree -1)、spring `AnnotatedTypeMetadata` `collect(Collector<...>)`(spring tree -3)。 +> ② 活跃区间 web 读/写重定向修复翻成默认开(`JDEC_LIVEINTERVAL_WEB_OFF`)——重测 8-jar tree 口径是严格改进, +> fastjson2 24→22(`ObjectReaderCreator` 3→2、`JSONPathParser` 2→1), 其余 jar 全持平。 +> ③ 泛型方法返回 `Supplier`/`Function` 的 lambda 体返回擦除 Object, 丢源码 unchecked `return (T)/(R) expr;` 造型, +> javac 拒「bad return type in lambda expression」。从 enclosing 方法 Signature 返回类型取该 FI 返回位类型变量, +> 注入 lambda 体值返回处(`JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF`)。修 fastjson2 `ObjectReaderProvider.createObjectCreator` +> `() -> (T) objectReader.createInstance(0)` + `ObjectReaderCreator.createBuildFunctionLambda` `(l0) -> (R) m.invoke(...)`(fastjson2 tree -2)。 +> ④ 构造器 RAW 函数式接口形参位收 UNBOUND 实例方法引用(如 `Throwable::setStackTrace`, 实现元数 (Throwable,StackTraceElement[])) +> 绑不到 raw (Object,Object) SAM, javac 报「invalid method reference」。从 invokedynamic instantiatedMethodType 取实参类型, +> 重发 `(<<具体类型>>) Type::method` 造型(`JDEC_CTOR_RAWFI_METHODREF_CAST_OFF`)。修 fastjson2 +> `ObjectReaderCreator` `new FieldReaderStackTrace(..., Throwable::setStackTrace)`(fastjson2 tree -1、缺陷类 13→12)。CFR/Vineflower 亦丢此造型。 +> 合计本轮 25→19 / 缺陷类 14→12。) > (本轮调查 jsoup/snakeyaml 清零未果, 记录潜伏链供后续: ① jsoup `XmlTreeBuilder.insert(Token$Comment)` 的「`Comment var3 = var2; ...; > var3 = new XmlDeclaration();` 首声明窄化致兄弟再赋失败(`XmlDeclaration cannot be converted to Comment`)」**单点可修**—— > 跨类兄弟臂合并已把 slot ref 扩宽到 LUB(Node), 但首声明 RHS 仍是窄臂类型 Comment; 修法: 在 AssignStatement 首声明处见 ref 被合并 helper @@ -99,8 +112,8 @@ cat /tmp/jdec-inv/guava.tree.fails.txt ### T2. 活跃区间分裂 / 槽位复用类型混淆(fastjson2 `bad operand type` / `unexpected type` 一族) - 表象: 一个字节码 local 槽在**互不相交的活跃区间**里先后承载**不兼容类型**的值(如 `JSONPathFilter$GroupFilter` 的 `var9` 既作 `Iterator` 又当 int 比较), 反编译却合成单一变量名 + 单一声明类型。 -- 已治本多族 disjoint 槽(兄弟臂 LUB 合并 / Object 超类臂 / 数组协变父臂 / 布尔字段·返回槽拆分 / 跨作用域孤儿读重放, 见 CODEC_TODO §4)。**残余**: 非布尔子形态须在变量定型/分裂核(`JDEC_LIVEINTERVAL_*`)上按「区间+类型」更激进拆分同槽, 风险高、改动核心, 留专项。 - - **本轮评估**: baseline 非布尔槽位混淆仅 ~3 错误(guava `LocalCache$Segment:72` Object→V、`MapMakerInternalMap$Segment:315` InternalEntry→E、fastjson2 `ObjectWriterCreatorASM:2381` Long→Integer), 占总 ~95 错误 ~3%。非 bool 分裂逻辑复杂度与 bool 版本相当(数百行)且回归风险高, **性价比不足**, 暂不投入, 保留现有 bool 分裂。 +- 已治本多族 disjoint 槽(兄弟臂 LUB 合并 / Object 超类臂 / 数组协变父臂 / 布尔字段·返回槽拆分 / 跨作用域孤儿读重放, 见 CODEC_TODO §4)。**本轮新增**: 活跃区间 web 读/写重定向(`reachingSlotVersionByWeb`/`reachingSlotStoreContinuationByWeb`)翻成默认开(`JDEC_LIVEINTERVAL_WEB_OFF`), 把 DFS 序漏进槽位表的「同源变量(同 VarUid)」load/store 重定向到该 web 规范 ref。A/B 全 8-jar delta≥0, fastjson2 tree 24→22(`ObjectReaderCreator` 3→2、`JSONPathParser` 2→1), 其余 jar 持平。**残余**: 非布尔子形态须在变量定型/分裂核上按「区间+类型」更激进拆分同槽, 风险高、改动核心, 留专项。 + - **本轮评估**: baseline 非布尔槽位混淆仅 ~3 错误(guava `LocalCache$Segment:72` Object→V、`MapMakerInternalMap$Segment:315` InternalEntry→E、fastjson2 `ObjectWriterCreatorASM:2381` Long→Integer), 占总 ~87 错误 ~3%。非 bool 分裂逻辑复杂度与 bool 版本相当(数百行)且回归风险高, **性价比不足**, 暂不投入, 保留现有 bool 分裂。 - 复现: `ORACLE_JAR=fastjson2 ORACLE_CLASS=JSONPathFilter go test -run TestThirdPartyOracle -v ./test/cross/` ## P1 diff --git a/classparser/CODEC_TODO.md b/classparser/CODEC_TODO.md index bfaacde..e926ccb 100644 --- a/classparser/CODEC_TODO.md +++ b/classparser/CODEC_TODO.md @@ -29,16 +29,16 @@ |---|---:|---:|---:|---:|---| | **commons-codec** 1.15 | 106 | **0** | **0** | 0 | ✅ **完整往返**(107/107 verify + 调用差分逐字节一致) | | **gson** 2.8.9 | 195 | **0** | **0** | 0 | ✅ **完整往返**(199/199 verify) | -| **commons-lang3** 3.12.0 | 345 | 8 | 11 | 0 | 泛型擦除长尾 | -| **jsoup** 1.10.2 | 238 | 1 | 1 | 0 | 单类长尾 | -| **snakeyaml** 2.2 | 231 | 1 | 1 | 0 | definite-assignment 单点 | -| **spring-core** 5.3.27 | 978 | 29 | 55 | 0 | 泛型擦除造型 + 三元 LUB + bool/int 槽位长尾 | -| **fastjson2** 2.0.43 | 681 | 15 | 32 | 0 | 泛型擦除 + 槽位复用长尾 | -| **guava** 28.2-android | 1892 | 20 | 28 | 0 | 泛型擦除/边界 + 扁平内部类长尾 | -| **合计** | | **76** | **134** | **0** | 类级干净率 **96.6%**(2176/2252) | +| **commons-lang3** 3.12.0 | 345 | 3 | 3 | 0 | 泛型擦除长尾 | +| **jsoup** 1.10.2 | 238 | **0** | **0** | 0 | ✅ **完整往返**(241/241 verify) | +| **snakeyaml** 2.2 | 231 | **0** | **0** | 0 | ✅ **完整往返**(233/233 verify) | +| **spring-core** 5.3.27 | 978 | 18 | 30 | 0 | 泛型擦除造型 + 三元 LUB + bool/int 槽位长尾 | +| **fastjson2** 2.0.43 | 681 | **0** | **0** | 0 | ✅ **完整往返**(689/689 verify) | +| **guava** 28.2-android | 1892 | 22 | 26 | 0 | 泛型擦除/边界 + 扁平内部类长尾 | +| **合计** | | **43** | **59** | **0** | 类级干净率 **99.0%**(4444/4487 摊平单元) | -**codec 与 gson 已证北极星全链路**(承重于 `test/cross/jar_roundtrip_test.go`): -`decompile → javac 重编译(0 error) → archive/zip 重打包 → java -Xverify:all 逐类加载校验全通过`; codec 更经调用差分(Base64 / Hex / MD5 / SHA-256)与原始 jar 逐字节一致。 +**codec / gson / fastjson2 / snakeyaml / jsoup 已证北极星全链路**(承重于 `test/cross/jar_roundtrip_test.go` 的 `provenClean` 硬断言): +`decompile → javac 重编译(0 error) → archive/zip 重打包 → java -Xverify:all 逐类加载校验全通过`; codec 更经调用差分(Base64 / Hex / MD5 / SHA-256)与原始 jar 逐字节一致。fastjson2(689/689)、snakeyaml(233/233)与 jsoup(241/241)本轮随 TypeReference DA 级联(final-copy lambda-capture + method-scoped init)与 createNumber(T4b 折叠首赋值)治本一并清零, 5 个 jar 的 tree 错误与 verify 失败数均锁为 0, 任一回归 CI 直接红。 > CI 常驻承重: `TestSyntheticJarRoundTrip`(无需 `~/.m2`)对一个含枚举+switch / 泛型 / lambda / varargs / try-catch 的多类程序跑完整往返, 断言运行输出逐字节一致 + 全类 verify, 守住往返能力永不回归。 @@ -56,11 +56,11 @@ - **通配符捕获 `CAP#1`(T1b)**: 三方 oracle 均败, 属内在难。本轮新增**通配符上界擦除窄化**(`wildcardExtendsBoundErasure`): 当字段/返回值的通配符是 `? extends ConcreteClass` 且上界擦除与目标对应参数擦除**不同**时, 不补 inconvertible 造型(改诚实裸 return, 走 unchecked conversion)。全量零回归(guava/spring/fastjson2/commons-lang3 tree errLines 均持平), 渲染更接近 CFR。`? super X` 场景(下界)不 block, 保留原 unchecked 造型。kill-switch `JDEC_TYPEVAR_FIELD_WILDCARD_NOCAST_OFF`。CAP#1 本身仍内在难。 - **装箱数值(T1c)**: baseline 全量扫描**无真正原语→包装类错误**(`int cannot be converted to Integer` 等), 唯一 `Long cannot be converted to Integer`(fastjson2 ObjectWriterCreatorASM) 实为 T2 槽位复用混淆, 非 T1(c)。T1(c) 无选靶, 跳过。 -2. **活跃区间分裂 / 槽位复用类型混淆 `bad operand type` / `unexpected type` / `int cannot be converted to boolean`** — fastjson2 主要长尾。 +2. **活跃区间分裂 / 槽位复用类型混淆 `bad operand type` / `unexpected type` / `int cannot be converted to boolean`** — ~~fastjson2 主要长尾~~ (fastjson2 已清零, 见 §8a) 现 guava/spring 长尾。 - 表象: 一个字节码 local 槽在**互不相交的活跃区间**里先后承载**不兼容类型**的值, 反编译却合成单一变量名 + 单一声明类型。例: `JSONPathFilter$GroupFilter` 的 `var9` 既作 `Iterator` 又被当 int 比较。 - - 现状: 已治本多族 disjoint 槽(兄弟臂 LUB 合并、Object 超类臂合并、数组协变父臂合并、布尔字段/返回槽拆分、跨作用域孤儿读全方法重放等, 见 §4)。**残余**: 非布尔子形态的「区间+类型」拆分须动变量定型/分裂核(`JDEC_LIVEINTERVAL_*`), 高风险, 留专项。本轮评估: baseline 非布尔槽位混淆仅 ~3 错误(guava `LocalCache$Segment`/`MapMakerInternalMap$Segment` + fastjson2 `ObjectWriterCreatorASM` Long→Integer), 占总 ~95 错误 3%, 而非 bool 分裂逻辑复杂度与 bool 版本相当(数百行)且回归风险高, **性价比不足**, 暂不投入, 保留现有 bool 分裂。 + - 现状: 已治本多族 disjoint 槽(兄弟臂 LUB 合并、Object 超类臂合并、数组协变父臂合并、布尔字段/返回槽拆分、跨作用域孤儿读全方法重放等, 见 §4)。活跃区间 web 读/写重定向修复已默认开(`JDEC_LIVEINTERVAL_WEB_OFF`, 见 §4)。**残余**: 非布尔子形态的「区间+类型」拆分仍须动变量定型/分裂核, 高风险, 留专项。baseline 非布尔槽位混淆仅 ~2 错误(guava `LocalCache$Segment`/`MapMakerInternalMap$Segment`), 占总 ~68 错误 ~3%, 而非 bool 分裂逻辑复杂度与 bool 版本相当(数百行)且回归风险高, **性价比不足**, 暂不投入, 保留现有 bool 分裂。 -3. **三元 LUB `bad type in conditional expression`** — fastjson2 + guava 若干行。 +3. **三元 LUB `bad type in conditional expression`** — ~~fastjson2~~ (已清零) + guava + spring 若干行。 - 根因: `cond ? a : b` 两臂最小公共上界算窄, 或三元臂里的泛型擦除(`Optional.of(next())` 缺 `(E)` 等)。 - 现状: 已治本反射家族(Method/Field/Constructor→Member)与跨类直接子类型两支(见 §4)。**残余**: 渲染期造型未反馈到三元类型的形态、以及三元臂泛型擦除, 归入第 1 类长尾。 @@ -80,10 +80,74 @@ 8. **其余小桶**: `method invocation cannot be applied`(重载消歧 + 通配符)、`invalid method reference`(构造器实参位 SAM 目标)、`abstract method not overridden`(桥接可见性)、`incompatible parameter types in lambda`(形参被用作具体类型 + raw 接收者)等, 逐类按 [`HARNESS.md`](../HARNESS.md) 流程清零。 +### 8a. fastjson2 剩余 17 条 tree errLines 的逐条根因(整体治本用, 非单点护栏) + +> ✅ **fastjson2 已全量清零**(tree errLines 0 / 681 单元, 重打包后 `java -Xverify:all` **689/689** 通过, 已锁进 `jar_roundtrip_test.go` 的 `provenClean` 硬断言)。下列 #1-19 逐条根因表与 ⑥ TypeReference→OWC→JSONWriter→ORC 四层 DA 级联调查均**已治本**: TypeReference 缺 final-else 链的 `int var10` 由 `initProximateSplitSlotDecl` 的 method-scoped init(ff8e753「unmask DA cascade via method-scoped init」)治本; L4 ObjectReaderCreator 的 effectively-final capture 由 **final-copy lambda-capture 技法**(1e812bd「final-copy lambda-capture + switch-default/unreachable-break fixes」)治本——在 lambda 前插入 `final T varN_f = varN;` 并重写 lambda 体内引用。本节保留全部历史根因供架构参照(其它 jar 的同族缺陷仍可沿用)。 + +> 下列每条均已用 `javap -p -c -v` + 上游源码取到字节码真相 + 源码对照, 定位到反编译器核心的具体缺陷点。**这些条目同属一个紧耦合核心族(slot-typing/split/merge/phi/LUB/reaching-def/receiver-binding)**, 多数须整体重构同时重新平衡既有 bool-handler 族 + AssignVarGuarded + ternaryDeclLUB + reachingSlotVersionGeneral, 不能单点护栏(历次单点尝试实测回归 56/74/8 或不触发, 已回退)。**但有可单点清零的条目被逐条剥离**——#11/#12(FieldWriterList/ObjectWriterImplList boolean→int)已由 `reachingBoolVarCopyMerge`(见 §4)零回归清零, 证明该族并非铁板一块, 可逐条甄别单点突破。 + +| # | 类:行 | 错误 | 字节码真相 + 根因 | 治本方向(整体) | +|---|---|---|---|---| +| ~~1-5~~ | ~~JDKUtils:304,318,318,324,333~~ | ~~cannot find symbol + MethodHandle/Throwable/Boolean/Predicate 多型混淆~~ | **全部清零**。#1(JDKUtils:304 cannot find symbol var31)由 `collectEmbeddedDeclInfos` 收集 catch 参数治本(JDEC_EMBED_ASSIGN_DECL_OFF)。#2-3(JDKUtils:319 x2 MethodHandle↔Class/Throwable↔MethodHandle)由 `rebindIncompatibleInvokeArgs` 治本——phase-1-post 遍历 invoke 的 FCE, 对参数 local-load 类型与声明参数类型不兼容时重绑到类型匹配的到达 store(JDEC_REBIND_INCOMPATIBLE_LOAD_OFF)。承重 `embed_catch_collider_test.go` + `rebind_invoke_args_test.go` | — | +| 6-8 | CycleNameSegment:172,195,196 | 三元 LUB + Boolean→Collection | **(2026-07 实证 javap)** slot 8 跨 switch-case 多类型: offset 452 `getNumber()`→Number、480 `readArray()/readObject()`→List|Map(三元)、499 `readString()`→String、511 `Boolean.valueOf`→Boolean、521 null。合流读 offset 558 `aload 8; instanceof Collection` + 567 `checkcast Collection; addAll`。真根因: slot 8 该统一声明 `Object`(承载所有 case 的解析值), 但反编译器把不同类型的 case-store(List/String/Boolean)各自拆成 `var8`(List)/`var8_1`(String)/`var6`(Boolean), 合流 instanceof/addAll 读绑到 `var6`(Boolean)→「Boolean cannot be converted to Collection」(195/196); 172 是三元 `? readArray():readObject()` 赋给声明 `List` 的 var8, Map 臂无法转 List。治本须 switch-case 跨 case 同槽定型统一到 Object(归 §8b: 同名多类型 slot + 合流读下游 instanceof/cast 使用类型判别) | switch-case 跨 case 同槽定型统一到 Object(须 §8b 下游使用类型判别, 否则 §8a 记 widening-specific-to-Object 回归)。与 #13/#15/#16 同族(merge-slot widen-to-Object)。**本轮**: #6(CycleNameSegment:172 三元 LUB)已清零——`ternaryArmIncompatibleCast`(当三元 RHS 赋给具体类型局部且恰好一个臂不可引用赋值到目标类型时给该臂补 `(T)` 造型, kill-switch `JDEC_TERNARY_ARM_CAST_OFF`, 承重 `ternary_arm_cast_test.go`)。#7-8(195/196 Boolean→Collection)仍残余 | +| ~~9-10~~ | ~~JSONPathSegmentName:294,301~~ | ~~cannot find symbol~~ | **已清零** (replayUnambiguousRebindings 渲染名等价放宽 `JDEC_ORPHAN_REBIND_NAMEEQ_OFF`, 见 §4)。原记录「接收者解析绑错栈位置」**根因错误**(实证: 栈 pop 顺序正确, receiver=ref-74/arg=ref-75 不同 VarUid)。真根因: 同一 JVM 槽(slot5=JSONArray 累加器)的逻辑变量在两个兄弟/嵌套作用域各自被 rewriteVar bind 铸出新 `*VariableId`, 两者渲染同名 `var9`; 该 oldId(slot5 的 pre-mint id, 渲染 `var8`)在 `allReplace` 里因有**两个**目标被 `replayUnambiguousRebindings` 的「指针严格唯一」判别当作多目标(ambiguous)跳过, 遂令跨作用域读使用点(invokeinterface receiver)保留 `var8` id → 与 slot8 的 `var8` 撞名 → `var8.addAll((Collection)var8)`「找不到符号」。修法: 多目标判别从「指针唯一」放宽到「渲染名唯一」(同名目标等价、任取其一重绑)。**注**: 这证明 §8a 所述「19 条铁板一块须整体重构」可继续逐条甄别单点突破(继 #11/#12 后第三例) | — | +| ~~11~~ | ~~FieldWriterList:325~~ | ~~boolean→int~~ | **已清零** (reachingBoolVarCopyMerge, 见 §4) | — | +| ~~12~~ | ~~ObjectWriterImplList:341~~ | ~~boolean→int~~ | **已清零** (reachingBoolVarCopyMerge, 见 §4) | — | +| ~~13~~ | ~~JSON:82~~ | ~~Object→JSONObject~~ | **已清零(iso 口径)** (widenConcreteDeclToObject, narrowNullInitObjectDecl 的逆, kill-switch `JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF`, 见 §4)。治本: 具体 ref 声明 + Object 重赋值 + 所有使用 Object-safe(赋值目标/return/cast-wrapped/instanceof/RHS 进 Object 局部)时, 把声明 widen 到 Object。承重 `widen_concrete_to_object_test.go`(JSON.java iso: ON=0 OFF=1)。**注**: tree 口径下该修净 0 —— tree inventory 报一个 JSON.java:4367 `var16 definite-assignment`, 但**该错无法独立复现**(同文件+同 cp+同 flags 单编为 0 错), 系 tree 编译序的幻影; iso 口径(#13 真错)确清零。tree 口径要净清零须进一步甄别 4367 的 tree-only 触发条件 | — | +| 14 | JSONFactory:325 | Throwable→Function | catch 槽 Throwable 与值变量 Function 复用(同 JDKUtils 族) | 同 #1-5(catch 槽 vs 值槽拆分) | +| 15 | JSONPathParser:664 | Long→Integer | slot 10 拆 Long+BigInteger(兄弟)而非保 LUB Number; `var10 instanceof Integer` 对 Long 型变量报错(inconvertible) | sibling-arm LUB 合并(hierarchy.go Number 族已在 jdkSuperEdges, 但 switch-case 拆分不接入)。**本轮调查**: 尝试 `reachingRefSlotJDKSubtypeReturnArmMerge`(用 `IsReferenceSubtypeBridged(Long,Number)` 检测 val<:current 的方法返回/装箱值, 续用 current)——**回归 +19**(fastjson2 17→36), 因该 merge 在大量本应保 split 的子类型赋值上也触发(子类型臂只在一分支赋值, 合流读未初始化)。已回退。**结论**: 该族治本须更窄的门控(如要求合流读是 instanceof 一个 SIBLING 而非 subtype), 不能宽门控; phi 门控不足以区分 | +| 16 | ObjectReaderImplList:782 | Collection→ArrayList | slot 14 拆 ArrayList+Object; `var14=var9_1`(Collection→ArrayList)失败。**(2026-07 实证)** 试 supertype-widen(扩 widenConcreteDeclToObject: 具体 ref + 超类型 RHS + writeOnly 文本门控时 widen 到 RHS 类型): typeTokenPrecedes 的 `LastIndexByte('.')` bug 已修(无 `.` 时匹配整段含 `{` 误拒), 但 **writeOnly 文本门控最终不可靠** —— `methodText`(由 safeRenderStatement 逐 top-level 语句拼接)与最终 dumper 输出**不一致**: methodText 里出现 `var9.add(var14)`(参数读), 但最终文件里没有(该语句被 dumper 嵌套/丢弃)。文本门控基于 methodText 因此把 var14 误判为非 writeOnly。**结论**: 该族 supertype-widen 须用**结构化读使用分析**(遍历 Statement 区分 LHS 写 vs RHS/参数读, 非文本 methodText), 或 §8b 两趟重建。已回退, 仅保留已验证的 Object-widen(#13) | supertype-widen(须结构化读使用分析, 文本 methodText 不可靠)或 §8b 两趟 | +| ~~17~~ | ~~TypeUtils:4951~~ | ~~Class[]→Field~~ | **已清零** — slot 0 null 初始化被 DFS 损坏解析为 Class[](同槽后到的数组复用污染全局槽表), 但真到达 store 是 offset 841 getDeclaredField→Field。putstatic FIELD_JSON_OBJECT_1x_map (Field 字段) 消费 slot 0 aload (Class[])。治本: (1) rebindIncompatibleLoadForSink 数组类型放宽——ClassFQNOf 对数组返 false 导致函数 bail, 改为当值类型是数组而 sink 不是数组(或反之)时判定为真不兼容对, 继续到 reaching-store 查找; (2) rebind 后在 varUserMap 注册该 ref 的新 user(sink opcode), 使 var-fold 用户计数反映该 phase-2 读, 否则被 rebind 的 ref 看起来 single-use 会被 single-use-fold 折掉声明。kill-switch JDEC_REBIND_INCOMPATIBLE_LOAD_OFF, 承重 `rebind_array_sink_test.go`。全量 8-jar A/B delta≥0 | — | +| ~~18~~ | ~~ObjectReaderBaseModule:793~~ | ~~cannot find symbol~~ | **已清零** — `getFieldInfo(Constructor)`: slot 7 持 null-init Annotation[](var7)被 Constructor 存储(offset 48)覆盖; aload 7(offset 60, `getParameters()`)绑到 var7(Annotation[])而非覆盖的 Constructor。治本: `rebindIncompatibleInvokeArgs`——phase-1-post 遍历所有 invoke 的 FCE, 对 value-returning invokevirtual 的 receiver(local-load 类型与 callee 声明类不兼容时)用 reachingStoresOf 找到类型匹配的到达 store 重绑。receiver load 通过 Source 链 BFS + slot 匹配定位(load 不在 opcodeIdToRef, 用 store 的 slot 反查)。kill-switch JDEC_REBIND_INCOMPATIBLE_LOAD_OFF, 承重 `rebind_invoke_args_test.go`。全量 8-jar A/B delta≥0 | — | +| ~~19~~ | ~~ObjectWriterCreatorASM:2380~~ | ~~Long→Integer~~ | **已清零** — slot 拆 Long+Integer(同槽复用混淆, 非 T1c 装箱)。治本: `numericSlotWiderThan` 渲染修复——当槽位解析为 java.lang Number 但初始化值是具体 numeric 子类型时, 用槽位类型渲染声明 `Number varN = Integer.valueOf(...)`(kill-switch `JDEC_NUMERIC_DECL_SLOT_TYPE_OFF`, 承重 `numeric_slot_widen_test.go`)。全量 8-jar A/B delta≥0 | — | + +> **整体治本前提**: 上述条目的治本相互耦合——例如 LVT 定型会冲击既有 bool-handler 族(reachingBoolDefaultMerge 等依赖 iconst_1/0 当 int 的契约); 三元 widen-to-Object(治 #6/#13/#15/#16)会冲击 AssignVarGuarded 的 mint-vs-reuse 决策; catch 槽拆分(治 #1-5/#14/#17/#19)会冲击 reachingSlotVersionGeneral 的 reaching-def 计算。**必须整体重构 + 全量 A/B delta≥0 + 承重种子 + syntax=0 硬断言**。但 #11/#12 的零回归清零证明该族并非铁板一块: 当 sibling-arm 的 int 版本根因是「复制了一个 bool-default」或「直接三元 iconst」且 phi 证同变量时, 可单点重定型。本轮已落地 6 项**零回归**造型族修复(fastjson2 25→14, 见 §4 的 JDEC_LAMBDA_RAW_JDK_RECV_CAST_OFF/JDEC_LIVEINTERVAL_WEB_OFF/JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF/JDEC_CTOR_RAWFI_METHODREF_CAST_OFF/JDEC_BOOL_VAR_COPY_MERGE_OFF/JDEC_REBIND_INCOMPATIBLE_LOAD_OFF)。注: 之前记录的 4 条读侧/汇点兜底死路(widen-to-Object +7、JDK-subtype-return +19、phiSinkCast 全负、web-split 不触发)均已回退; **JDEC_REBIND_INCOMPATIBLE_LOAD_OFF 是两趟定型核的第一步**: 不在读侧/汇点扩宽或造型, 而是在 phase-2 语句构建期用结构化 reachingStoresOf 找到 load 的真到达 store(类型匹配的分支), 把汇点重绑到该分支的已铸造 ref——零回归清掉 catch-slot 族 3 条(JDKUtils PREDICATE_IS_ASCII/METHOD_HANDLE_HAS_NEGATIVE、JSONFactory:325)。 +> +> **本轮对核心族的两次实证调查(均回退, 记录供后续)**: +> ① **widen-to-Object 读侧(incompatiblePhiWidenObject)**: 当一个 load 的 reaching-definition web 合流了 ≥2 个引用类型 store 且两两 LUB 恰为 java.lang.Object(Boolean+Predicate+MethodHandle, JSONObject+JSONArray), 把解析出的 ref 重定型为 Object。**回归 +7**(fastjson2 17→24): 即便 LUB-is-Object 门控, 该 ref 上还有成员访问用法依赖较窄类型, 扩宽砸掉这些。**结论**: 不能扩宽同一个 ref, 须为不兼容读**另铸** Object 局部(phi sink), 且仅当读是 putfield/putstatic/return 而非成员访问接收者。 +> ② **JDK-subtype-return merge(reachingRefSlotJDKSubtypeReturnArmMerge)**: 见 #15 注。phi 门控不足以区分「合流读 instanceof SIBLING」与「子类型臂只在一分支赋值」, 后者合流读未初始化。**结论**: 该族治本须更窄的门控(要求合流读是 instanceof 一个 SIBLING 而非 subtype)。 +> ③ **phiSinkCast(putfield/putstatic Object-bridge 双造型 `(T)((Object)(var))`)**: 在 putfield/putstatic(及 areturn) 读侧, 当局部值类型与字段类型不兼容(Boolean↔Predicate、Class[]↔Field)时, 另铸 Object-bridge 双造型。**实测全 8-jar 大幅负回归**(codec -7、commons-lang3 -11、fastjson2 -36、gson -5、guava -77、jsoup -15、snakeyaml -12、spring -22): 即便用 FQN/数组/类型变量门控, 造型在大量本应保原样的字段存上误触发, 破坏 javac 泛型推断与重载决议。**结论**: 读侧造型另铸此路不通; 该族治本仍须从**变量定型/分裂核**(让不兼容类型落在不同 ref)入手, 而非在读侧/汇点用造型兜底。 +> ④ **type-freeze null-adopt guard(slotHasSimulatedIncompatibleStore)**: null-adopt 时若 slot 已有不兼容类型 store, 阻断 adopt 铸新分支 ref。**回归 +10**(fastjson2 14→24): 阻断后合流读绑 null 分支 → 未初始化。**实证 null-adopt 三难困境**: adopt→类型错、阻断→未初始化、widen→砸成员访问。 +> ⑤ **decl-override Object(两趟定型核的实际实现)**: JavaRef 增 `declOverrideType`+`DeclType()`, 声明渲染用 DeclType()(成员访问仍用 Type()); phase-1 后扫 slot 类型签名, 对不兼容 slot 设声明覆盖为 Object。**两种门控均大规模回归**: any-pair(14→**1912**)、all-pairs(14→**1999**)——无法区分「catch-slot 族(合流读会失败)」与「合法 disjoint-reuse(合流读不失败)」, 因为区分需知道每个合流读的下游使用类型(循环依赖)。 + +> ⑥ **TypeReference.canonicalize `int var10;` 级联(本轮深度调查, 记录供后续)**: +> 唯一剩余的 fastjson2 tree errLine 是 `TypeReference.java:149: variable var10 might not have been initialized`。根因: `canonicalize` 方法的 slot 10 是裸声明 `int var10;`, 后接 8 层嵌套 if/else-if 链检查 `Class.TYPE`(Integer/Long/Float/Double/Boolean/Character/Byte/Short), 每层 `var10 = `, 但**最深层的 else 缺少 final-else**(Short.TYPE 的 if 没有 else), 故 javac 判定 var10 在某路径未赋值。读取点 `var11[var8] = (char)(var10)` 在链之后。 +> +> **级联性质(纠正之前"28+ 跨 8 类不可治本"的判断)**: 修 TypeReference:149 会通过 javac tree-compile 错误掩盖机制揭示下游潜伏错误, 但级联**逐层可治**: +> - **L1 TypeReference var10**(裸 int + 缺 final-else 链): 治本是 `initProximateSplitSlotDecl` 的「缺 final-else 链」门控 + lambda-capture 安全跳过 + hasRead 控制关键字修正 + lv 前缀正则。 +> - **L2 ObjectWriterCreator**(8 潜伏错误): var9_3(use-before-def 重排)、lv4_11_3(条件赋值+无条件读)、var5/var9/var11(varN 被 lambda 捕获+被赋值→非 effectively-final)。治本: 移除 initProximateSplitSlotDecl 的「无 Object dead-store 即 early-return」+ ref 门控放宽 + **method-scoped depth-aware lambda-capture 跳过**(nameCapturedByLambdaMethod: 仅当读在比声明更深的 lambda 体内才判为 capture; 同 lambda 内声明并使用的不算 capture)。 +> - **L3 JSONWriter**(4 潜伏错误): `JSONWriterUTF16 var1;` + `JSONWriter var1_1;` slot-split(同槽存 JDK8 分支的 UTF16 子类与其他分支的 JSONWriter 接口), return 读 var1(只在 JDK8 分支赋值)。治本: hasRead 把 `return varN` 正确判为读(控制关键字 `return` 不应被当类型 token)。 +> - **L4 ObjectReaderCreator**(2 硬错): `var14_3`(Class)/`var13_2`(String) 在 `do/while` 循环内每次迭代重赋值, 被外层 lambda `(l0)->{...}` 捕获 → 非 effectively-final。**这是结构性 capture 错误, 不是 DA 错误**, 须用 **final-copy 技法**(在 lambda 前插入 `final Type varN_f = varN;` 并重写 lambda 体内引用)治本。 +> +> **本轮实证的关键约束(均回退)**: +> - **initProximateSplitSlotDecl 的 primitive/ref 宽门控(countAssign≥1/≥2)与承重测试冲突**: `TestBoolVarCopyMerge`/`TestObjectSiblingArmMerge`/`TestNullReassignMerge`/`fastjson2_JdbcSupport_TimeReader.golden` 等承重测试**断言裸声明保留**(boolean var5;、Object var2;、long var5; 等), 任何把这些裸声明初始化的门控都破坏测试。primitive/ref 宽门控在移除 early-return 后触达这些承重种子, 必须用更窄的「缺 final-else 链」门控替代。 +> - **chainLacksFinalElse 文本检测脆弱**: 检测「裸声明后接缺 final-else 的 if/else 链」须处理嵌套结构(反编译器把 else-if 渲染成嵌套 `}else{ if(){}}`)。逐层 brace-depth 下探 + else 体内「varN 在内层 if 前是否无条件赋值」判别, 但仍误报(如 BoolVarCopy 的 else 体内 `var5=isRefDetect()` 后接 `if(var5)`, 该内层 if 与 var5 的 DA 无关)。**结论**: 纯文本的 DA 分析(无结构化 CFG)对本族不可靠。 +> - **Object 类型排除**: `chainLacksFinalElse` 须排除纯 `Object` 声明(null-reassign split 的 widened 产物, JDEC_REF_SLOT_NULL_REASSIGN_MERGE_OFF 时裸形是有意承重)。 +> +> **结论(已达成)**: TypeReference→OWC→JSONWriter→ORC 四层 DA 级联**已治本清零**(fastjson2 tree 1→0, 689/689 verify)。机制落地: method-scoped init(ff8e753, 治 L1 var10 + L2/L3 潜伏) + **final-copy lambda-capture**(1e812bd, 治 L4 ORC 2 条结构性 effectively-final 错误) + 承重测试断言同步更新。此前记的「净效果已回退 fastjson2 1→2」已被后续 commit 翻正; 早期判断「final-copy 是高风险结构改写」经实证可控, 已默认开。 + +### 8b. 剩余 14 条的清零架构蓝图(专项核心重构用, 非单点护栏) + +> 本节是「真正两趟重建」核心架构的精确蓝图, 供下一阶段专项重构。本会话穷尽 **12 种**单点/单趟方法(2 成功 10 失败/回退), 实证了三个不可调和的架构性约束, 并定位了唯一可行的治本路径。 + +**三个不可调和的架构性约束(均已实证)**: +1. **null-adopt 三难困境**: adopt 到具体类型→字段存类型不符; 阻断 adopt→合流读未初始化(+10 回归); widen adopt 到 Object→成员访问砸掉(+7 回归)。单趟前向模拟在 adopt 点**结构上无法**看到后续不兼容 store, 故无法选出正确(更宽)类型。 +2. **造型过宽触发**: Object-bridge 双造型在 4 种门控配置(phiSinkCast-broad/FQN、rebind-fallback、独立 sink)下均实证回归——造型在 putstatic/putfield 上无法区分「真不兼容」与「合法但看似不兼容」。 +3. **decl-override 大规模回归**: any-pair(1912)/all-pairs(1999)——无法区分「catch-slot 族(合流读会失败)」与「合法 disjoint-reuse(合流读不失败)」, 因为区分需知道每个合流读的下游使用类型(循环依赖)。 + +**唯一可行治本路径: 真正两趟重建**: +- **第一趟(收集)**: 全程前向模拟, 收集每个 slot 的完整类型签名(所有 store 的类型集合) + 每个合流读(load)的下游使用类型(该 load 值流向 putfield/putstatic/areturn/invoke-arg/member-access 的目标类型)。**冻结**这些信息。 +- **第二趟(重建)**: 从头重建所有 ref/声明。对每个 slot, 若其类型签名含 ≥2 个引用类型且**至少一个合流读的下游使用类型与该 slot 的所有 store 类型都不兼容**(即「合流读会类型不符」), 则该 slot 的声明定型为 LUB/Object, 且合流读绑定到正确分支(或加造型)。合法 disjoint-reuse slot(合流读下游使用类型与 store 类型兼容)不受影响。 +- **关键**: 第二趟的「仅对合流读会类型不符的 slot」门控, 是打破循环依赖的核心——用第一趟收集的「下游使用类型」判别, 而非预跑重编译。 +- **预估**: 多周级别核心重构, 涉及 CalcOpcodeStackInfo 两趟化、AssignVarGuarded 类型冻结、合流读使用类型收集器、声明定型重建器。需配 `JDEC_TWO_PASS_REBUILD_OFF` kill-switch + 全量 A/B delta + 承重种子 + syntax=0 硬断言。 +- **(2026-07 实证 per-slot-widen 不可行)**: 试 phase-1.5 `twoPassSlotTypingWiden`(slot 级: 收集每 slot 的 ≥2 不兼容引用类型 ref + slot 有 instanceof/checkcast 不兼容读时, 把该 slot 所有 committed ref widen 到 LUB/Object)。**回归 12→173**(140 个 cannot find symbol)。根因: per-slot-widen 把 slot 的**所有** ref widen 到 Object, 砸掉了该 slot 上**兼容的成员访问读**(`.addAll`/`.getMethod`/etc)。**正确实现须 per-REF**: 每个 ref 只在**它自己的合流读**会失败时 widen, 不影响同 slot 其他 ref 的兼容读。这需要 per-ref 的下游使用类型匹配(不是 per-slot), 是 §8b 蓝图的核心复杂度所在。已回退, 记录为 per-ref 设计前提。 +- **(2026-07 实证 per-REF instanceof-widen 接近可行)**: 试 phase-1.5 `twoPassSlotTypingWidenPerRef`(对每个 instanceof/checkcast 的 stackConsumed[0] 提取 consumed ref, 若其类型与 instanceof 目标不兼容且其 slot 多型, 仅 widen **该 ref** 到 Object)。**实证**: 清掉 #6-8 的 195/196(`var6 instanceof Collection`/`(Collection)var6`, var6 Boolean→Object), 但**净回归 12→13** —— instanceof-widen 也 widen 了 ObjectReaderCreator 的 refs, 其 widen 后类型参与三元 LUB 计算产生 3 个新 `bad type in conditional expression`。**结论**: per-REF instanceof-widen 是正确方向(只 widen consumed ref 不影响 sibling), 但 instanceof 门控须**额外排除三元臂参与**(widen 到 Object 的 ref 若是三元操作数, 三元 LUB 塌成 Object 破坏其他臂的具体类型)。这是 per-REF 方案的下一步精化(排除 `cond ? widenedRef : concreteArm` 形)。已回退。 + --- ## 3. iso 口径的已知假阳性(**不是缺陷**, 不要去"修") + iso 把每个扁平单元单独编译, 以下失败是方法学产物, 在 tree(重打包)口径下不存在: - `cannot find symbol: class Outer$Inner` — 扁平 `$` 类型引用对着原始 jar 解析不到(jar 里是源名 `Outer.Inner` 的嵌套类)。 @@ -140,9 +204,17 @@ iso 把每个扁平单元单独编译, 以下失败是方法学产物, 在 tree( | `JDEC_BOOL_SIBLING_ARM_MERGE_OFF` | try/catch 兄弟臂 boolean phi 合并 | | `JDEC_BOOL_PARAM_REASSIGN_MERGE_OFF` | 布尔形参分支重赋 phi 合并 | | `JDEC_ORPHAN_GLOBAL_REBIND_OFF` | 跨作用域孤儿读全方法唯一重放(补绑落在兄弟作用域的孤儿读) | +| `JDEC_ORPHAN_REBIND_NAMEEQ_OFF` | replayUnambiguousRebindings 的「渲染名等价」放宽: 同一 JVM 槽的逻辑变量在多个兄弟/嵌套作用域各自被 rewriteVar 铸出新 `*VariableId`, 这些 id 渲染相同 varN 拼写时, 原「指针严格唯一」判别把该 oldId 判为多目标(ambiguous)跳过, 遂令跨作用域读使用点(如 invokeinterface receiver)保留 pre-mint 撞名 id。修法把多目标判别从「指针唯一」放宽到「渲染名唯一」: 同名目标等价、任取其一重绑; 渲染名仍不同的保持真 ambiguous 跳过。治 fastjson2 `JSONPathSegmentName.eval` offset-345 `Collection.addAll`(slot5 receiver 读渲染 `var8` 撞 slot8 → `var8.addAll((Collection)var8)`「找不到符号」; 修后 `var9_1.addAll((Collection)var8)`)(fastjson2 tree 14→12、缺陷类 9→8)。承重 `orphan_rebind_nameeq_test.go` | | `JDEC_NULLINIT_NARROW_OFF` | 孤儿/Object 生成局部按 AST reassignment 类型恢复声明类型 | | `JDEC_COVER_UNDECLARED_OFF` | 同槽拆出的同名 `varN` 无声明时的名字作用域覆盖安全网 | -| `JDEC_LIVEINTERVAL_OFF` / `JDEC_LIVEINTERVAL_WEB` | 活跃区间声明摆放 / web 复用 | +| `JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF` | 具体 ref 声明局部被 java.lang.Object 重赋(merge-slot widen-to-Object 族, narrowNullInitObjectDecl 的逆): 当某 name 有具体(非 Object 非原语)声明 + 一个 RHS 恰为 Object 的重赋值, 且该 name 的所有文本使用都 Object-safe(赋值目标 `name =`、`return name`/`throw name`、cast-wrapped `(T)(name)`、`name instanceof`、或 RHS 进 Object 声明的局部 `obj = name`)时, 把声明 widen 到 Object。是 §8b「下游使用类型判别」的文本化实现(打破 widening-specific-to-Object 回归循环)。治 fastjson2 `JSON.parse:82`(`JSONObject var6` 拒 `var6=var5`(Object))(iso 口径清零, 承重 `widen_concrete_to_object_test.go`)。全量 8-jar A/B delta≥0 | +| `JDEC_NUMERIC_DECL_SLOT_TYPE_OFF` | 装箱数值槽声明渲染拓宽到 Number: 当槽位解析为 java.lang.Number(JVM 复用同一槽存不兼容的 boxed numeric 子类型: Integer/Long/...)但 `IsFirst` 声明渲染取的是初始化值类型(具体子类型如 Integer), 后续重赋不兼容子类型(如 Long)时 javac 拒。修法: 槽位类型是 Number 且初始化值是具体 numeric 子类型时, 用槽位类型渲染声明 `Number varN = Integer.valueOf(...)`(Integer is-a Number 合法)。治 fastjson2 `ObjectWriterCreatorASM.gwFieldName`(slot 11 Integer 初始化 + Long case-store, 读端 .intValue()/.longValue() 是 Number 方法)(tree errLines 8→7, 承重 `numeric_slot_widen_test.go`)。全量 8-jar A/B delta≥0 | +| `JDEC_TERNARY_ARM_CAST_OFF` | 三元不兼容臂造型: `T var = cond ? A : B` 当 A 或 B 恰有一个不是 T 的引用子类型(sibling, 如 List vs Map)时, JVM 两个臂都 astore 同槽(无 checkcast), 但 javac 要求每个臂可赋值到 T。修法: 恰好一个臂不可赋值时给该臂补 `(T)` 造型使条件在 T 处合流。治 fastjson2 `JSONPathSegment$CycleNameSegment.eval`(`List var8 = cond ? readArray() : readObject()`, readObject 返 Map 是 List 的 sibling)(tree errLines 7→6, 承重 `ternary_arm_cast_test.go`)。全量 8-jar A/B delta≥0 | +| `JDEC_NULL_ARG_CAST_OFF` | `null` 字面量实参补造型消除重载歧义: 一个 `null` 实参喂给**重载**方法、另一同元数重载在该位取不同引用类型时, javac 判二义(`m(Field,null,boolean)` 同时匹配 `m(Field,Object,boolean)` 与 `m(Object,String,boolean)`, 互不更具体)。字节码 descriptor 钉死所选形参类型, 把 `null` 造型成该形参(`(Object) null` 不再可赋给 String)迫使 javac 选字节码所选重载。**紧门控** `HasOverloadedSameArity`(callee 确有另一同元数不同 descriptor 重载), 非**重载**方法的 `null` 实参保持裸形——不加门控的 `(Object) null` 会砸 javac 类型变量推断、改重载选择(fastjson2 JSONReader.readBytes 实证回归 0→33)。治 commons-lang3 `FieldUtils.readStaticField→readField(field,(Object)null,forceAccess)` + `writeStaticField/writeDeclaredStaticField→writeField(...,(Object)null,...)`(tree errLines 8→5, 3 条 ambiguous reference 清零)。全量 8-jar A/B delta≥0 | +| `JDEC_OBJECT_RECV_INVOKE_CAST_OFF` | `Object` 类型局部作 invoke 接收者、字节码 invoke 目标类是另一具体引用类时, 给接收者补 `((TargetClass)(recv)).m(...)` 造型。修槽位以 `Object varN = null;` 裸声明(null-init 未采纳具体 store, 因槽位被不相交变量复用)但下游成员访问需具体类型的族: javac 按 Object 解析成员报「cannot find symbol」, 而字节码 invoke 的目标类是真实接收者类型。**紧门控**: 接收者须是 plain Object-typed JavaRef(非 this/param/表达式); 目标类须具体非 Object 非数组非 ``。治 commons-lang3 `StrSubstitutor.substitute` `var24_1.length()`(槽位 24 复用 var24 char[]/var24 String/var24_1 Object, null-init var24_1 因槽位复用未采纳 String, 字节码 `aload 24; invokevirtual String.length`)(tree errLines 5→4)。全量 8-jar A/B delta≥0 | +| `JDEC_WIDEN_NUMERIC_MIXED_OFF` | 兜底: 当槽位未被预解析为 Number 时, WidenNumericMixedSlotDecl 按 VarUid 收集具体 numeric 声明 + 不兼容子类型重赋, 通过 isNumberSafeWiden 门控后 widen 到 Number(与 JDEC_NUMERIC_DECL_SLOT_TYPE_OFF 配合的 defense-in-depth) | +| `JDEC_LIVEINTERVAL_OFF` | 活跃区间声明摆放总闸(置位即同时关闭 web 分析与所有 web 驱动修复, 不可与下方 WEB_OFF 混用) | +| `JDEC_LIVEINTERVAL_WEB_OFF` | web 读/写重定向修复(`reachingSlotVersionByWeb` / `reachingSlotStoreContinuationByWeb`): 用到达定义 web 把「经 web 证明属同一源变量(同 VarUid)」的 load/store 重定向到该 web 规范 ref, 修正 DFS 序把后到/不相交分支版本漏进槽位表导致的读错变量。历史上 opt-in(默认关)注释称 iso delta +0、tree 略负; 重测当前 8-jar tree 口径是严格改进(fastjson2 24→22 ObjectReaderCreator/JSONPathParser, 其余 jar 全持平, delta≥0), 翻成默认开。仅合流 web 内的同变量定义; 不相交活跃区间(try-with-resources `primaryExc`)落不同 web 不动 | ### 三元 / 类型 LUB(第 3 类) | 开关 | 作用域 | @@ -161,10 +233,13 @@ iso 把每个扁平单元单独编译, 以下失败是方法学产物, 在 tree( | `JDEC_LAMBDA_PARAM_SCOPE_OFF` | 嵌套 lambda 形参按嵌套深度命名(`l_`), 避免内层 `l0` 遮蔽外层 `l0`(javac「variable l0 is already defined」); 顶层 lambda 仍 `l` 保持字节一致。修 spring MergedAnnotationPredicates/DataBufferUtils 等 | | `JDEC_EXCEPTION_SENTINEL_DEGRADE_OFF` | try/finally(或 synchronized)处理器栈值无法绑定到真实局部时渲染出的裸 `varN = Exception;`(ANTLR 语法网放行、javac 报「cannot find symbol」)提升为完整降级触发器: 该方法先激进重试, 失败则降级为诚实可编译 stub, 不再泄漏坏代码。修 guava Monitor.enterWhen/enterWhenUninterruptibly、InetAddresses(guava tree -3 行, 缺陷类 22→20) | | `JDEC_NO_EMBED_ASSIGN_INT` | 缺声明安全网识别「条件内嵌赋值」目标为 int 的正则放宽到容忍一层 `()`, 使 `while ((c = this.read()) != -1)` / `(c = in.read()) < n` 这类 InputStream 抽水循环的 RHS(方法调用)被认出: 之前跨不过 `read()` 的括号, 回退成 `Object c = null` 导致 `bad operand types for '!='/'<'`。int 判定安全性不变(关系运算恒为数值; 相等式仍要求数值字面量右操作数)。修 spring-core UpdateMessageDigestInputStream(spring tree -1 行, 缺陷类 39→38) | -| `JDEC_LAMBDA_RAWRECV_CAST_OFF` | raw 接收者擦除 SAM 的方法调用侧实参造型(`(Consumer)`) | +| `JDEC_LAMBDA_RAWRECV_CAST_OFF` | jar 内 RAW 泛型接收者擦除 SAM 的方法调用侧**lambda 体**实参造型(`(Consumer)`)。**方法引用(`Type::m`/`receiver::m`/`Type::new`)跳过**: 它原生可绑到 raw SAM(无显式形参可冲突), 造型反而在 SAM 嵌套通配符处(`Stream.flatMap` 的 `Function>`)钉死具体参数化、挫败 javac 多态推断。判定靠 `CustomValue.IsMethodRef`(bootstrap 方法引用分支置位) | +| `JDEC_LAMBDA_RAW_JDK_RECV_CAST_OFF` | 同上 JDK 接收者伴生(`Stream`/`Optional` 的 RAW 接收者): `.map((l0) -> ...)` 显式类型 lambda 须补 `(Function)` 才绑到擦除 SAM; 方法引用同样跳过。修 fastjson2 `JSONPathSegment$CycleNameSegment$MapRecursive`。本轮方法引用跳过分支清掉 fastjson2 `ObjectReaderCreator.toFieldReaderArray` `flatMap(Collection::stream)`(fastjson2 tree -1) 与 spring `AnnotatedTypeMetadata` `collect(Collector<...>)`(spring tree -3) | | `JDEC_CTOR_METHODREF_FIX_OFF` | 构造器方法引用 `::new` 渲染(修 `::new_`) | | `JDEC_CTOR_DIAMOND_OFF` | 泛型类 `new` 带方法引用/lambda 实参时补菱形 `<>` | +| `JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF` | 泛型方法返回 `Supplier`/`Function`/`BiFunction<..>` 的 lambda 体, 经 raw 接收者或 Object 返回调用取到擦除 Object 值, 丢源码的 unchecked `return (T)/(R) expr;` 造型, javac 拒「bad return type in lambda expression: Object cannot be converted to T」。修法: 从 enclosing 方法 Signature 的返回类型取该 FI 返回位类型变量, 注入 lambda 体值返回处。仅 instantiatedMethodType 返回为 Object 且 enclosing 方法返回位确为类型变量时触发。修 fastjson2 `ObjectReaderProvider.createObjectCreator` `() -> (T) objectReader.createInstance(0)` + `ObjectReaderCreator.createBuildFunctionLambda` `(l0) -> (R) m.invoke(...)`(fastjson2 tree -2)。**数组类型变量扩展**: instantiatedMethodType 返回擦除 `Object[]`(而非 Object)且 enclosing 方法返回位是数组类型变量 `O[]` 时, 注入 `return (O[]) expr;`(commons-lang3 `Streams$ArrayCollector.finisher` 的 `Function,O[]>` lambda 体返回 `list.toArray((Object[])Array.newInstance(...))`, 源码带 `(O[])` 造型; tree errLines -2)。CFR 亦丢此造型, 三方同败 | | `JDEC_METHODREF_INSTANTIATED_TYPE_OFF` | 方法引用值类型从 invokedynamic instantiatedMethodType 上行为参数化 functional interface | +| `JDEC_CTOR_RAWFI_METHODREF_CAST_OFF` | 构造器/静态方法的 RAW 函数式接口形参位(如 raw `BiConsumer`, SAM accept(Object,Object))收 UNBOUND 实例方法引用(如 `Throwable::setStackTrace`, 实现元数 (Throwable,StackTraceElement[]))时, 绑不到 raw SAM, javac 报「invalid method reference」。修法: 从方法引用携带的 invokedynamic instantiatedMethodType 取实参类型, 重发 `(<<具体类型>>) Type::method` 造型。限 ctor/static 调用、>=2 元数 SAM 族(BiConsumer/BiFunction/BiPredicate)、且至少一形参比 Object 更具体。修 fastjson2 `ObjectReaderCreator` `new FieldReaderStackTrace(..., Throwable::setStackTrace)`(fastjson2 tree -1、缺陷类 13→12)。CFR/Vineflower 亦丢此造型, 三方同败 | | `JDEC_DOPRIVILEGED_LAMBDA_CAST_OFF` | 传给 `AccessController.doPrivileged` 的 lambda 补 `(PrivilegedAction)` 造型消歧 | | `JDEC_BOOL_TO_INT_COERCE_OFF` | 内在布尔值赋 int 缺 `? 1 : 0` 造型 | | `JDEC_BOOL_TO_INT_COERCE_EXPR_OFF` | 上一项结构性扩展支(比较/布尔调用/短路三元) | @@ -181,6 +256,8 @@ iso 把每个扁平单元单独编译, 以下失败是方法学产物, 在 tree( | `JDEC_REF_SLOT_CROSSCLASS_SIBLING_ARM_MERGE_OFF` | 跨类(jar 内)兄弟臂引用 phi 合并: 两互斥臂存 jar 内兄弟类型(`TextNode`/`DataNode` 同继承 `LeafNode`, 互不为子类型)汇入合流。JDK 兄弟合并(仅 JDK 表)与 jar 内子类型合并(需互为子类型)都不覆盖 jar 内兄弟对, 故晚臂分裂、合流读未初始化。以 `CrossClassCommonSuperType`(BFS 两臂 jar 内超类闭包取最近公共祖先, 排除 Object)加宽共享 ref, phi 门控; 仅认 `new` 分配臂(排除枚举常量/静态字段臂, 后者重建三元按枚举本身定型, 加宽致「bad type in conditional」如 guava LittleEndianByteArray)。修 jsoup HtmlTreeBuilder.insert | | `JDEC_BOOL_TO_INT_COERCE_OFF`(共用) → boolean[] 元素存 | `values.CoerceBooleanAssignRHS` 经 `arrayStoreRHS` 处理 `boolean[]` 元素被 int 值赋值的逆向造型: 布尔值存入 `boolean[]` 元素经 javac 编成物化 int 菱形 `cond ? 1 : 0`(JVM 无布尔存储; bastore 同时服务 byte[]/boolean[]), 原样渲染进 `boolean[]` 元素报「int cannot be converted to boolean」。`CoerceBooleanAssignRHS` 把 0/1 叶重定型为布尔(`coerceBooleanArgument`)再折成连接式(`boolReduce`: `cond?true:false`→`cond`); 仅 leftType 为 boolean 且 rhs 为 int 时触发, 已是布尔的值(比较/谓词调用/布尔 ref)原样返回。之前仅覆盖裸 int 字面量(0/1→false/true), 现扩展到三元/表达式 RHS。修 spring ASM `ClassReader.readTypeAnnotationTarget`/`AttributeMethods.` + commons-lang3 `Conversion`(spring 54→52、commons-lang3 18→16) | | `JDEC_BOOL_ACCUM_SLOT_SPLIT_OFF` | 布尔累加器复用不相交 int 循环计数器槽位拆分(`reachingBoolAccumulatorSlotSplit`, 与 `reachingBoolReturnSlotSplit`/`reachingBoolFieldSlotSplit` 同族): `boolean flag=false; flag|=someZcall()` 的 `iconst_0` 初值被 AssignVarGuarded 见作 int 类别、续用了停在同槽表项里的(已死)int 循环计数器 ref, 合并两不相交活跃区; 该槽随后经 `flag|=Zcall` 自累加(`ior/iand/ixor` 回存同槽, 兄弟操作数为 Z 返回调用)定型 boolean, 早循环渲染成 `boolean`(PREDICATE_IS_ASCII、METHOD_HANDLE_HAS_NEGATIVE) + `JSONFactory:325`(fastjson2 tree 17→14、缺陷类 11→9)。承重 `rebind_incompatible_load_test.go` + jar 级 A/B delta。**两趟定型核第一步**(治本方向, 非读侧兜底) | | `JDEC_ARRAY_PARAM_REF_ARG_CAST_OFF` | 数组形参收 null-Object 实参造型(`arrayParamRefArgCast`, 在 `renderArgAt`): 形参是数组类型(`byte[]`/`Object[]`)而实参静态类型是非数组引用类(通常 `java.lang.Object`, 来自 null 初始化局部)时, 类-类造型分支不触发(数组类型 `RawType()` 是 `*JavaArrayType` 非 `*JavaClass`, ok1 为假), 裸 Object 实参不可赋给数组形参, javac 报「Object cannot be converted to byte[]」。字节码里该值(null 或 checkcast)已占数组槽, 补 `(byte[])` 造型保义。紧门控: 形参为数组 + 实参为非数组引用且擦除为 Object(具体类实参传数组形参是真类型错, 不遮蔽)。修 spring ASM `Attribute.computeAttributesSize`/`putAttributes`(Object→byte[]) + cglib `Enhancer`(Object→Object[]), spring 51→48 | | `JDEC_REF_SLOT_THROWABLE_ARM_MERGE_OFF` | try/catch 异常槽 Throwable 族上型臂合并(`reachingRefSlotThrowableArmMerge`, 与 sibling/subtype/object 臂合并同族但限 java.lang.Throwable 族): 多 catch handler 把各自捕获的异常写入同一 JVM 槽, 捕获后的读是一个逻辑 `Throwable cause` 变量(类型为各臂 LUB)。DFS 序里子型臂(InterruptedException)先铸槽变量, getCause()→Throwable 上型臂被 AssignVarGuarded 拆成独立变量, 于是 `cause instanceof X`/`(X)cause` 绑到窄型 InterruptedException 变量, javac 报「InterruptedException cannot be converted to X」。既有 subtype 合并只在 val⊂current 时保留 current, sibling 合并在 LUB==某臂时退出, 都不覆盖「val 为严格上型」的加宽方向。为 hierarchy.go 补 java.lang.Throwable 族常见异常层级边 + `IsThrowableRooted`(对 jdkSuperEdges 做 Throwable 闭包); 两臂皆 Throwable 子类、val 严格上型(CommonSuperType==val)且 phi 共载时 `ResetVarType(vt)` 把共享 ref 加宽到 LUB —— 合并后 catch 变量的用法都是 Throwable 级(instanceof/cast/getMessage/getCause/rethrow), 加宽绝不回归窄型无造型读。修 spring core codec `Decoder.decode`/`Encoder.encode`(spring 48→46) | | `JDEC_ATOMIC_REF_PARAM_OFF` | `java.util.concurrent.atomic.AtomicReference` 的 V 形参方法实参造型(`jdkMethodParamTypeArgIndex` 增 AtomicReference 分支): 字段/局部 `AtomicReference` 的 `get()` 读进 Object 局部后回传给 `compareAndSet(V,V)`/`weakCompareAngeSet(V,V)`/`getAndSet(V)`/`set(V)`/`lazySet(V)`/`getAndAccumulate(V,BinaryOperator)`/`accumulateAndGet(V,BinaryOperator)`(descriptor 擦成 Object), 裸 Object 实参被 javac 按 `AtomicReference.m(V)` 定型判「Object cannot be converted to T」。V 是唯一类型实参, 全部 V 形参位映射到 0(compareAndSet 两形参、单参方法的形参 0、accumulator 的形参 0; operator 形参不动)。`instantiatedParamType` 把 V 形参解析成接收者 T 后, 既有 arg-cast 路径重下 `(T)` unchecked 造型。限 `ntype==1`(裸/非通配参数化), 既有通配符早退(`AtomicReference`)不受影响。修 commons-lang3 `AtomicInitializer.get`(commons-lang3 12→11、缺陷类 9→8) | diff --git a/classparser/bool_sibling_arm_merge_test.go b/classparser/bool_sibling_arm_merge_test.go index 03de5c9..41b77d3 100644 --- a/classparser/bool_sibling_arm_merge_test.go +++ b/classparser/bool_sibling_arm_merge_test.go @@ -23,7 +23,8 @@ import ( var ( // Fix ON: both arms assign ONE boolean variable, read in scope and definitely assigned. - boolSiblingArmOnRe = regexp.MustCompile(`boolean var1;`) + // Tolerate the default-initializer form (`boolean var1 = false;`) added by initProximateSplitSlotDecl. + boolSiblingArmOnRe = regexp.MustCompile(`boolean var1(?:\s*=\s*false)?;`) // Fix OFF: the merge read splits to a phantom `Object var1 = null` -- the recompile blocker. boolSiblingArmOffRe = regexp.MustCompile(`Object var1 = null;`) ) diff --git a/classparser/bool_var_copy_merge_test.go b/classparser/bool_var_copy_merge_test.go new file mode 100644 index 0000000..0217d59 --- /dev/null +++ b/classparser/bool_var_copy_merge_test.go @@ -0,0 +1,66 @@ +package javaclassparser + +// 承重测试: if/else 两臂共用一个 boolean 槽位 —— 一臂复制/三元生成 int-0/1 (boolean default), +// 另一臂存 Z-返回调用的真 boolean 值, 合流后 boolean 用法 (`if (itemRefDetect)`、 +// `previous = itemRefDetect`) 不再渲染 `int = boolean` / `boolean != int` +// (reachingBoolVarCopyMerge, kill-switch JDEC_BOOL_VAR_COPY_MERGE_OFF)。 +// +// 镜像 fastjson2 FieldWriterList.writeList (`itemRefDetect = previousItemRefDetect`) 与 +// ObjectWriterImplList.writeList (`itemRefDetect = (itemClassRefDetect && isRefDetect()) ? 1 : 0`)。 +// javac 把复制臂编成 iload/istore 或 iconst/istore (int 范畴), DFS 遂在复制臂铸出 int 版本; +// boolean 臂的 AssignVarGuarded 拒绝 int/boolean 合并、另铸 boolean 变量。合流读 `if (itemRefDetect)` +// 与 `previous = itemRefDetect` 渲染成 `int = boolean` / `boolean != int`, javac 拒 +// "boolean cannot be converted to int"。 +// 修复把复制臂的 int ref (及其被证为 boolean 的 int-0/1 default) 重定型为 boolean (phi 证同变量)。 +// kill-switch 置位后恢复 int/boolean 分裂, 证明承重。 + +import ( + "os" + "regexp" + "strings" + "testing" +) + +var ( + // Fix ON: the itemRefDetect slot is ONE boolean variable, no int declaration leaks. + // Tolerate the default-initializer form (`boolean var5 = false;`) added by initProximateSplitSlotDecl. + boolVarCopyOnRe = regexp.MustCompile(`boolean var5(?:\s*=\s*false)?;`) + // Fix OFF: the copy arm splits off an `int var5;` that the merge read cannot accept -- the + // recompile blocker (`var5 = var2` renders int = boolean). + boolVarCopyOffRe = regexp.MustCompile(`int var5(?:\s*=\s*0)?;`) +) + +func TestBoolVarCopyMergeIsLoadBearing(t *testing.T) { + seed, err := os.ReadFile("testdata/regression/BoolVarCopyMergeSeed.class") + if err != nil { + t.Fatalf("read BoolVarCopyMergeSeed seed: %v", err) + } + + // Fix ON (default): both arms continue ONE boolean slot, the post-merge read is consistently typed. + os.Unsetenv("JDEC_BOOL_VAR_COPY_MERGE_OFF") + on, err := Decompile(seed) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !boolVarCopyOnRe.MatchString(on) { + t.Errorf("fix ON: expected unified `boolean var5;`, got:\n%s", on) + } + if boolVarCopyOffRe.MatchString(on) { + t.Errorf("fix ON: must NOT split into `int var5;`, got:\n%s", on) + } + if !strings.Contains(on, "var5 = var2") && !strings.Contains(on, "var5 = this.isRefDetect()") { + t.Errorf("fix ON: expected the copy arm and boolean arm to assign var5, got:\n%s", on) + } + + // Fix OFF (kill-switch): the copy arm splits off an int variable, leaving `int var5;` whose + // `var5 = var2` (boolean) assignment javac rejects with "boolean cannot be converted to int" -- + // proving the fix is load-bearing. + t.Setenv("JDEC_BOOL_VAR_COPY_MERGE_OFF", "1") + off, err := Decompile(seed) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if !boolVarCopyOffRe.MatchString(off) { + t.Errorf("fix OFF: expected the `int var5;` split fallback, got:\n%s", off) + } +} diff --git a/classparser/cross_class_sibling_arm_merge_test.go b/classparser/cross_class_sibling_arm_merge_test.go index 53f836a..faf686b 100644 --- a/classparser/cross_class_sibling_arm_merge_test.go +++ b/classparser/cross_class_sibling_arm_merge_test.go @@ -34,7 +34,7 @@ func TestCrossClassSiblingArmMergeIsLoadBearing(t *testing.T) { if err != nil { t.Fatalf("decompile (fix ON) failed: %v", err) } - if !strings.Contains(on, "SiblingArmBase var2;") || + if !strings.Contains(on, "SiblingArmBase var2") || !strings.Contains(on, "var2 = new SiblingArmLeft();") || !strings.Contains(on, "var2 = new SiblingArmRight();") { t.Errorf("fix ON: expected a single SiblingArmBase var2 assigned in BOTH arms, got:\n%s", on) @@ -47,7 +47,7 @@ func TestCrossClassSiblingArmMergeIsLoadBearing(t *testing.T) { } if strings.Contains(off, "var2 = new SiblingArmLeft();") && strings.Contains(off, "var2 = new SiblingArmRight();") && - strings.Contains(off, "SiblingArmBase var2;") { + strings.Contains(off, "SiblingArmBase var2") { t.Errorf("fix OFF: expected the arms to split into distinct variables (kill-switch not load-bearing), got:\n%s", off) } } diff --git a/classparser/ctor_rawfi_methodref_cast_test.go b/classparser/ctor_rawfi_methodref_cast_test.go new file mode 100644 index 0000000..8b42897 --- /dev/null +++ b/classparser/ctor_rawfi_methodref_cast_test.go @@ -0,0 +1,48 @@ +package javaclassparser + +// 承重测试: 构造器 RAW 函数式接口形参位的方法引用造型 (kill-switch JDEC_CTOR_RAWFI_METHODREF_CAST_OFF)。 +// +// 构造器(或静态方法)的某形参是 RAW 函数式接口(如 raw `BiConsumer`, SAM 为 accept(Object,Object)), +// 传入的 UNBOUND 实例方法引用 `Throwable::setStackTrace`(实现元数 (Throwable, StackTraceElement[])) +// 绑不到 raw (Object,Object) SAM, javac 报「invalid method reference」。源码原带 +// `(BiConsumer) Throwable::setStackTrace` 造型; 从 invokedynamic +// instantiatedMethodType 恢复。镜像 fastjson2 ObjectReaderCreator +// `new FieldReaderStackTrace(..., Throwable::setStackTrace)`(CFR/Vineflower 亦丢此造型, 三方同败)。 + +import ( + "os" + "strings" + "testing" +) + +func TestCtorRawFIMethodRefCastIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/MethodRefRawFICtorSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + // Fix ON (default): the raw-BiConsumer constructor argument carries the recovered + // `(BiConsumer)` cast on the method reference. + os.Unsetenv("JDEC_CTOR_RAWFI_METHODREF_CAST_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "(BiConsumer)(Throwable::setStackTrace)") { + t.Errorf("fix ON: expected the cast `(BiConsumer)(Throwable::setStackTrace)`, got:\n%s", on) + } + + // Fix OFF: the bare method reference reappears, proving the cast is load-bearing -- javac would + // reject it as "invalid method reference" against the raw BiConsumer.accept(Object,Object) SAM. + t.Setenv("JDEC_CTOR_RAWFI_METHODREF_CAST_OFF", "1") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if strings.Contains(off, "(BiConsumer)") { + t.Errorf("fix OFF: expected NO `(BiConsumer)` cast, got:\n%s", off) + } + if !strings.Contains(off, "Throwable::setStackTrace") { + t.Errorf("fix OFF: expected the bare `Throwable::setStackTrace` method reference, got:\n%s", off) + } +} diff --git a/classparser/decompiler/core/bootstrap_methods.go b/classparser/decompiler/core/bootstrap_methods.go index 1519961..b2c027c 100644 --- a/classparser/decompiler/core/bootstrap_methods.go +++ b/classparser/decompiler/core/bootstrap_methods.go @@ -144,11 +144,33 @@ var buildinBootstrapMethods = map[string]func(args ...values.JavaValue) BuildinB if err != nil { return nil, fmt.Errorf("dump lambda method `%s.%s` error: %w", classMember.Name, member, err) } + // A lambda body that returns a generic-erased Object while its functional-interface SAM + // returns a method type variable lost the source's unchecked `return (T) expr;` cast. The + // erased lambda impl method returns Object (its instantiatedMethodType return is Object), + // but the FI target -- recovered from the ENCLOSING method's Signature return type, since + // the lambda is the direct return value and the method declares `Supplier` etc. -- has a + // type variable in the return position. javac rejects the bare body + // ("bad return type in lambda expression: Object cannot be converted to T"). Re-emit the cast. + // Kill-switch: JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF=1. + var retTypevarCast string + if os.Getenv("JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF") == "" { + var instantiatedMT values.JavaValue + if len(args1) >= 3 { + instantiatedMT = args1[2] + } + retTypevarCast = lambdaReturnPositionTypevar(typ, instantiatedMT) + } cv := values.NewCustomValue(func(funcCtx *class_context.ClassContext) string { s := methodStr for i, ca := range captured { s = strings.ReplaceAll(s, fmt.Sprintf("\x00LCAP%d\x00", i), ca.String(funcCtx)) } + if retTypevarCast != "" { + castTarget := resolveLambdaReturnTypevar(funcCtx, retTypevarCast) + if castTarget != "" { + s = injectLambdaReturnCast(s, castTarget) + } + } return s }, func() types.JavaType { return typ @@ -234,6 +256,18 @@ var buildinBootstrapMethods = map[string]func(args ...values.JavaValue) BuildinB // receiver (`(C::m).apply(x)` does not compile); flag it so the call site adds the cast. refVal.Flag = "lambda" refVal.NoOuterCapture = len(capturedArgs) == 0 + refVal.IsMethodRef = true + // Carry the instantiatedMethodType descriptor so a constructor argument whose formal is a + // RAW functional interface can recover the source's `(FI<...>) Type::method` cast (the raw + // SAM erases the impl method's arity, so the bare method ref is "invalid"). fastjson2 + // ObjectReaderCreator `new FieldReaderStackTrace(..., Throwable::setStackTrace)`. + if len(args1) >= 3 { + if cv, ok := args1[2].(*values.CustomValue); ok { + if s := cv.String(&class_context.ClassContext{}); strings.HasPrefix(s, "(") { + refVal.InstantiatedMtdDesc = s + } + } + } return refVal, nil } }, @@ -443,3 +477,169 @@ func inferLambdaTypeFromInstantiated(rawType types.JavaType, instantiatedMethodT } return types.NewParameterizedType(rawName, typeArgs) } + +// lambdaFIReturnPosition is the type-argument index that supplies the SAM's RETURN type for each +// standard JDK functional interface whose SAM returns a (possibly type-variable) value. -1 means +// the FI's SAM returns void (Consumer/BiConsumer) and no return cast ever applies. +var lambdaFIReturnPosition = map[string]int{ + "java.util.function.Supplier": 0, + "java.util.function.Function": 1, + "java.util.function.BiFunction": 2, + "java.util.function.Predicate": -1, + "java.util.function.Consumer": -1, + "java.util.function.BiConsumer": -1, + "java.util.function.BiPredicate": -1, +} + +// lambdaReturnPositionTypevar returns the FI's raw class name when the lambda is a JDK +// Supplier/Function/BiFunction whose instantiatedMethodType return type is Object (erased) -- the +// necessary precondition for a return-position type-variable cast. The actual type variable name +// is resolved later from the enclosing method's Signature (see resolveLambdaReturnTypevar), since +// the lambda value carries only the erased/instantiated type, not the enclosing method's type vars. +// Returns "" when no cast applies (non-Object return, or an FI whose SAM returns void). +func lambdaReturnPositionTypevar(rawType types.JavaType, instantiatedMethodType values.JavaValue) string { + if rawType == nil { + return "" + } + jc, ok := rawType.RawType().(*types.JavaClass) + if !ok || jc == nil { + return "" + } + pos, known := lambdaFIReturnPosition[jc.Name] + if !known || pos < 0 { + return "" + } + // The instantiatedMethodType's return type must be the erased Object: only then did the body lose + // a type-variable cast. A concrete instantiated return (e.g. `()Ljava/lang/String;`) binds directly + // and must not be cast. + if instantiatedMethodType == nil { + return "" + } + desc := "" + if cv, ok := instantiatedMethodType.(*values.CustomValue); ok { + if s := cv.String(&class_context.ClassContext{}); strings.HasPrefix(s, "(") { + desc = s + } + } + if desc == "" { + return "" + } + mt, err := types.ParseMethodDescriptor(desc) + if err != nil { + return "" + } + ft := mt.FunctionType() + if ft == nil || ft.ReturnType == nil { + return "" + } + // The instantiatedMethodType's return type must be the erased Object — only then did the body lose a + // type-variable cast. A concrete instantiated return binds directly and must not be cast. The erased + // SAM return is `Object` for a scalar type variable (Supplier/Function) OR `Object[]` for an + // ARRAY type variable (Function,O[]> — apply erases to `Object[]`, not Object; the lambda + // impl `lambda$finisher$1` returns `Object[]`). Accept both. + if retClass, ok := ft.ReturnType.RawType().(*types.JavaClass); ok && retClass != nil && retClass.Name == "java.lang.Object" { + return jc.Name + } + if ft.ReturnType.IsArray() { + if elem := ft.ReturnType.ElementType(); elem != nil { + if retClass, ok := elem.RawType().(*types.JavaClass); ok && retClass != nil && retClass.Name == "java.lang.Object" { + return jc.Name + } + } + } + return "" +} + +// resolveLambdaReturnTypevar recovers the type-variable NAME the return cast should target, from +// the ENCLOSING method's generic Signature. The lambda is the method's direct return value (the +// fastjson2 `public Supplier m() { ...; return () -> ...createInstance(); }` shape), so the +// method Signature's return type is the FI with its real type-variable argument. Returns "" when the +// enclosing method has no Signature, the return type is not a matching parameterized FI, or the +// return-position type argument is not a bare type variable. +func resolveLambdaReturnTypevar(funcCtx *class_context.ClassContext, fiRawName string) string { + if funcCtx == nil || funcCtx.CurrentMethodSig == "" { + return "" + } + sig := funcCtx.CurrentMethodSig + // Strip a leading `` formal-type-parameter declaration so ParseMethodSignature + // (which expects a leading `(`) sees the parameter/return body. + if strings.HasPrefix(sig, "<") { + depth := 0 + for i, c := range sig { + if c == '<' { + depth++ + } else if c == '>' { + depth-- + if depth == 0 { + sig = sig[i+1:] + break + } + } + } + } + _, ret := types.ParseMethodSignature(sig) + if ret == nil { + return "" + } + pt, ok := types.AsParameterizedType(ret) + if !ok || pt.RawClassName != fiRawName { + return "" + } + pos, _ := lambdaFIReturnPosition[fiRawName] + if pos >= len(pt.TypeArgs) { + return "" + } + ta := pt.TypeArgs[pos] + if ta == nil { + return "" + } + // A bare type variable parses as a *JavaClass whose Name is a single identifier (no dot), e.g. "T". + // A concrete class arg (Object/String/...) has a dotted FQN and binds directly, so no cast. + if jc, ok := ta.RawType().(*types.JavaClass); ok && jc != nil && !strings.Contains(jc.Name, ".") { + return jc.Name + } + // An ARRAY type-variable arg (`O[]` — Function, O[]>.apply returns O[] but the lambda impl + // returns the erased Object[]) parses as a *JavaArrayType whose element is a bare type variable. + // The source carried `return (O[]) expr;` (commons-lang3 Streams$ArrayCollector.finisher: the + // `Function,O[]>` lambda body returns `list.toArray((Object[]) Array.newInstance(...))`, + // an erased Object[] the source cast back to O[]). Recover `O[]` from the element type variable. + if ta.IsArray() { + if elem := ta.ElementType(); elem != nil { + if jc, ok := elem.RawType().(*types.JavaClass); ok && jc != nil && !strings.Contains(jc.Name, ".") { + return jc.Name + strings.Repeat("[]", ta.ArrayDim()) + } + } + } + return "" +} + +// injectLambdaReturnCast rewrites a statement-lambda body `(...) -> { ... return EXPR; ... }` so the +// body's value `return EXPR;` becomes `return (TYPEVAR) (EXPR);`, re-emitting the source's unchecked +// cast on the erased Object return. The value return is the LAST `return ` token in the rendered arrow +// body (javac's lambda body shape), and its expression is the run of non-`;`/non-`}` characters up to +// the terminating `;`. A bare `return;` (void) is left untouched. Returns the body unchanged if no +// return site matches. Kill-switch: JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF. +func injectLambdaReturnCast(body, typevar string) string { + idx := strings.LastIndex(body, "return ") + if idx < 0 { + return body + } + exprStart := idx + len("return ") + // Skip leading whitespace of the return expression; remember how many bytes we trimmed so the + // tail splice stays byte-aligned. + skipped := 0 + for exprStart+skipped < len(body) && (body[exprStart+skipped] == ' ' || body[exprStart+skipped] == '\t') { + skipped++ + } + rest := body[exprStart+skipped:] + if strings.HasPrefix(rest, ";") { + return body // void return; nothing to cast + } + end := strings.IndexByte(rest, ';') + if end < 0 { + return body + } + expr := strings.TrimSpace(rest[:end]) + castStmt := "(" + typevar + ") (" + expr + ");" + return body[:exprStart] + castStmt + body[exprStart+skipped+end+1:] +} diff --git a/classparser/decompiler/core/class_context/context.go b/classparser/decompiler/core/class_context/context.go index 2e2cf57..0f4a97c 100644 --- a/classparser/decompiler/core/class_context/context.go +++ b/classparser/decompiler/core/class_context/context.go @@ -81,6 +81,17 @@ type ClassContext struct { // `add(E)` vs `add(E...)`). Consumed by sameClassMethodParamType as a fallback after the arity path // declines. Kill-switch consumer: JDEC_SAMECLASS_DESC_SIG_OFF. Empty when the class has no such method. MethodSignaturesByDesc map[string]string + // MethodDescriptors records every (name, EXACT JVM descriptor) key present on same-class methods, + // REGARDLESS of whether the method has a generic Signature attribute (unlike MethodSignaturesByDesc, + // which only carries methods that have a Signature). This lets a renderer detect OVERLOAD + // AMBIGUITY at a call site: a `null` argument fed to an overloaded method whose other same-arity + // overload takes a different reference type at the same slot is ambiguous to javac (null is + // assignable to both Object and String); casting `null` to the descriptor-pinned formal forces + // javac to pick the bytecode-selected overload. The table is populated for ALL non-/non- + // methods so plain (non-generic) overloads like commons-lang3 FieldUtils.readField(Field,Object,boolean) + // vs readField(Object,String,boolean) are visible. Empty on the single-class path. Kill-switch consumer: + // JDEC_NULL_ARG_CAST_OFF. + MethodDescriptors map[string]bool // ConstructorSignatures maps a same-class constructor's argument count to its raw generic Signature // string (e.g. 1 -> `(Ljava/util/Comparator<-TK;>;)V`). A `this(...)` self-call loses the source's // unchecked wildcard cast on an argument whose parameter is a wildcard parameterization mentioning a @@ -257,6 +268,64 @@ func methodSigKey(name string, argc int) string { return name + "/" + strconv.Itoa(argc) } +// HasOverloadedSameArity reports whether the same-class method `name` has another method with a +// DIFFERENT descriptor at the same arity as `descriptor`. Used to decide whether a `null` argument at +// this slot is genuinely ambiguous (a second overload takes a different reference type there) and thus +// needs an explicit `(FormalType) null` cast to force javac to pick the bytecode-selected overload. +// Returns false when only one same-arity overload exists (no ambiguity) or when MethodDescriptors is +// unset (single-class path, no sibling info). +func (f *ClassContext) HasOverloadedSameArity(name, descriptor string) bool { + if f == nil || name == "" || descriptor == "" || f.MethodDescriptors == nil { + return false + } + argc := descriptorArgc(descriptor) + for k := range f.MethodDescriptors { + if len(k) <= len(name) || k[:len(name)] != name { + continue + } + otherDesc := k[len(name):] + if otherDesc == descriptor { + continue // same method + } + if descriptorArgc(otherDesc) == argc { + return true + } + } + return false +} + +// descriptorArgc counts the number of parameter field descriptors in a JVM method descriptor string +// (e.g. "(Ljava/lang/Object;I)V" -> 2). A pure-string counterpart of dumper.methodParamFieldDescriptors +// so class_context (which must not import the parser) can compute arity without a cycle. +func descriptorArgc(descriptor string) int { + open := strings.IndexByte(descriptor, '(') + closeIdx := strings.IndexByte(descriptor, ')') + if open < 0 || closeIdx < 0 || closeIdx < open { + return 0 + } + params := descriptor[open+1 : closeIdx] + argc := 0 + for i := 0; i < len(params); { + for i < len(params) && params[i] == '[' { + i++ + } + if i >= len(params) { + break + } + if params[i] == 'L' { + semi := strings.IndexByte(params[i:], ';') + if semi < 0 { + break + } + i += semi + 1 + } else { + i++ + } + argc++ + } + return argc +} + // MethodSigKey is the exported builder for the MethodSignatures key, so the dumper populates the map // with exactly the key MethodSignature looks up. func MethodSigKey(name string, argc int) string { diff --git a/classparser/decompiler/core/code_analyser.go b/classparser/decompiler/core/code_analyser.go index 173493f..1181835 100644 --- a/classparser/decompiler/core/code_analyser.go +++ b/classparser/decompiler/core/code_analyser.go @@ -38,6 +38,13 @@ type Decompiler struct { FunctionContext *class_context.ClassContext varTable map[int]*values.JavaRef opcodeIdToRef map[*OpCode][][2]any + // refToCreatingStore records, per *JavaRef pointer, the FIRST local-store opcode whose simulation + // created that ref (isFirst=true). It lets the boolean-copy merge deterministically recover the + // store that defined a slot's current ref without scanning opcodeIdToRef (a map whose iteration + // order is non-deterministic, and where several stores can share a reused ref pointer). Only stores + // where the ref was freshly minted are recorded, so a later same-type store reusing an existing ref + // never overwrites the creator. Populated alongside opcodeIdToRef in the OP_*STORE handler. + refToCreatingStore map[*values.JavaRef]*OpCode // dupConvertedRefValue records, per dup-family opcode and in the SAME order as the // opcodeIdToRef entries appended by checkAndConvertRef, the actual value each synthesized // temp was created from. The dup statement-parse handler must use this instead of @@ -45,7 +52,18 @@ type Decompiler struct { // consume order (e.g. the array reference under the index in `this.f[i] op= v`, whose // dup2 pops index first), stackConsumed[i] points at the wrong operand and would emit a // bogus assignment like `int t = i; t[i] = ...`. - dupConvertedRefValue map[*OpCode][]values.JavaValue + dupConvertedRefValue map[*OpCode][]values.JavaValue + // checkcastInnerArg records, per OP_CHECKCAST opcode, the inner value (the aload SlotValue) + // BEFORE it was wrapped in the cast CustomValue. This lets phase-2 invoke-arg rebinding reach + // the original local-load without unpacking the CustomValue's closures (which are opaque). + // Populated in the phase-1 OP_CHECKCAST handler. + checkcastInnerArg map[*OpCode]values.JavaValue + // invokeFuncCall records, per invoke-family opcode, the FunctionCallExpression it produced (for + // value-returning invokes, the FCE is pushed on the phase-1 stack and consumed later; the FCE's + // receiver and arguments were bound at phase-1 time when opcodeIdToRef was incomplete). The + // phase-1-post pass rebindIncompatibleInvokeArgs walks this map to rebind incompatible + // receivers/arguments using the now-complete opcodeIdToRef. Populated in the phase-1 invoke handler. + invokeFuncCall map[*OpCode]*values.FunctionCallExpression bytecodes []byte opCodes []*OpCode RootOpCode *OpCode @@ -150,7 +168,10 @@ func NewDecompiler(bytecodes []byte, constantPoolGetter func(id int) values.Java opcodeIndexToOffset: map[int]uint16{}, varTable: map[int]*values.JavaRef{}, opcodeIdToRef: map[*OpCode][][2]any{}, + refToCreatingStore: map[*values.JavaRef]*OpCode{}, dupConvertedRefValue: map[*OpCode][]values.JavaValue{}, + checkcastInnerArg: map[*OpCode]values.JavaValue{}, + invokeFuncCall: map[*OpCode]*values.FunctionCallExpression{}, varUserMap: omap.NewEmptyOrderedMap[*values.JavaRef, []*VarFoldRule](), delRefUserAttr: map[string][3]int{}, selfOpFoldedRefs: map[string]bool{}, @@ -749,6 +770,531 @@ func refIsPrimitive(ref *values.JavaRef) bool { return isPrim } +// rebindIncompatibleLoadForSink is the two-pass load rebinding for the catch-slot / switch-case +// slot-reuse family (fastjson2 JDKUtils.: one JVM slot carries Boolean + Predicate + +// MethodHandle across disjoint live ranges, linked through shared putstatic reads). By pass 2 the +// forward simulation is complete, so opcodeIdToRef holds every store's committed ref. When a sink +// (putfield/putstatic/areturn) consumes a local-load value (SlotValue wrapping a JavaRef) whose +// resolved type is INCOMPATIBLE with the sink's declared type, the load bound the wrong branch ref +// (the single global slot table parked a DFS-stale ref in `current`). The load's TRUE reaching +// definition -- found by the structural reachingStoresOf walk (Source edges, no simulation order) -- +// carries a type-compatible ref; rebind the sink to that ref via a fresh SlotValue so the assignment +// reads the branch-correct variable. +// +// Safety: it fires ONLY when (1) the value is a local-load SlotValue wrapping a JavaRef; (2) the +// resolved ref type and the sink type are BOTH non-primitive reference classes (not Object, not type +// variables) of DIFFERENT names; (3) neither is a bridged subtype of the other (a genuine +// incompatible pair); (4) a reaching store exists whose committed ref type IS the sink type (exact +// name match) -- proving the compatible branch was actually minted. This never widens or casts; it +// merely re-points the read at the already-minted compatible ref. Kill-switch: +// JDEC_REBIND_INCOMPATIBLE_LOAD_OFF=1. +func (d *Decompiler) rebindIncompatibleLoadForSink(sink *OpCode, value values.JavaValue, sinkType types.JavaType) values.JavaValue { + if os.Getenv("JDEC_REBIND_INCOMPATIBLE_LOAD_OFF") == "1" { + return value + } + if value == nil || sinkType == nil || sink == nil { + return value + } + // The value must be a local-load SlotValue wrapping a JavaRef. + ref, ok := slotValueJavaRef(value) + if !ok || ref == nil { + return value + } + vt := ref.Type() + if vt == nil { + return value + } + if _, isPrim := vt.RawType().(*types.JavaPrimer); isPrim { + return value + } + if _, isPrim := sinkType.RawType().(*types.JavaPrimer); isPrim { + return value + } + vtFQN, vtOK := types.ClassFQNOf(vt) + ttFQN, ttOK := types.ClassFQNOf(sinkType) + if !vtOK || !ttOK { + // An ARRAY-typed value flowing into a non-array reference sink (or vice versa) is an obvious + // incompatible pair (e.g. Class[] value into a Field sink). ClassFQNOf returns false for arrays, + // so this branch lets the array-vs-class case through to the reaching-store lookup below instead + // of bailing. fastjson2 TypeUtils.: slot 0 null-initialized as Class[] (DFS-corruption + // from a later array reuse) but the true reaching store is getDeclaredField → Field. + vtIsArray := vt.IsArray() + ttIsArray := sinkType.IsArray() + if vtIsArray == ttIsArray { + return value + } + // One is array, the other isn't: genuine incompatibility. Synthesize FQNs so the store lookup + // below can match against the non-array side (the sink type). + if !ttOK { + return value + } + vtFQN = "" // mark as unknown-array so the subtype checks below skip + } + if vtFQN == ttFQN { + return value + } + if vtFQN == ttFQN { + return value + } + isObject := func(n string) bool { return n == "java.lang.Object" } + // Allow an Object-typed value (a null-adopted or merge-widened slot) flowing into a specific + // reference sink: the slot may carry a branch whose type matches the sink (fastjson2 JSON:82 + // `var6 = var5` where var5 is Object but the true branch is JSONObject). The exact-match reaching + // store gate below proves the compatible branch exists. Reject only when the SINK is Object (no + // specific target to match). + if isObject(ttFQN) { + return value + } + provider := func() types.SuperTypeProvider { + if fc := d.FunctionContext; fc != nil { + return fc.SiblingSuperTypes + } + return nil + }() + // Genuine incompatible pair (Boolean vs Predicate, etc.): neither assignable to the other. For an + // Object-typed value the downcast direction (target <: value) is allowed through -- the exact-match + // reaching store gate below is the safety net. + if !isObject(vtFQN) { + if types.IsReferenceSubtypeBridged(vtFQN, ttFQN, provider) { + return value + } + if types.IsReferenceSubtypeBridged(ttFQN, vtFQN, provider) { + return value + } + } + // Find the load that produced this value: it is the Source opcode of the sink whose value was + // pushed by an aload. Walk back one Source hop to the load, then use reachingStoresOf (structural, + // simulation-order-independent) to find the TRUE reaching store on this path. + if len(sink.Source) == 0 { + return value + } + load := sink.Source[0] + if load == nil || !isLocalLoadOpcode(load.Instr.OpCode) { + return value + } + slot := GetRetrieveIdx(load) + if slot < 0 { + return value + } + stores, _ := reachingStoresOf(load, slot) + for _, st := range stores { + refs, ok := d.opcodeIdToRef[st] + if !ok || len(refs) == 0 { + continue + } + last := refs[len(refs)-1] + if len(last) == 0 { + continue + } + stRef, ok := last[0].(*values.JavaRef) + if !ok || stRef == nil { + continue + } + stType := stRef.Type() + if stType == nil { + continue + } + stFQN, stOK := types.ClassFQNOf(stType) + if !stOK { + continue + } + // Exact name match to the sink type: this is the branch-correct ref. + if stFQN == ttFQN && stRef.VarUid != ref.VarUid { + // Rebind: a fresh SlotValue wrapping the compatible ref, so the sink reads the correct branch. + sv := values.NewSlotValue(stRef, stType) + // Register a var-user for the rebound ref so the var-fold user-count reflects this read. + // Without this, the rebound ref may look single-use (its phase-1 user count excludes this + // phase-2 sink), and var-fold would single-use-fold it away — dropping its declaration and + // leaving the sink reading an undeclared variable (fastjson2 TypeUtils. + // FIELD_JSON_OBJECT_1x_map: the getDeclaredField store is folded into setAccessible, and the + // putstatic sink that was rebound to read it then sees no declaration). + users := d.varUserMap.GetMust(stRef) + d.varUserMap.Set(stRef, append(users, &VarFoldRule{ + Replace: func(v values.JavaValue) { + sv.ResetValue(v) + }, + CurrentOpcode: sink, + })) + return sv + } + } + return value +} + +// rebindCheckcastInnerArgs is the phase-2 post-pass for checkcast-wrapped local-loads whose resolved +// type is incompatible with the cast target. fastjson2 JDKUtils:318: `metafactory(trustedLookup((Class) +// (var22_1)), ..., (MethodHandle)(var21_1), ...)` where var22_1 is MethodHandle (cast to Class fails) +// and var21_1 is Throwable (cast to MethodHandle fails). Each cast wraps an aload SlotValue; the cast's +// ref type (the checkcast target) differs from the slot's parked DFS-stale ref. By phase 2 the +// simulation is complete, so use the structural reachingStoresOf walk (Source edges) on the checkcast's +// source aload to find the TRUE reaching store whose committed ref type matches the cast target, and +// rebind the inner SlotValue to that ref. This makes the cast wrap the branch-correct variable. Kill- +// switch: JDEC_REBIND_CHECKCAST_INNER_OFF=1 (shared with JDEC_REBIND_INCOMPATIBLE_LOAD_OFF). +func (d *Decompiler) rebindCheckcastInnerArgs() { + if os.Getenv("JDEC_REBIND_CHECKCAST_INNER_OFF") == "1" { + return + } + if os.Getenv("JDEC_REBIND_INCOMPATIBLE_LOAD_OFF") == "1" { + return + } + for checkcastOp, innerArg := range d.checkcastInnerArg { + if checkcastOp == nil || innerArg == nil { + continue + } + cv, ok := d.constantPoolGetter(int(Convert2bytesToInt(checkcastOp.Data))).(*values.JavaClassValue) + if !ok || cv == nil { + continue + } + castType := cv.Type() + if castType == nil { + continue + } + ctFQN, ctOK := types.ClassFQNOf(castType) + if !ctOK || ctFQN == "java.lang.Object" { + continue + } + innerRef, ok := slotValueJavaRef(innerArg) + if !ok || innerRef == nil { + continue + } + vt := innerRef.Type() + if vt == nil { + continue + } + if _, isPrim := vt.RawType().(*types.JavaPrimer); isPrim { + continue + } + vtFQN, vtOK := types.ClassFQNOf(vt) + if !vtOK { + continue + } + if vtFQN == ctFQN { + continue + } + provider := func() types.SuperTypeProvider { + if fc := d.FunctionContext; fc != nil { + return fc.SiblingSuperTypes + } + return nil + }() + if types.IsReferenceSubtypeBridged(vtFQN, ctFQN, provider) { + continue + } + if types.IsReferenceSubtypeBridged(ctFQN, vtFQN, provider) { + continue + } + // Find the source aload of the checkcast (the load that fed the cast). + if len(checkcastOp.Source) == 0 { + continue + } + load := checkcastOp.Source[0] + if load == nil || !isLocalLoadOpcode(load.Instr.OpCode) { + continue + } + slot := GetRetrieveIdx(load) + if slot < 0 { + continue + } + stores, _ := reachingStoresOf(load, slot) + var matchRef *values.JavaRef + for _, st := range stores { + refs, ok := d.opcodeIdToRef[st] + if !ok || len(refs) == 0 { + continue + } + last := refs[len(refs)-1] + if len(last) == 0 { + continue + } + stRef, ok := last[0].(*values.JavaRef) + if !ok || stRef == nil { + continue + } + stType := stRef.Type() + if stType == nil { + continue + } + stFQN, stOK := types.ClassFQNOf(stType) + if !stOK { + continue + } + if stFQN == ctFQN && stRef.VarUid != innerRef.VarUid { + matchRef = stRef + break + } + } + if matchRef == nil { + continue + } + // Rebind the inner SlotValue to the matched branch ref. ResetValue replaces the SlotValue's + // inner value, so the cast now wraps the branch-correct variable. + if sv, ok := innerArg.(*values.SlotValue); ok && sv != nil { + sv.ResetValue(matchRef) + } + } +} + +// rebindIncompatibleInvokeArgs is the phase-1-post pass for value-returning invokes whose receiver or +// argument is a local-load bound (at phase-1 time, when opcodeIdToRef was incomplete) to a DFS-stale +// wrong-type ref. By phase 1's end opcodeIdToRef is fully populated, so the structural reachingStoresOf +// walk can find the TRUE reaching store whose ref type matches the callee's declaring class (receiver) +// or the declared parameter type (argument), and the FCE's SlotValue is reset to that ref. +// +// Canonical case: fastjson2 ObjectReaderBaseModule:793 `var7.getParameters()` where var7 resolved as +// Annotation[] (DFS corruption from a null-init sharing slot 7 with a later Constructor store); the +// true reaching store is a Constructor, and rebindIncompatibleLoadForSink cannot fire because the +// value-returning invokevirtual pushes its FCE on the phase-1 stack and the receiver is embedded in it, +// never reaching a phase-2 putstatic/putfield sink. Kill-switch: JDEC_REBIND_INCOMPATIBLE_LOAD_OFF=1 +// (shared with rebindIncompatibleLoadForSink / rebindCheckcastInnerArgs). +func (d *Decompiler) rebindIncompatibleInvokeArgs() { + if os.Getenv("JDEC_REBIND_INCOMPATIBLE_LOAD_OFF") == "1" { + return + } + provider := func() types.SuperTypeProvider { + if fc := d.FunctionContext; fc != nil { + return fc.SiblingSuperTypes + } + return nil + }() + for invokeOp, fce := range d.invokeFuncCall { + if invokeOp == nil || fce == nil { + continue + } + // Rebind the receiver (non-static invokes only). + if !fce.IsStatic && fce.Object != nil { + if fce.ClassName != "" { + if recvType := types.NewJavaClass(fce.ClassName); recvType != nil { + if better := d.rebindInvokeOperand(invokeOp, fce.Object, recvType, provider); better != nil { + fce.Object = better + } + } + } + } + // Rebind arguments against the declared parameter types. + if fce.FuncType != nil { + for i, arg := range fce.Arguments { + if i >= len(fce.FuncType.ParamTypes) { + break + } + pt := fce.FuncType.ParamTypes[i] + if pt == nil { + continue + } + if better := d.rebindInvokeOperand(invokeOp, arg, pt, provider); better != nil { + fce.Arguments[i] = better + } + } + } + } +} + +// rebindInvokeOperand checks whether a single invoke operand (receiver or argument) is a local-load +// SlotValue whose resolved type is incompatible with `sinkType` (the declaring class or declared +// parameter type), and if so rebinds it to a reaching-store ref whose type matches sinkType. Returns +// the rebound value (a fresh SlotValue) or nil to keep the original. Mirrors +// rebindIncompatibleLoadForSink's gate logic but operates on the FCE operand directly. +func (d *Decompiler) rebindInvokeOperand(invokeOp *OpCode, operand values.JavaValue, sinkType types.JavaType, provider types.SuperTypeProvider) values.JavaValue { + if operand == nil || sinkType == nil { + return nil + } + sv, ok := operand.(*values.SlotValue) + if !ok || sv == nil { + return nil + } + ref, ok := slotValueJavaRef(sv) + if !ok || ref == nil { + return nil + } + vt := ref.Type() + if vt == nil { + return nil + } + if _, isPrim := vt.RawType().(*types.JavaPrimer); isPrim { + return nil + } + if _, isPrim := sinkType.RawType().(*types.JavaPrimer); isPrim { + return nil + } + vtFQN, vtOK := types.ClassFQNOf(vt) + ttFQN, ttOK := types.ClassFQNOf(sinkType) + if !vtOK || !ttOK { + vtIsArray := vt.IsArray() + ttIsArray := sinkType.IsArray() + if vtIsArray == ttIsArray { + return nil + } + if !ttOK { + return nil + } + vtFQN = "" + } + if vtFQN == ttFQN { + return nil + } + if ttFQN == "java.lang.Object" { + return nil + } + if vtFQN != "" { + if types.IsReferenceSubtypeBridged(vtFQN, ttFQN, provider) { + return nil + } + if types.IsReferenceSubtypeBridged(ttFQN, vtFQN, provider) { + return nil + } + } + // Find the load that fed this operand. For a value-returning invokevirtual, the receiver load is + // NOT a direct Source of the invoke (the FCE was pushed on the phase-1 stack and consumed later). + // Instead, scan all opcodes for the local-load whose opcodeIdToRef entry matches this ref's VarUid. + load := d.findLoadByRef(ref) + if load == nil { + // Fallback: walk the invoke's Source chain. + load = d.findLocalLoadInSource(invokeOp, ref) + } + if load == nil { + return nil + } + // The found opcode may be a STORE (opcodeIdToRef is keyed by stores) or a LOAD (from the Source + // chain fallback). Extract the slot accordingly. + slot := -1 + if isLocalStoreOpcode(load.Instr.OpCode) { + slot = GetStoreIdx(load) + } else if isLocalLoadOpcode(load.Instr.OpCode) { + slot = GetRetrieveIdx(load) + } + if slot < 0 { + return nil + } + stores, _ := reachingStoresOf(load, slot) + if os.Getenv("JDEC_REBIND_INVOKE_DEBUG") == "1" { + storeOffs := []string{} + for _, st := range stores { + off := -1 + if st != nil { + off = int(st.CurrentOffset) + } + storeOffs = append(storeOffs, fmt.Sprintf("%d", off)) + } + fmt.Fprintf(os.Stderr, "[REBIND-OP] loadOff=%d slot=%d stores=[%s]\n", int(load.CurrentOffset), slot, strings.Join(storeOffs, ",")) + } + for _, st := range stores { + refs, ok := d.opcodeIdToRef[st] + if !ok || len(refs) == 0 { + continue + } + last := refs[len(refs)-1] + if len(last) == 0 { + continue + } + stRef, ok := last[0].(*values.JavaRef) + if !ok || stRef == nil { + continue + } + stType := stRef.Type() + if stType == nil { + continue + } + stFQN, stOK := types.ClassFQNOf(stType) + if !stOK { + continue + } + if stFQN == ttFQN && stRef.VarUid != ref.VarUid { + newSV := values.NewSlotValue(stRef, stType) + // Register a var-user so var-fold doesn't single-use-fold the rebound ref away. + users := d.varUserMap.GetMust(stRef) + d.varUserMap.Set(stRef, append(users, &VarFoldRule{ + Replace: func(v values.JavaValue) { + newSV.ResetValue(v) + }, + CurrentOpcode: invokeOp, + })) + return newSV + } + } + return nil +} + +// findLoadByRef scans all opcodes for the local-LOAD opcode (aload/iload/etc.) whose opcodeIdToRef +// entry matches the given ref's VarUid. This is used to locate the load that fed a value-returning +// invoke's receiver or argument (whose load is not a direct Source of the invoke because the FCE was +// pushed on the phase-1 stack and consumed later). Returns the matching load opcode or nil. +func (d *Decompiler) findLoadByRef(ref *values.JavaRef) *OpCode { + if ref == nil { + return nil + } + // Scan for local-load opcodes only (not stores or invokes). opcodeIdToRef is also keyed by + // stores and dup-converted opcodes, so we must filter to loads to get the right slot via + // GetRetrieveIdx. + for _, op := range d.opCodes { + if op == nil || op.Instr == nil { + continue + } + if !isLocalLoadOpcode(op.Instr.OpCode) { + continue + } + // Loads are NOT in opcodeIdToRef by default. But the load's produced ref is tracked via the + // SlotValue pushed on the stack. Instead, match by slot: check if the load's slot produces a + // ref whose opcodeIdToRef entry (from a store) matches. But we don't have a load->ref map. + // Alternative: scan by offset — find the load nearest to but before the invoke that shares + // the same slot as a store whose ref matches. + } + // No direct load->ref mapping available. Fall through to return nil; the Source-chain fallback + // (findLocalLoadInSource) handles it. + return nil +} + +// findLocalLoadInSource walks the invoke opcode's Source chain (transitively) to find the local-load +// opcode whose slot matches the ref's slot. For a value-returning invokevirtual, the receiver load is +// an ancestor Source of the invoke (the load was consumed by the invokevirtual in phase 1). Loads are +// NOT registered in opcodeIdToRef (only stores are), so we match by slot number: the first local-load +// in the Source chain whose GetRetrieveIdx matches the slot of any store whose ref matches the given +// ref. Returns nil if no matching local-load is found. +func (d *Decompiler) findLocalLoadInSource(invoke *OpCode, ref *values.JavaRef) *OpCode { + if invoke == nil || ref == nil { + return nil + } + // Find the slot of the ref: scan stores in opcodeIdToRef for one whose ref matches. + refSlot := -1 + for _, op := range d.opCodes { + if op == nil || op.Instr == nil || !isLocalStoreOpcode(op.Instr.OpCode) { + continue + } + if refs, ok := d.opcodeIdToRef[op]; ok && len(refs) > 0 { + last := refs[len(refs)-1] + if len(last) > 0 { + if lr, ok := last[0].(*values.JavaRef); ok && lr != nil && lr.VarUid == ref.VarUid { + if s := GetStoreIdx(op); s >= 0 { + refSlot = s + break + } + } + } + } + } + if refSlot < 0 { + return nil + } + // BFS the invoke's Source chain for the first local-load of that slot. + visited := map[*OpCode]bool{invoke: true} + queue := append([]*OpCode{}, invoke.Source...) + for depth := 0; depth < 8 && len(queue) > 0; depth++ { + nextQueue := []*OpCode{} + for _, cur := range queue { + if cur == nil || visited[cur] { + continue + } + visited[cur] = true + if isLocalLoadOpcode(cur.Instr.OpCode) { + if GetRetrieveIdx(cur) == refSlot { + return cur + } + } + nextQueue = append(nextQueue, cur.Source...) + } + queue = nextQueue + } + return nil +} + // reachingSlotVersionOnMismatch repairs a stale slot read produced by the single global varTable. // See the call site in loadVarBySlot for the root cause. We trigger ONLY on the unambiguous // corruption signal: the load opcode's category (reference vs primitive) disagrees with the @@ -1151,6 +1697,126 @@ func (d *Decompiler) reachingBoolDefaultMerge(store *OpCode, slot int, current * return nil } +// reachingBoolVarCopyMerge repairs the if/else (or switch-case) sibling-arm split where one arm copies +// an earlier `boolean previous = (expr) != 0` (compiled to `iconst_1/0; istore`, typed int) into the +// slot (`itemRefDetect = previous`) and a SIBLING arm stores a genuinely boolean-typed value +// (`itemRefDetect = jsonWriter.isRefDetect()`, a Z-returning call or a boolean comparison). javac emits +// the copy as `iload previous; istore S`, so the copied value carries the source's int-typed ref; the +// sibling boolean store then sees int-vs-boolean and AssignVarGuarded (no int<->boolean conversion) +// mints a FRESH boolean variable, splitting one source variable into `int varN` (the copy arm) and +// `boolean varN_1` (the boolean arm). The post-merge use (`if (itemRefDetect)`, `previous = itemRefDetect`) +// then renders as `int = boolean` / `boolean != int` and javac rejects it ("boolean cannot be converted +// to int"). fastjson2 FieldWriterList.writeList / ObjectWriterImplList.writeList. +// +// reachingBoolDefaultMerge cannot anchor here: the copy arm's reaching definition is NOT an int-0/1 +// literal RHS but an `iload` of ANOTHER slot, and the two arms are siblings (neither reaches the other +// through Source edges), so neither the single-reaching-def gate nor the literal-RHS check matches. +// reachingBoolSiblingArmMerge is the role-inverse (value int-literal, current boolean) and likewise +// does not fire. +// +// This handler anchors on the boolean store: when the value being stored is boolean-typed and the slot's +// CURRENT ref (the copy arm's, still parked in the global slot table) was itself defined by copying a +// slot whose OWN definition is an int-0/1 literal default initializer (the canonical bool-default shape +// reachingBoolDefaultMerge would re-type), it re-types the copy arm's ref to boolean. The two arms then +// denote one boolean variable and the merge read stays consistently typed. Gated by a phi (the copy +// def and this store share a downstream load), which rejects genuine disjoint slot reuse. Kill-switch: +// JDEC_BOOL_VAR_COPY_MERGE_OFF=1. +func (d *Decompiler) reachingBoolVarCopyMerge(store *OpCode, slot int, current *values.JavaRef, val values.JavaValue) *values.JavaRef { + if os.Getenv("JDEC_BOOL_VAR_COPY_MERGE_OFF") == "1" { + return nil + } + if store == nil || current == nil || val == nil || val.Type() == nil { + return nil + } + // The value being stored must be genuinely boolean-typed (a Z-returning call / boolean comparison), + // not an int-0/1 literal (that is reachingBoolSiblingArmMerge's domain). + if !isExactPrimer(val.Type(), types.JavaBoolean) { + return nil + } + // The slot's current version must be a plain int local (the copy arm's ref AssignVarGuarded would + // refuse to merge with a boolean value). + if current.IsParam || !isExactPrimer(current.Type(), types.JavaInteger) { + return nil + } + // Find the store that defined `current` (the sibling arm's ref parked in the global slot table by + // DFS order). The reaching-definition analysis walks Source edges backward and CANNOT see a sibling + // branch's definition (no path), so reachingBoolDefaultMerge (which uses reachingSlotStoreOps) bails + // here even though the bool-default shape matches; `current` carries the sibling ref directly. + copyStore := d.findStoreDefiningRef(current) + if copyStore == nil { + return nil + } + // The sibling arm's RHS must prove the value is really a boolean the JVM widened to int. Two shapes: + // (a) an int-0/1 literal default initializer — the canonical bool-default `(expr) != 0` compiled to + // iconst_1/0 (fastjson2 ObjectWriterImplList.write: `(itemClassRefDetect && isRefDetect()) ? 1 : 0`); + // (b) a copy of ANOTHER slot whose OWN definition is such an int-0/1 literal default (fastjson2 + // FieldWriterList.writeList: `itemRefDetect = previous`, where previous defaulted to iconst_1/0). + // A genuine int (non-0/1 literal, or a copy of a real int) is left untouched so it is not mis-typed. + copyVal := d.slotStoreValue[copyStore] + boolType := types.NewJavaPrimer(types.JavaBoolean) + var litToRetype *values.JavaLiteral + // Shape (a): the sibling arm's own RHS is an int-0/1 literal. + if lit, ok := intLiteral01(copyVal); ok { + litToRetype = lit + } else { + // Shape (b): the sibling arm's RHS is a copy of another slot; that source must itself be an + // int-0/1 literal default initializer. + srcRef, ok := slotValueJavaRef(copyVal) + if !ok || srcRef == nil { + return nil + } + srcStore := d.findStoreDefiningRef(srcRef) + if srcStore == nil { + return nil + } + srcLit, ok := intLiteral01(d.slotStoreValue[srcStore]) + if !ok { + return nil + } + litToRetype = srcLit + } + // A phi must prove the copy arm and this boolean store are ONE source variable: both reach a common + // downstream load of `slot`. Genuine disjoint reuse (the copy arm's value is consumed before this + // store, no shared load) shares no load and is left to split. + if !d.slotDefPhiReachesLoad(store, slot, current.VarUid) { + return nil + } + // Re-type the copy arm's ref (and the proven-boolean default's int-0/1 literal) to boolean, so both + // arms denote one boolean variable. This mirrors reachingBoolDefaultMerge's lossless re-typing of an + // int-0/1 default to false/true. + current.ResetVarType(boolType) + litToRetype.JavaType = boolType + return current +} + +// findStoreDefiningRef recovers the store opcode that FIRST created `ref` (pointer identity), using the +// refToCreatingStore index populated alongside opcodeIdToRef. The global slot table parks a sibling arm's +// ref in the slot; this recovers the store that produced it so its RHS can be inspected. Returns nil when +// no such store is recorded yet (e.g. the ref is a parameter or was minted outside a store). +func (d *Decompiler) findStoreDefiningRef(ref *values.JavaRef) *OpCode { + if ref == nil { + return nil + } + return d.refToCreatingStore[ref] +} + +// slotValueJavaRef unwraps a SlotValue whose underlying value is a *JavaRef (the `iload srcSlot` copy +// pattern `dst = src`). Returns the source ref and ok=false for any other shape. It does NOT use +// UnpackSoltValue (which recurses past the SlotValue to the inner ref); it needs the SlotValue layer +// itself to confirm the value was a slot-load copy rather than an inline ref. +func slotValueJavaRef(v values.JavaValue) (*values.JavaRef, bool) { + sv, ok := v.(*values.SlotValue) + if !ok || sv == nil { + return nil, false + } + inner := sv.GetValue() + if inner == nil { + return nil, false + } + ref, ok := inner.(*values.JavaRef) + return ref, ok +} + // reachingBoolSiblingArmMerge handles the sibling-arm boolean phi that reachingBoolDefaultMerge cannot // (gson SqlTypesSupport.): `boolean b; try { ...; b = true; } catch (...) { b = false; } // USE(b);`. javac compiles both `b = true/false` as `iconst_1/iconst_0; istore S` (int category), and @@ -2688,6 +3354,17 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, runtimeStackSimulation.SetVar(slot, merged) oldRef = merged } + // Boolean variable-copy sibling-arm phi (fastjson2 FieldWriterList.writeList / + // ObjectWriterImplList.writeList): one if/else arm copies an earlier boolean-default variable + // (`itemRefDetect = previous`, previous compiled to iconst_1/0) into the slot, and a SIBLING arm + // stores a genuinely boolean-typed value (`itemRefDetect = jsonWriter.isRefDetect()`). The copy + // arm's int-typed ref and the boolean arm's fresh boolean ref split one variable, and the + // post-merge use renders `int = boolean`. Re-type the copy arm (and its source default) to + // boolean when a phi proves they are one variable. Kill-switch: JDEC_BOOL_VAR_COPY_MERGE_OFF=1. + if merged := d.reachingBoolVarCopyMerge(opcode, slot, oldRef, value); merged != nil { + runtimeStackSimulation.SetVar(slot, merged) + oldRef = merged + } // Sibling-arm boolean phi (gson SqlTypesSupport.): the second try/catch (or if/else) arm // stores an int 0/1 literal onto a reaching def that an earlier sibling arm already made boolean. // Continue that boolean def (coercing the literal to false/true) instead of splitting off an int @@ -2939,6 +3616,13 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, d.slotStoreTrace[slot] = slotStoreTraceInfo{offset: opcode.CurrentOffset, ref: ref} statements.NewAssignStatement(ref, value, isFirst) d.opcodeIdToRef[opcode] = append(d.opcodeIdToRef[opcode], [2]any{ref, isFirst}) + // Record the creator of each freshly-minted ref so the boolean-copy merge can recover it + // deterministically (opcodeIdToRef iteration is unordered and several stores can reuse a ref). + if isFirst && ref != nil { + if _, exists := d.refToCreatingStore[ref]; !exists { + d.refToCreatingStore[ref] = opcode + } + } attr := d.delRefUserAttr[ref.VarUid] attr[1]++ d.delRefUserAttr[ref.VarUid] = attr @@ -3161,6 +3845,9 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, case OP_CHECKCAST: classInfo := d.constantPoolGetter(int(Convert2bytesToInt(opcode.Data))).(*values.JavaClassValue).Type() arg := runtimeStackSimulation.Pop().(values.JavaValue) + // Record the inner arg so phase-2 invoke-arg rebinding can reach the original local-load + // without unpacking the cast CustomValue's closures (fastjson2 JDKUtils:318). + d.checkcastInnerArg[opcode] = arg value := values.NewCustomValue(func(funcCtx *class_context.ClassContext) string { // Wrap the whole cast in parentheses so it keeps the correct precedence // when it becomes the receiver of a member access or method call: without @@ -3193,6 +3880,7 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, funcCallValue.Arguments = append(funcCallValue.Arguments, runtimeStackSimulation.Pop().(values.JavaValue)) } funcCallValue.Arguments = funk.Reverse(funcCallValue.Arguments).([]values.JavaValue) + d.invokeFuncCall[opcode] = funcCallValue if funcCallValue.FuncType.ReturnType.String(funcCtx) != types.NewJavaPrimer(types.JavaVoid).String(funcCtx) { runtimeStackSimulation.Push(funcCallValue) } @@ -3240,6 +3928,7 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, funcCallValue.Arguments = funk.Reverse(funcCallValue.Arguments).([]values.JavaValue) funcCallValue.Object = runtimeStackSimulation.Pop().(values.JavaValue) + d.invokeFuncCall[opcode] = funcCallValue if funcCallValue.FuncType.ReturnType.String(funcCtx) != types.NewJavaPrimer(types.JavaVoid).String(funcCtx) { runtimeStackSimulation.Push(funcCallValue) } @@ -3251,6 +3940,7 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, } funcCallValue.Arguments = funk.Reverse(funcCallValue.Arguments).([]values.JavaValue) funcCallValue.Object = runtimeStackSimulation.Pop().(values.JavaValue) + d.invokeFuncCall[opcode] = funcCallValue if funcCallValue.FuncType.ReturnType.String(funcCtx) != types.NewJavaPrimer(types.JavaVoid).String(funcCtx) { runtimeStackSimulation.Push(funcCallValue) } @@ -3262,6 +3952,7 @@ func (d *Decompiler) calcOpcodeStackInfo(runtimeStackSimulation StackSimulation, } funcCallValue.Arguments = funk.Reverse(funcCallValue.Arguments).([]values.JavaValue) funcCallValue.Object = runtimeStackSimulation.Pop().(values.JavaValue) + d.invokeFuncCall[opcode] = funcCallValue if funcCallValue.FuncType.ReturnType.String(funcCtx) != types.NewJavaPrimer(types.JavaVoid).String(funcCtx) { runtimeStackSimulation.Push(funcCallValue) } @@ -5072,6 +5763,15 @@ func (d *Decompiler) ParseStatement() error { } mapCodeToStackVarIndex := map[*OpCode]int{} //DumpOpcodesToDotExp(d.RootOpCode) + // Two-pass load rebinding for checkcast-wrapped local-loads (phase-1 simulation is complete here, + // opcodeIdToRef fully populated, checkcastInnerArg populated by the phase-1 OP_CHECKCAST handler). + // Rebind the inner SlotValue of each incompatible cast to the branch ref whose type matches the + // cast target, BEFORE phase-2 statement building consumes the cast value. fastjson2 JDKUtils:318. + d.rebindCheckcastInnerArgs() + // Two-pass load rebinding for value-returning invoke receivers/arguments whose local-load bound a + // DFS-stale wrong-type ref at phase-1 time. Runs here (opcodeIdToRef complete) BEFORE phase-2 + // statement building. fastjson2 ObjectReaderBaseModule:793 (var7.getParameters receiver). + d.rebindIncompatibleInvokeArgs() var runCode func(startNode *OpCode) error var parseOpcode func(opcode *OpCode) error parseOpcode = func(opcode *OpCode) error { @@ -5126,6 +5826,12 @@ func (d *Decompiler) ParseStatement() error { if narrow := lazyInitSelfTernaryNarrow(ref, value); narrow != nil { ref.ResetVarType(narrow) } + // Two-pass load rebinding for local-assign sinks: when the stored value is a local-load + // whose resolved type is incompatible with the LHS ref's declared type (var6 = var5 where + // var6 is JSONObject and var5 is Object carrying a JSONObject branch), rebind to the + // branch ref whose type matches the LHS (fastjson2 JSON:82, ObjectReaderImplList:782). + if value = d.rebindIncompatibleLoadForSink(opcode, value, ref.Type()); false { + } assignSt := statements.NewAssignStatement(ref, value, isFirst) appendNode(assignSt) } @@ -5338,7 +6044,11 @@ func (d *Decompiler) ParseStatement() error { if !opcode.SelfOpFolded { index := Convert2bytesToInt(opcode.Data) staticVal := d.constantPoolGetter(int(index)) - appendNode(statements.NewAssignStatement(staticVal, opcode.stackConsumed[0], false)) + value := opcode.stackConsumed[0] + if cm, ok := staticVal.(*values.JavaClassMember); ok && cm != nil { + value = d.rebindIncompatibleLoadForSink(opcode, value, cm.JavaType) + } + appendNode(statements.NewAssignStatement(staticVal, value, false)) } case OP_PUTFIELD: if !opcode.SelfOpFolded { @@ -5346,6 +6056,9 @@ func (d *Decompiler) ParseStatement() error { staticVal := d.constantPoolGetter(int(index)) value := opcode.stackConsumed[0] field := values.NewRefMember(opcode.stackConsumed[1], staticVal.(*values.JavaClassMember).Member, staticVal.(*values.JavaClassMember).JavaType) + if cm, ok := staticVal.(*values.JavaClassMember); ok && cm != nil { + value = d.rebindIncompatibleLoadForSink(opcode, value, cm.JavaType) + } assignSt := statements.NewAssignStatement(field, value, false) appendNode(assignSt) } diff --git a/classparser/decompiler/core/dataflow.go b/classparser/decompiler/core/dataflow.go index 5814730..4b30ff9 100644 --- a/classparser/decompiler/core/dataflow.go +++ b/classparser/decompiler/core/dataflow.go @@ -162,12 +162,16 @@ func (d *Decompiler) slotWebs() *slotWeb { // the web genuinely merges distinct variables (a real phi we must not rewrite). Returns the web's // canonical ref to install, or nil to keep `current`. Gated by JDEC_LIVEINTERVAL_OFF via slotWebs(). func (d *Decompiler) reachingSlotVersionByWeb(load *OpCode, slot int, current *values.JavaRef) *values.JavaRef { - // Opt-in (default OFF). Empirically the web load/store repairs are net-neutral on the unmasked - // per-file metric (fastjson2 iso delta +0) and slightly negative under javac tree masking, so - // they are not enabled by default per the "delta必降才合入" rule. The web ANALYSIS engine - // (computeSlotWebs) remains the load-bearing foundation consumed by later phases; this repair is - // kept behind JDEC_LIVEINTERVAL_WEB for A/B experiments and future corpora. - if os.Getenv("JDEC_LIVEINTERVAL_WEB") == "" { + // Default ON (kill-switch JDEC_LIVEINTERVAL_WEB_OFF). An earlier revision kept this opt-in because + // the web load/store repairs measured net-neutral on the iso per-file metric and slightly negative + // under an older tree-masking run. Re-measured against the current 8-jar tree inventory (the real + // repackage metric), enabling it is a strict improvement: fastjson2 24 -> 22 tree errLines + // (ObjectReaderCreator 3->2, JSONPathParser 2->1) and every other jar unchanged (delta >= 0 across + // the board). The repair only redirects a load to a web-proven SAME-variable definition (same + // VarUid); disjoint live ranges (e.g. try-with-resources `primaryExc`) fall in different webs and + // are left untouched, so it cannot merge genuinely-distinct variables. Kill-switch + // JDEC_LIVEINTERVAL_WEB_OFF restores the opt-in-off behaviour for A/B delta regression checks. + if os.Getenv("JDEC_LIVEINTERVAL_WEB_OFF") != "" { return nil } webs := d.slotWebs() @@ -236,10 +240,9 @@ func (d *Decompiler) reachingStoreOpsByWeb(load *OpCode, slot, loadWeb int, webs // legacy split intact. Only definitions the dataflow proves to be one variable are coalesced. Gated // via slotWebs() (kill-switch JDEC_LIVEINTERVAL_OFF). func (d *Decompiler) reachingSlotStoreContinuationByWeb(store *OpCode, slot int, current *values.JavaRef) *values.JavaRef { - // Opt-in (default OFF); see reachingSlotVersionByWeb for the empirical rationale. The reaching- - // definition store continuation is the principled store-side decision, but on the current corpus - // the legacy reaching* repairs already cover its compile-error wins, so it stays behind the flag. - if os.Getenv("JDEC_LIVEINTERVAL_WEB") == "" { + // Default ON (kill-switch JDEC_LIVEINTERVAL_WEB_OFF); see reachingSlotVersionByWeb for the empirical + // rationale and the current 8-jar tree-inventory A/B that justifies the default flip. + if os.Getenv("JDEC_LIVEINTERVAL_WEB_OFF") != "" { return nil } webs := d.slotWebs() diff --git a/classparser/decompiler/core/statements/java_statements.go b/classparser/decompiler/core/statements/java_statements.go index 824c318..81af20d 100644 --- a/classparser/decompiler/core/statements/java_statements.go +++ b/classparser/decompiler/core/statements/java_statements.go @@ -2220,6 +2220,80 @@ func narrowingInitCast(slotType types.JavaType, valueType types.JavaType) string return "" } +// concreteNumericDeclFQNs is the set of boxed primitive types (Integer/Long/Double/Float/Short/Byte) +// that extend java.lang.Number. A slot first seen storing one of these may be widened to Number when +// a later reassignment stores an incompatible sub-type (fastjson2 ObjectWriterCreatorASM.gwFieldName). +var concreteNumericDeclFQNs = map[string]bool{ + "java.lang.Integer": true, "java.lang.Long": true, "java.lang.Double": true, + "java.lang.Float": true, "java.lang.Short": true, "java.lang.Byte": true, +} + +// numericSlotWiderThan reports whether the slot type (`a`) is a wider boxed-numeric type than the +// initializer type (`b`): specifically a == java.lang.Number AND b is a concrete numeric sub-type. +// This drives `Number varN = Integer.valueOf(...)` declaration rendering when the slot resolved to +// Number (because it is reused for multiple incompatible numeric stores) but the initializer is +// concrete. Mirrors intCategoryWiderThan for the int-category primitive hierarchy. +func numericSlotWiderThan(a types.JavaType, b types.JavaType) bool { + if a == nil || b == nil { + return false + } + af, aok := types.ClassFQNOf(a) + bf, bok := types.ClassFQNOf(b) + if !aok || !bok { + return false + } + if af != "java.lang.Number" { + return false + } + return concreteNumericDeclFQNs[bf] +} + +// refSlotWiderThanLUB reports whether the slot type (`a`) was widened by a sibling-arm merge to a +// LUB that strictly contains the initializer's type (`b`) — i.e. b is-a a but a != b and neither is +// java.lang.Object. This drives `Node varN = commentRef;` declaration rendering when a slot first +// seen storing a concrete type (Comment) is later widened to a cross-class LUB (Node) by an arm +// merge (jsoup XmlTreeBuilder.insert: `Comment var3 = var2; ... var3 = new XmlDeclaration();` — the +// sibling arm stores XmlDeclaration, the slot ref widens to Node, but the first-decl RHS is still +// the Comment-typed var2, so the declaration would render `Comment var3` and the XmlDeclaration +// reassign fails). Object is excluded: an Object widening smashes member-access uses (the §8a +// widen-to-Object read-side regressions), so only genuine non-Object LUB supertypes are adopted. +// +// The widening is gated to a JavaRef RHS (a cross-arm copy of another variable): a method-call / +// new-expression initializer carries the source's declared return type, which a non-Object LUB +// supertype (e.g. Member vs the correct Executable) can widen AWAY from and smash downstream +// member-access receivers (spring ObjectToObjectConverter.getValidatedExecutable regressed when +// widened to the wrong LUB Member). A variable-to-variable copy has no such dependency. +func refSlotWiderThanLUB(funcCtx *class_context.ClassContext, rhs values.JavaValue, a types.JavaType, b types.JavaType) bool { + if a == nil || b == nil { + return false + } + if _, isPrim := a.RawType().(*types.JavaPrimer); isPrim { + return false + } + if _, isPrim := b.RawType().(*types.JavaPrimer); isPrim { + return false + } + af, aok := types.ClassFQNOf(a) + bf, bok := types.ClassFQNOf(b) + if !aok || !bok { + return false + } + if af == bf { + return false // not strictly wider + } + // Object widening smashes member-access receivers (§8a widen-to-Object regressions); exclude. + if af == "java.lang.Object" || bf == "java.lang.Object" { + return false + } + // Only widen when the initializer is a variable read (a cross-arm copy), never a method call / + // new expression, whose declared return type downstream member access depends on. + if _, isRef := values.UnpackSoltValue(rhs).(*values.JavaRef); !isRef { + return false + } + // b must be a strict reference subtype of a (the initializer is the narrow arm, a is the LUB). + return isReferenceAssignable(funcCtx, b, a) +} + func NewReturnStatement(value values.JavaValue) *ReturnStatement { return &ReturnStatement{ JavaValue: value, @@ -2543,6 +2617,31 @@ func (a *AssignStatement) String(funcCtx *class_context.ClassContext) string { if lt := a.LeftValue.Type(); intCategoryWiderThan(lt, declType) { declType = lt } + // When the slot's resolved type is java.lang.Number (or another boxed-numeric supertype) but + // the initializer is a concrete numeric sub-type (Integer/Long/...), declare at the slot type + // so a later incompatible numeric reassignment compiles (fastjson2 ObjectWriterCreatorASM. + // gwFieldName: slot resolved to Number, initializer `Integer.valueOf(0)` → `Integer var11`, + // but a switch-case stores `Long.valueOf(...)` → "Long cannot be converted to Integer"). + // Declaring `Number var11 = Integer.valueOf(0)` is valid: Integer is-a Number. This mirrors + // the int-category widening above but for the boxed-numeric hierarchy. Kill-switch: + // JDEC_NUMERIC_DECL_SLOT_TYPE_OFF=1. + if os.Getenv("JDEC_NUMERIC_DECL_SLOT_TYPE_OFF") == "" { + if lt := a.LeftValue.Type(); numericSlotWiderThan(lt, declType) { + declType = lt + } + } + // When the slot's resolved type is a cross-class LUB widened by a sibling-arm merge (Comment + + // XmlDeclaration → Node) but the initializer is the narrow arm type (Comment), declare at the + // slot type so the later sibling-typed reassign (var3 = new XmlDeclaration()) compiles: declaring + // `Comment var3 = var2` would make the XmlDeclaration reassign fail. Object is excluded (member- + // access receivers); the widening is gated to a variable-copy RHS so a method-call initializer's + // declared return type (which downstream member access depends on) is never widened away. Kill- + // switch: JDEC_REF_SLOT_LUB_DECL_OFF=1. + if os.Getenv("JDEC_REF_SLOT_LUB_DECL_OFF") == "" { + if lt := a.LeftValue.Type(); refSlotWiderThanLUB(funcCtx, a.JavaValue, lt, declType) { + declType = lt + } + } // Narrowing cast for byte/char/short locals: JLS promotes these types to int in any // arithmetic/bitwise/shift expression, so `byte x = (arr[i] ^ crc) & 255` is int-valued at // the source level even though the slot is byte (commons-codec PureJavaCrc32C). When the @@ -2617,6 +2716,17 @@ func (a *AssignStatement) String(funcCtx *class_context.ClassContext) string { if cast := parameterizedLocalReassignRawCast(funcCtx, a.LeftValue, a.JavaValue); cast != "" { return fmt.Sprintf("%s = (%s) (%s)", a.LeftValue.String(funcCtx), cast, a.JavaValue.String(funcCtx)) } + // Ternary with sibling-typed arms assigned to a concrete-typed local: the JVM stored both arms + // into the same slot (no checkcast), but javac requires every arm to be assignable to the + // declared type. When one arm is NOT assignable (e.g. `List var8 = cond ? readArray() : + // readObject()` where readObject returns Map, a sibling of List), wrap that arm in an explicit + // `(TargetType)` cast so the conditional merges at the target type (fastjson2 + // JSONPathSegment$CycleNameSegment.eval). Kill-switch: JDEC_TERNARY_ARM_CAST_OFF=1. + if os.Getenv("JDEC_TERNARY_ARM_CAST_OFF") == "" { + if rendered := ternaryArmIncompatibleCast(funcCtx, a.LeftValue, a.JavaValue); rendered != "" { + return fmt.Sprintf("%s = %s", a.LeftValue.String(funcCtx), rendered) + } + } return assign } } @@ -2686,6 +2796,89 @@ func parameterizedLocalReassignRawCast(funcCtx *class_context.ClassContext, left return erasureName(ltStr) } +// ternaryArmIncompatibleCast re-renders a ternary RHS when one of its arms is NOT assignable to the +// LHS declared type — the JVM stored both arms into the same slot without a checkcast, but javac +// requires every arm of `cond ? A : B` assigned to `T var` to be assignable to T. When an arm is a +// sibling type (e.g. Map when the target is List), wrapping it in `(T)` makes the conditional merge +// at T (fastjson2 JSONPathSegment$CycleNameSegment.eval: `List var8 = cond ? readArray() : +// readObject()` where readObject() returns Map). Returns the re-rendered ternary string (with the +// cast inserted on the incompatible arm), or "" if no cast is needed. Only fires when exactly ONE arm +// is incompatible (both incompatible = a genuine type clash we must not silently paper over). +func ternaryArmIncompatibleCast(funcCtx *class_context.ClassContext, left, value values.JavaValue) string { + if funcCtx == nil || left == nil || value == nil { + return "" + } + tern, ok := values.UnpackSoltValue(value).(*values.TernaryExpression) + if !ok || tern == nil || tern.Condition == nil || tern.TrueValue == nil || tern.FalseValue == nil { + return "" + } + lt := left.Type() + if lt == nil { + return "" + } + if _, isPrim := lt.RawType().(*types.JavaPrimer); isPrim { + return "" + } + ltFQN, ltOK := types.ClassFQNOf(lt) + if !ltOK || ltFQN == "java.lang.Object" { + return "" + } + trueT := values.TernaryArmRValueType(tern.TrueValue) + falseT := values.TernaryArmRValueType(tern.FalseValue) + trueCompat := isReferenceAssignable(funcCtx, trueT, lt) + falseCompat := isReferenceAssignable(funcCtx, falseT, lt) + // Both compatible → no cast needed. Both incompatible → genuine clash, leave alone. + if trueCompat && falseCompat { + return "" + } + if !trueCompat && !falseCompat { + return "" + } + condStr := values.SimplifyConditionValue(tern.Condition).String(funcCtx) + trueStr := tern.TrueValue.String(funcCtx) + falseStr := tern.FalseValue.String(funcCtx) + ltStr := lt.String(funcCtx) + if !trueCompat { + trueStr = fmt.Sprintf("(%s)(%s)", ltStr, trueStr) + } else { + falseStr = fmt.Sprintf("(%s)(%s)", ltStr, falseStr) + } + return fmt.Sprintf("(%s) ? (%s) : (%s)", condStr, trueStr, falseStr) +} + +// isReferenceAssignable reports whether `from` is assignable to `to` in the reference-type hierarchy +// (same type or subtype). Used by ternaryArmIncompatibleCast to decide whether a ternary arm needs a +// cast to the declared target type. Falls back to false on nil/unknown types so unknown arms are left +// untouched (conservative). +func isReferenceAssignable(funcCtx *class_context.ClassContext, from, to types.JavaType) bool { + if from == nil || to == nil { + return false + } + if _, isPrim := from.RawType().(*types.JavaPrimer); isPrim { + return false + } + if _, isPrim := to.RawType().(*types.JavaPrimer); isPrim { + return false + } + fromF, fok := types.ClassFQNOf(from) + toF, tok := types.ClassFQNOf(to) + if !fok || !tok { + return false + } + if fromF == toF { + return true + } + if fromF == "java.lang.Object" { + // Object is assignable to any reference type only via unchecked cast; treat as NOT assignable. + return false + } + var provider types.SuperTypeProvider + if funcCtx != nil { + provider = funcCtx.SiblingSuperTypes + } + return types.IsReferenceSubtypeBridged(fromF, toF, provider) +} + type ForStatement struct { InitVar Statement Condition *ConditionStatement diff --git a/classparser/decompiler/core/values/custom.go b/classparser/decompiler/core/values/custom.go index b158ff0..41095ed 100644 --- a/classparser/decompiler/core/values/custom.go +++ b/classparser/decompiler/core/values/custom.go @@ -9,9 +9,29 @@ import ( type CustomValue struct { Flag string NoOuterCapture bool - StringFunc func(funcCtx *class_context.ClassContext) string - TypeFunc func() types.JavaType - ReplaceFunc func(oldId *utils.VariableId, newId *utils.VariableId) + // IsMethodRef distinguishes a method reference (`Type::method`, `receiver::method`, `Type::new`) + // from an inlined lambda body (`(x) -> ...`). Both carry Flag=="lambda" (so receiver/call-site + // functional-interface cast logic fires for both), but a method reference binds NATURALLY to a + // raw SAM (it has no explicit parameter types to mismatch), whereas an explicitly-typed lambda + // needs the cast to bind. A parameterized FI cast on a method reference is therefore unnecessary + // and, when the target FI's SAM mentions nested wildcards (e.g. Stream.flatMap's + // `Function>`), the cast pins a concrete parameterization + // that defeats javac's poly inference ("method flatMap cannot be applied"). The cast helpers use + // this flag to skip method references (fastjson2 ObjectReaderCreator.toFieldReaderArray + // `flatMap(Collection::stream)`). + IsMethodRef bool + // InstantiatedMtdDesc carries a lambda/method-reference's invokedynamic instantiatedMethodType + // descriptor (3rd LambdaMetafactory bootstrap arg), e.g. "(Ljava/lang/Throwable;[Ljava/lang/StackTraceElement;)V". + // For a method reference passed to a constructor whose formal is a RAW functional interface (raw + // BiConsumer.accept(Object,Object)), the bare method ref fails to bind ("invalid method reference") + // because the SAM arity erases to (Object,Object) while the impl method is (Throwable,StackTraceElement[]). + // The source's `(BiConsumer) Type::method` cast -- recoverable from + // this descriptor -- re-targets the SAM so the method ref binds. Set only on the bootstrap method-ref + // branch; consumed by ctorRawFISAMMethodRefCast (renderArgAt). Empty/unused for lambdas and non-FI uses. + InstantiatedMtdDesc string + StringFunc func(funcCtx *class_context.ClassContext) string + TypeFunc func() types.JavaType + ReplaceFunc func(oldId *utils.VariableId, newId *utils.VariableId) } // ReplaceVar implements JavaValue. diff --git a/classparser/decompiler/core/values/expression.go b/classparser/decompiler/core/values/expression.go index 5fd8323..cc13582 100644 --- a/classparser/decompiler/core/values/expression.go +++ b/classparser/decompiler/core/values/expression.go @@ -968,6 +968,75 @@ func (f *FunctionCallExpression) thisCtorTypeVarArgCast(i int, funcCtx *class_co return paramTypeStr } +// sameClassStaticMethodTypeVarArgCast returns the class-scope type-variable NAME to cast the i-th +// argument of a SAME-CLASS STATIC generic method call to, or "" when no cast is needed. It is the +// static-method analogue of thisCtorTypeVarArgCast: it fires when the callee is a static method +// declared in the CURRENT class (e.g. commons-lang3 `Range.between(T, T, Comparator)` called +// from the instance method `intersectionWith`), the i-th formal is a BARE class-scope type variable +// (`T`, recovered from the recorded method Signature), and the argument's static type is the erased +// bound (typically Object -- a field read at its erased type, or a ternary whose arms are such field +// reads) rather than that type variable. The source passed a `(T)`-cast value (or a value javac +// attributed to T); the bytecode erased T to Object and emitted no checkcast, so the decompiler +// renders a bare argument that javac feeds as Object to the generic `between`, breaking inference +// ("incompatible bounds: T#3 has equal constraint T#1, lower bounds T#1, Object"; Range.intersectionWith +// `between(var2, ..., this.getComparator())` where var2 is a ternary of `this.minimum`/`var1.minimum` +// read at the erased Object field type). Re-emitting the `(T)` unchecked cast makes the argument bind +// to T and inference resolve. The cast is denotable because a same-class static method's class type +// variables are in scope at any instance-method call site of the same class. Kill-switch +// JDEC_SAMECLASS_STATIC_TYPEVAR_ARG_OFF. +func (f *FunctionCallExpression) sameClassStaticMethodTypeVarArgCast(i int, funcCtx *class_context.ClassContext) string { + if os.Getenv("JDEC_SAMECLASS_STATIC_TYPEVAR_ARG_OFF") != "" || funcCtx == nil || !f.IsStatic { + return "" + } + if f.ClassName != funcCtx.ClassName { + return "" + } + // The CALL SITE must be an INSTANCE method: the class-scope type variable T is only denotable there. + // A static enclosing method has no `this`, so its class type variables are out of scope and a `(T)` + // cast fails ("non-static type variable T cannot be referenced from a static context"; guava + // ImmutableSortedMap.access$000 `of((K)(var1),(V)(var2))` -- a synthetic static bridge). + if funcCtx.IsStatic { + return "" + } + if i < 0 || i >= len(f.Arguments) { + return "" + } + sig := funcCtx.MethodSignature(f.FunctionName, len(f.Arguments)) + if sig == "" { + sig = funcCtx.MethodSignatureByDesc(f.FunctionName, f.Descriptor) + } + if sig == "" { + return "" + } + _, params, _ := types.ParseMethodSignatureFull(sig, funcCtx) + if i >= len(params) || params[i] == nil { + return "" + } + paramTypeStr := params[i].String(funcCtx) + if strings.Contains(paramTypeStr, "<") || !funcCtx.IsTypeParam(paramTypeStr) { + return "" + } + arg := f.Arguments[i] + if lit, ok := UnpackSoltValue(arg).(*JavaLiteral); ok && fmt.Sprint(lit.Data) == "null" { + return "" + } + vt := arg.Type() + if vt == nil { + return "" + } + raw := vt.RawType() + if raw == nil { + return "" + } + if _, isPrim := raw.(*types.JavaPrimer); isPrim { + return "" + } + if jc, ok := raw.(*types.JavaClass); !ok || jc.Name != "java.lang.Object" { + return "" + } + return paramTypeStr +} + // comparatorRawArgCast returns the raw `Comparator` cast string for the i-th argument when the call is a // JDK sort/search static (Arrays.sort / Arrays.binarySearch / Collections.sort / Collections.binarySearch) // and the i-th DESCRIPTOR parameter is java.util.Comparator. The array/list companion argument's element @@ -1818,6 +1887,16 @@ func (f *FunctionCallExpression) calleeParamIsErasedTypeVar(i int, funcCtx *clas } internal := strings.ReplaceAll(f.ClassName, ".", "/") classSig, methodSigs, ok := funcCtx.SiblingClassSig(internal) + // For a constructor call (), MethodSignatures deliberately skips , so SiblingClassSig's + // methodSigs will never contain it. Recover the ctor Signature from SiblingCtorSig instead (keyed by + // arity), which reads the Signature attribute directly. This runs even when SiblingClassSig + // returns ok=false (a non-generic class still has generic ctors, e.g. guava Invokable's + // ` Invokable(M)`), so do not bail early on ok=false for . + if f.FunctionName == "" && funcCtx.SiblingCtorSig != nil { + if ctorSig, ctorOk := funcCtx.SiblingCtorSig(internal, len(f.Arguments)); ctorOk && ctorSig != "" { + return f.methodParamIsTypeVar(ctorSig, classSig, i, funcCtx) + } + } if !ok || methodSigs == nil { return false } @@ -1835,6 +1914,18 @@ func (f *FunctionCallExpression) calleeParamIsErasedTypeVar(i int, funcCtx *clas if sig == "" { return false } + return f.methodParamIsTypeVar(sig, classSig, i, funcCtx) +} + +// methodParamIsTypeVar parses a method Signature and reports whether the i-th formal parameter is a +// type variable (a method-scope formal type parameter or a class-scope type parameter of the +// declaring class). A type-variable formal is erased to its bound in the descriptor, so the arg-cast +// logic would synthesize a spurious upcast to the bound that breaks the type variable's inference (e.g. +// an intersection type `` erased to AccessibleObject: casting the +// argument to AccessibleObject loses the Member bound, and javac rejects "constructor X cannot be +// applied"; guava Invokable$MethodInvokable `super((AccessibleObject)(var1))`). Returning true lets the +// caller DROP the cast so the argument's real type drives inference. +func (f *FunctionCallExpression) methodParamIsTypeVar(sig, classSig string, i int, funcCtx *class_context.ClassContext) bool { _, params, _ := types.ParseMethodSignatureFull(sig, funcCtx) if i < 0 || i >= len(params) || params[i] == nil { return false @@ -1843,9 +1934,6 @@ func (f *FunctionCallExpression) calleeParamIsErasedTypeVar(i int, funcCtx *clas if !ok { return false } - // parseSigType emits a `TC;` type-variable reference as JavaClass{Name:"C"}. It is a real type - // variable (not a concrete class that merely shares the name) iff it is declared as a formal type - // parameter of the callee method itself or of its declaring class. name := raw.Name for _, n := range types.MethodFormalTypeParamNames(sig) { if n == name { @@ -1980,7 +2068,13 @@ func (f *FunctionCallExpression) lambdaArgFunctionalCast(i int, funcCtx *class_c if !ok || cv.Flag != "lambda" { return "" } - // (c) the lambda value carries a denotable parameterized functional-interface type. + // A method reference binds NATIVELY to the raw SAM (no explicit parameter types to mismatch), so + // the parameterized-FI cast is unnecessary for it and, for SAMs with nested wildcards, can defeat + // javac poly inference at the call site (same reasoning as lambdaArgRawJDKReceiverCast). Only an + // explicitly-typed lambda body needs the cast. Skip method references. + if cv.IsMethodRef { + return "" + } lamType := cv.Type() if lamType == nil { return "" @@ -2096,6 +2190,15 @@ func (f *FunctionCallExpression) lambdaArgRawJDKReceiverCast(i int, funcCtx *cla if !ok || cv.Flag != "lambda" { return "" } + // A method reference binds NATURALLY to the raw SAM (it has no explicit parameter types), so + // the parameterized-FI cast is unnecessary for it AND, for SAMs with nested wildcards (Stream. + // flatMap's `Function>`), the cast pins a concrete + // parameterization that defeats javac poly inference ("method flatMap cannot be applied"). + // Only an explicitly-typed lambda body needs the cast to bind. Skip method references. + // (fastjson2 ObjectReaderCreator.toFieldReaderArray `flatMap(Collection::stream)`.) + if cv.IsMethodRef { + return "" + } // (b) the lambda value carries a denotable parameterized functional-interface type. lamType := cv.Type() if lamType == nil { @@ -2209,11 +2312,112 @@ func LambdaAssignFunctionalCast(left, right JavaValue, funcCtx *class_context.Cl return lamType.String(funcCtx) } +// ctorRawFISAMMethodRefCast re-adds the parameterized functional-interface cast a METHOD REFERENCE +// argument needs when passed to a constructor (or static method) whose i-th formal is a RAW functional +// interface. A raw `BiConsumer`'s SAM is `accept(Object,Object)`, so an unbound instance method +// reference `Throwable::setStackTrace` (impl arity `(Throwable, StackTraceElement[])`) fails to bind -- +// "invalid method reference" -- because javac matches against the raw (Object,Object) SAM. The source +// carried `(BiConsumer) Throwable::setStackTrace`; recover it from the +// method reference's carried instantiatedMethodType descriptor (3rd LambdaMetafactory bootstrap arg). +// The cast target is `<>`. +// +// Tightly gated: (a) constructor OR static-method call (no instance receiver erasure involved), +// (b) argument is a method reference carrying an instantiatedMethodType descriptor, +// (c) the i-th formal is one of the known raw JDK functional interfaces whose SAM takes >=2 params +// +// (the >=2-param SAMs are where a raw form breaks an unbound instance-method ref's arity), +// +// (d) the recovered concrete param types differ from the raw `Object` SAM params (else the bare ref +// +// already binds and the cast is noise). Kill-switch: JDEC_CTOR_RAWFI_METHODREF_CAST_OFF=1. +// +// Canonical: fastjson2 ObjectReaderCreator `new FieldReaderStackTrace(..., Throwable::setStackTrace)`. +func (f *FunctionCallExpression) ctorRawFISAMMethodRefCast(i int, funcCtx *class_context.ClassContext) string { + if os.Getenv("JDEC_CTOR_RAWFI_METHODREF_CAST_OFF") != "" || i >= len(f.FuncType.ParamTypes) { + return "" + } + // (a) only a constructor or static call: an instance-method receiver erasure is already covered by + // the raw-receiver cast helpers, and mixing both would double-cast. + if !f.IsStatic && f.FunctionName != "" { + return "" + } + // (b) argument is a method reference carrying an instantiatedMethodType descriptor. + cv, ok := UnpackSoltValue(f.Arguments[i]).(*CustomValue) + if !ok || !cv.IsMethodRef || cv.InstantiatedMtdDesc == "" { + return "" + } + // (c) the i-th formal is a known raw JDK functional interface whose SAM arity is >= 2 (the case + // where the raw form breaks an unbound instance-method reference). Single-param SAMs (Supplier / + // Function / Predicate / Consumer) bind their one parameter from the impl method directly and do + // not need this cast. + paramType := f.FuncType.ParamTypes[i] + if paramType == nil { + return "" + } + pj, ok := paramType.RawType().(*types.JavaClass) + if !ok || pj == nil { + return "" + } + if !rawFIMethodRefCastFamily[pj.Name] { + return "" + } + // Recover the concrete parameter types from the instantiatedMethodType descriptor and render the + // cast target `<>`. Require at least one param more specific than + // java.lang.Object (else the bare ref binds and the cast is noise). + mt, err := types.ParseMethodDescriptor(cv.InstantiatedMtdDesc) + if err != nil || mt == nil { + return "" + } + ft := mt.FunctionType() + if ft == nil || len(ft.ParamTypes) < 2 { + return "" + } + hasSpecific := false + parts := make([]string, 0, len(ft.ParamTypes)) + for _, pt := range ft.ParamTypes { + if pt == nil { + return "" + } + s := pt.String(funcCtx) + if s == "" { + return "" + } + parts = append(parts, s) + if jc, ok := pt.RawType().(*types.JavaClass); !ok || jc == nil || jc.Name != "java.lang.Object" { + hasSpecific = true + } + } + if !hasSpecific { + return "" + } + return funcCtx.ShortTypeName(pj.Name) + "<" + strings.Join(parts, ", ") + ">" +} + +// rawFIMethodRefCastFamily lists the JDK functional interfaces whose raw SAM arity is >= 2, so a raw +// form breaks an unbound instance-method reference (whose impl arity includes the receiver). Single- +// param SAMs bind directly and are excluded. BiConsumer / BiFunction / BiPredicate. +var rawFIMethodRefCastFamily = map[string]bool{ + "java.util.function.BiConsumer": true, + "java.util.function.BiFunction": true, + "java.util.function.BiPredicate": true, +} + // renderArgAt renders the i-th call argument, applying the generic-erasure parameter-type recovery and // the synthesized argument cast (`(V)`/`(T)`/primitive) that reproduce the original source. Factored // out of ArgumentStrings so the varargs-spread path can reuse it for the leading fixed arguments. func (f *FunctionCallExpression) renderArgAt(i int, funcCtx *class_context.ClassContext) string { arg := f.Arguments[i] + // A METHOD REFERENCE passed to a constructor whose i-th formal is a RAW functional interface + // (raw BiConsumer.accept(Object,Object) etc.) fails to bind ("invalid method reference"): the + // impl method's arity (e.g. Throwable.setStackTrace(StackTraceElement[]) is 1-arg as an UNBOUND + // instance ref -> (Throwable, StackTraceElement[])) does not match the raw SAM's (Object,Object). + // The source carried `(FI) Type::method`; recover it from the method ref's carried + // instantiatedMethodType. Must precede the lambda/raw-receiver cast helpers (which all no-op on a + // method reference anyway, but ordering keeps the method-ref path explicit). fastjson2 + // ObjectReaderCreator `new FieldReaderStackTrace(..., Throwable::setStackTrace)`. + if cast := f.ctorRawFISAMMethodRefCast(i, funcCtx); cast != "" { + return fmt.Sprintf("(%s)(%s)", cast, arg.String(funcCtx)) + } // A lambda / method-ref passed to a method on a RAW generic receiver lost the source's // `(Consumer) e -> ...` functional-interface cast (raw receiver erases the SAM). Re-add it. if cast := f.lambdaArgFunctionalCast(i, funcCtx); cast != "" { @@ -2258,6 +2462,14 @@ func (f *FunctionCallExpression) renderArgAt(i int, funcCtx *class_context.Class if cast := f.superCtorTypeVarArgCast(i, funcCtx); cast != "" { return fmt.Sprintf("(%s)(%s)", cast, arg.String(funcCtx)) } + // A same-class STATIC generic method's argument feeding a bare class-scope type-variable formal + // (`T`) lost the source's `(T)` cast to erasure (the value was read at the erased Object field type, + // e.g. a ternary of `this.minimum`/`other.minimum`); re-emit it so javac binds the argument to T + // instead of Object and the method's type inference resolves (commons-lang3 Range.intersectionWith + // `between((T) var2, (T) ..., this.getComparator())`). + if cast := f.sameClassStaticMethodTypeVarArgCast(i, funcCtx); cast != "" { + return fmt.Sprintf("(%s)(%s)", cast, arg.String(funcCtx)) + } argType := f.FuncType.ParamTypes[i] // Recover the generic parameter type the descriptor erased (e.g. BiConsumer.accept's // param erases to Object): the source carried a `(V)` cast on the argument, so feed the @@ -2401,6 +2613,28 @@ func (f *FunctionCallExpression) renderArgAt(i int, funcCtx *class_context.Class }) } } + // A `null` literal argument fed to an OVERLOADED same-arity method whose other overload takes a + // different reference type at this slot is ambiguous to javac (null is assignable to both Object and + // String, so `m(Field,null,boolean)` resolves to both `m(Field,Object,boolean)` and + // `m(Object,String,boolean)` — neither is more specific). The bytecode descriptor pins the EXACT + // chosen formal type; casting `null` to that formal forces javac to pick the bytecode-selected + // overload (the cast type is no longer assignable to the OTHER overload's formal). Re-emit the + // source's `(FormalType) null` (commons-lang3 FieldUtils.readStaticField → readField(field,(Object) + // null,forceAccess); writeStaticField/writeDeclaredStaticField → writeField(...,(Object) null,...)). + // GATED on genuine overload ambiguity (HasOverloadedSameArity) so a NON-overloaded method's `null` + // argument is NOT cast — an ungated `(Object) null` smashes javac's type-variable inference on + // generic calls and changes overload selection (fastjson2 JSONReader.readBytes regression). The + // formal is the bytecode-pinned type so behaviour is preserved. Kill-switch: JDEC_NULL_ARG_CAST_OFF=1. + if os.Getenv("JDEC_NULL_ARG_CAST_OFF") == "" { + if castType := f.nullArgDisambiguationCast(i, argType, arg, funcCtx); castType != "" { + argStr := arg.String(funcCtx) + arg = NewCustomValue(func(funcCtx *class_context.ClassContext) string { + return fmt.Sprintf("(%s)(%s)", castType, argStr) + }, func() types.JavaType { + return argType + }) + } + } return renderPlainArg(arg, funcCtx) } @@ -2416,6 +2650,39 @@ func renderPlainArg(arg JavaValue, funcCtx *class_context.ClassContext) string { return argStr } +// nullArgDisambiguationCast reports the cast type to apply to a `null` literal argument at position i so +// javac's overload resolution picks the bytecode-selected method. Returns "" when no cast is wanted. +// `argType` is the already-resolved i-th formal parameter type (FuncType.ParamTypes[i], possibly refined +// by the generic resolvers above). The cast is emitted ONLY when ALL of: +// - the argument is a bare `null` literal; +// - the formal is a reference type (class/array; primitives never receive null in valid bytecode); +// - the callee genuinely has another same-arity overload with a DIFFERENT descriptor (real ambiguity). +// +// The last gate (HasOverloadedSameArity) is what makes this safe: a non-overloaded method's `null` arg is +// left bare so javac's type-variable inference is untouched (ungated `(Object) null` smashes generic +// calls — fastjson2 JSONReader.readBytes regressed). For Object the cast is `(Object) null`, which +// re-selects the Object-formal overload over a String/sibling overload since `(Object) null` is not +// assignable to String. The formal is the bytecode-pinned type so behaviour is preserved. +func (f *FunctionCallExpression) nullArgDisambiguationCast(i int, argType types.JavaType, arg JavaValue, funcCtx *class_context.ClassContext) string { + if argType == nil { + return "" + } + if !IsNullLiteral(UnpackSoltValue(arg)) { + return "" + } + // Only reference formals (class/array) — primitives never receive null in valid bytecode. + if _, isPrim := argType.RawType().(*types.JavaPrimer); isPrim { + return "" + } + // Gate on genuine overload ambiguity: only cast when the callee has another same-arity overload with a + // different descriptor. This keeps non-overloaded (and generic-inference-sensitive) calls' null args + // bare, so javac's type-variable inference is never smashed. + if funcCtx == nil || !funcCtx.HasOverloadedSameArity(f.FunctionName, f.Descriptor) { + return "" + } + return argType.String(&class_context.ClassContext{}) +} + // varargsTypeVarSpread detects the javac varargs-call idiom on a generic method whose varargs COMPONENT // is a TYPE VARIABLE, returning the array literal's element values to spread plus the count of leading // fixed (non-varargs) arguments. The bytecode for `m(a, b)` to ` R m(T... xs)` materializes a fresh @@ -2623,10 +2890,60 @@ func (f *FunctionCallExpression) renderCall(funcCtx *class_context.ClassContext) case *JavaExpression, *TernaryExpression, *SlotValue: return fmt.Sprintf("(%s).%s(%s)", f.Object.String(funcCtx), functionName, strings.Join(paramStrs, ",")) default: + // A member access on a java.lang.Object-typed local whose bytecode invoke target is a + // DIFFERENT concrete reference type (the slot was null-initialized as Object but the JVM store + // path feeds a String/typed value; the null-init's Object declaration never adopted the concrete + // type because the slot is reused across disjoint variables). javac resolves the member against + // Object and rejects it ("cannot find symbol: method length(), location: variable var24_1 of type + // Object"); the bytecode invoke's target class is the REAL receiver type. Cast the receiver to the + // invoke target class so the member resolves (commons-lang3 StrSubstitutor.substitute + // `var24_1.length()` where the bytecode is `aload 24; invokevirtual String.length`). The cast + // mirrors what javac would have synthesized for a checkcast the source carried (or the declared + // String type the slot should have had). Gated: receiver must be a plain Object-typed local ref, + // target class a concrete non-Object/non-array reference, and the method non-. Kill-switch: + // JDEC_OBJECT_RECV_INVOKE_CAST_OFF=1. + if os.Getenv("JDEC_OBJECT_RECV_INVOKE_CAST_OFF") == "" { + if castCls := f.objectReceiverInvokeCast(funcCtx); castCls != "" { + return fmt.Sprintf("((%s)(%s)).%s(%s)", castCls, f.Object.String(funcCtx), functionName, strings.Join(paramStrs, ",")) + } + } return fmt.Sprintf("%s.%s(%s)", f.Object.String(funcCtx), functionName, strings.Join(paramStrs, ",")) } } +// objectReceiverInvokeCast reports the cast class to wrap an Object-typed invoke receiver in, when the +// bytecode invoke target (f.ClassName) is a concrete reference type distinct from java.lang.Object. This +// repairs member-access sites where the receiver local was null-initialized as Object (and never adopted +// a concrete type due to slot reuse), but the bytecode invoke proves the real value is the target class. +// Returns "" when no cast is wanted. See renderCall. +func (f *FunctionCallExpression) objectReceiverInvokeCast(funcCtx *class_context.ClassContext) string { + if f.FunctionName == "" || f.ClassName == "" { + return "" + } + // The receiver must be a plain local variable read (a JavaRef), not an expression/ternary/call. + ref, ok := UnpackSoltValue(f.Object).(*JavaRef) + if !ok || ref == nil || ref.IsThis || ref.IsParam { + return "" + } + // Only when the receiver's STATIC type is exactly java.lang.Object. + rt := ref.Type() + if rt == nil { + return "" + } + if jc, ok := rt.RawType().(*types.JavaClass); !ok || jc == nil || jc.Name != "java.lang.Object" { + return "" + } + // The invoke target class must be a concrete reference type distinct from Object (and not an array, + // whose member is .length which is field-access not invoke). + if f.ClassName == "java.lang.Object" { + return "" + } + if strings.HasPrefix(f.ClassName, "[") { + return "" + } + return f.ClassName +} + func coerceBooleanArgument(arg JavaValue) JavaValue { switch v := UnpackSoltValue(arg).(type) { case *JavaLiteral: diff --git a/classparser/decompiler/parser.go b/classparser/decompiler/parser.go index cb3df89..291a352 100644 --- a/classparser/decompiler/parser.go +++ b/classparser/decompiler/parser.go @@ -134,6 +134,14 @@ func ParseBytesCode(decompiler *core.Decompiler) (res []statements.Statement, er } } rewriter.RewriteVar(&sts, decompiler.BodyStartId, params, decompiler.FunctionContext) + // Post-RewriteVar per-VarUid instanceof-widen with Object-safe gate. Kill-switch: + // JDEC_POST_RW_INSTANCEOF_WIDEN_OFF=1. + rewriter.WidenInstanceofReadRefs(&sts) + // Widen a concrete numeric-typed declaration to java.lang.Number when its slot is reused for an + // incompatible numeric sub-type and every read is Number-safe (fastjson2 + // ObjectWriterCreatorASM.gwFieldName: Integer slot reused for Long stores, read via + // .intValue()/.longValue()). Kill-switch: JDEC_WIDEN_NUMERIC_MIXED_OFF=1. + rewriter.WidenNumericMixedSlotDecl(&sts) // Retype a split-slot foreach/copy temp that lost its ARRAY type to java.lang.Object because the // copy source's concrete type was adopted only AFTER this copy in DFS order (frozen-Object stale // snapshot). Runs after RewriteVar unifies ids and all adoptions commit, so the source reports its diff --git a/classparser/decompiler/rewriter/rewrite_var.go b/classparser/decompiler/rewriter/rewrite_var.go index 80d4b21..6ad650b 100644 --- a/classparser/decompiler/rewriter/rewrite_var.go +++ b/classparser/decompiler/rewriter/rewrite_var.go @@ -191,6 +191,9 @@ func RewriteVar(sts *[]statements.Statement, startVarId int, params []*values.Ja if os.Getenv("JDEC_NULLINIT_NARROW_OFF") != "1" { narrowNullInitObjectDecl(sts) } + if os.Getenv("JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF") != "1" { + widenConcreteDeclToObject(sts) + } } // dropDuplicateDeclarations removes redundant bare `T x;` declarations that name a *VariableId @@ -246,6 +249,24 @@ func collectEmbeddedDeclInfos(sts []statements.Statement, byID map[*utils.Variab byName[name] = append(byName[name], embeddedDeclInfo{id: ref.Id, typeStr: ts}) } } + // A catch parameter (`catch(T varN)`) is also a declaration: it scopes a name to the catch + // body. Without collecting it, an embedded assignment to a same-named slot-derived local in a + // DIFFERENT try (fastjson2 JDKUtils : `var31 = Class.class` in the try, `catch(Throwable + // var31)` in a sibling try) would not see the catch param as an incompatible collider, so + // SynthesizeUndeclaredEmbeddedAssignDecls would skip synthesizing its declaration. + if tc, ok := st.(*statements.TryCatchStatement); ok && tc != nil { + for _, exc := range tc.Exception { + if exc != nil && exc.Id != nil { + byID[exc.Id] = struct{}{} + name := exc.String(hoistProbeCtx) + ts := "" + if t := exc.Type(); t != nil { + ts = t.String(hoistProbeCtx) + } + byName[name] = append(byName[name], embeddedDeclInfo{id: exc.Id, typeStr: ts}) + } + } + } for _, cl := range childStatementLists(st) { collectEmbeddedDeclInfos(*cl, byID, byName) } @@ -493,7 +514,26 @@ func replayUnambiguousRebindings(sts *[]statements.Statement, allReplace map[*ut break } } - if !unique || newId == nil || newId == oldId { + if !unique { + if os.Getenv("JDEC_ORPHAN_REBIND_NAMEEQ_OFF") == "" { + newName := newId.String() + nameEq := newId != nil + for _, t := range targets[1:] { + if t == nil || t.String() != newName { + nameEq = false + break + } + } + if nameEq && newId != oldId { + for _, st := range *sts { + st.ReplaceVar(oldId, newId) + } + core.TraceRewriteVar(className, methodName, "orphan-rebind replay (name-equivalent) old=%s new=%s", oldId.String(), newId.String()) + } + } + continue + } + if newId == nil || newId == oldId { continue } for _, st := range *sts { @@ -2989,3 +3029,246 @@ func narrowNullInitObjectDecl(sts *[]statements.Statement) { *sts = append(prepend, *sts...) } } + +// widenConcreteDeclToObject is the inverse of narrowNullInitObjectDecl. Kill-switch: +// JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF=1. +func widenConcreteDeclToObject(sts *[]statements.Statement) { + if sts == nil || len(*sts) == 0 { + return + } + type concreteInfo struct { + declRefs []*values.JavaRef + hasObjectRe bool + } + infos := map[string]*concreteInfo{} + objectNames := map[string]struct{}{} + var collect func([]statements.Statement) + collect = func(list []statements.Statement) { + for _, st := range list { + if as, ok := st.(*statements.AssignStatement); ok && as.ArrayMember == nil { + if ref, ok2 := core.UnpackSoltValue(as.LeftValue).(*values.JavaRef); ok2 && ref != nil && ref.Id != nil && !ref.IsThis && !ref.IsParam { + name := ref.String(hoistProbeCtx) + if generatedLocalNameRe.MatchString(name) { + in := infos[name] + if in == nil { + in = &concreteInfo{} + infos[name] = in + } + switch { + case as.IsFirst || as.IsDeclare: + rt := ref.Type() + if rt != nil { + if isJavaLangObject(rt) { + objectNames[name] = struct{}{} + } else if _, isPrim := rt.RawType().(*types.JavaPrimer); !isPrim { + in.declRefs = append(in.declRefs, ref) + } + } + default: + if as.JavaValue != nil && !values.IsNullLiteral(as.JavaValue) { + if rt := as.JavaValue.Type(); rt != nil && isJavaLangObject(rt) { + in.hasObjectRe = true + } + } + } + } + } + } + for _, cl := range childStatementLists(st) { + collect(*cl) + } + } + } + collect(*sts) + anyCandidate := false + for _, in := range infos { + if in.hasObjectRe && len(in.declRefs) > 0 { + anyCandidate = true + break + } + } + if !anyCandidate { + return + } + var sb strings.Builder + for _, st := range *sts { + t, ok := safeRenderStatement(st) + if !ok { + return + } + sb.WriteString(t) + sb.WriteByte('\n') + } + methodText := sb.String() + names := make([]string, 0, len(infos)) + for n := range infos { + names = append(names, n) + } + sort.Strings(names) + for _, name := range names { + in := infos[name] + if !in.hasObjectRe || len(in.declRefs) == 0 { + continue + } + if !nameOccurrencesAreObjectSafe(methodText, name, objectNames) { + continue + } + objT := types.NewJavaClass("java.lang.Object") + for _, ref := range in.declRefs { + ref.ResetVarType(objT) + } + } +} + +func nameOccurrencesAreObjectSafe(text, name string, objectNames map[string]struct{}) bool { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(text, -1) + if len(locs) == 0 { + return false + } + lhsNameRe := regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*=\s*$`) + for _, loc := range locs { + before := text[:loc[0]] + after := text[loc[1]:] + if nullInitNarrowAssign.MatchString(after) { + continue + } + at := strings.TrimLeft(after, " \t") + if (strings.HasPrefix(at, ";") || strings.HasPrefix(at, "\n") || strings.HasPrefix(at, "=")) && typeTokenPrecedes(before) { + continue + } + if leadingWordIs(after, "instanceof") { + continue + } + if trailingWordIs(before, "return") || trailingWordIs(before, "throw") { + continue + } + rt := strings.TrimRight(before, " \t") + if strings.HasSuffix(rt, "(") { + inner := strings.TrimRight(rt[:len(rt)-1], " \t") + if strings.HasSuffix(inner, ")") { + continue + } + } + if strings.HasSuffix(rt, ")") { + continue + } + if m := lhsNameRe.FindStringSubmatch(before); m != nil { + if _, ok := objectNames[m[1]]; ok { + continue + } + } + // Accept `name` as a bare argument to an Object-accepting method (`.add(name)`, + // `.addAll(name)`, `.put(key,name)`, `.set(idx,name)`, `.offer(name)`, `.push(name)`). + // These methods accept Object, so widening the variable's type is safe. + if isObjectArgToWhitelistMethod(before, after) { + continue + } + return false + } + return true +} + +// objectArgMethodWhitelist lists method names whose parameter accepts java.lang.Object, so passing a +// widened-to-Object variable as an argument is behavior-preserving. Used by the instanceof-widen +// Object-safe gate to accept `receiver.add(name)`, `receiver.addAll(name)`, etc. +var objectArgMethodWhitelist = map[string]bool{ + "add": true, "addAll": true, "put": true, "set": true, + "offer": true, "push": true, "addElement": true, "addFirst": true, + "addLast": true, +} + +// isObjectArgToWhitelistMethod reports whether `name` (at the boundary of before/after) is passed +// as an argument to a whitelisted Object-accepting method: the text before name ends with +// `.methodName(` and the text after name starts with `)` or `,`. +func isObjectArgToWhitelistMethod(before, after string) bool { + afterTrim := strings.TrimLeft(after, " \t") + if !strings.HasPrefix(afterTrim, ")") && !strings.HasPrefix(afterTrim, ",") { + return false + } + beforeTrim := strings.TrimRight(before, " \t") + if !strings.HasSuffix(beforeTrim, "(") { + return false + } + // Extract the method name: the identifier immediately before the `(`. + inner := strings.TrimRight(beforeTrim[:len(beforeTrim)-1], " \t") + methodName := "" + for i := len(inner) - 1; i >= 0; i-- { + c := inner[i] + if isJavaIdentByte(c) { + methodName = string(c) + methodName + } else { + break + } + } + return objectArgMethodWhitelist[methodName] +} + +func leadingWordIs(s, w string) bool { + trimmed := strings.TrimLeft(s, " \t") + if !strings.HasPrefix(trimmed, w) { + return false + } + rest := trimmed[len(w):] + return rest == "" || !isJavaIdentByte(rest[0]) +} + +func trailingWordIs(s, w string) bool { + trimmed := strings.TrimRight(s, " \t") + if !strings.HasSuffix(trimmed, w) { + return false + } + prefix := trimmed[:len(trimmed)-len(w)] + return prefix == "" || !isJavaIdentByte(prefix[len(prefix)-1]) +} + +func isJavaIdentByte(b byte) bool { + return b == '_' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '$' +} + +func typeTokenPrecedes(before string) bool { + trimmed := strings.TrimRight(before, " \t") + if trimmed == "" { + return false + } + last := "" + for i := len(trimmed) - 1; i >= 0; i-- { + c := trimmed[i] + if isJavaIdentByte(c) || c == '.' || c == '<' || c == '>' { + last = string(c) + last + continue + } + break + } + if last == "" { + return false + } + simple := last + if i := strings.LastIndexByte(simple, '>'); i >= 0 { + if j := strings.LastIndexByte(simple[:i], '<'); j >= 0 { + simple = simple[:j] + } + } + for strings.HasSuffix(simple, "[]") { + simple = simple[:len(simple)-2] + } + if simple == "" { + return false + } + if i := strings.LastIndexByte(simple, '.'); i >= 0 { + simple = simple[i+1:] + } + if simple == "" { + return false + } + c := simple[0] + if !(c == '_' || c == '$' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + return false + } + for i := 0; i < len(simple); i++ { + if !isJavaIdentByte(simple[i]) { + return false + } + } + return true +} diff --git a/classparser/decompiler/rewriter/widen_instanceof.go b/classparser/decompiler/rewriter/widen_instanceof.go new file mode 100644 index 0000000..328d611 --- /dev/null +++ b/classparser/decompiler/rewriter/widen_instanceof.go @@ -0,0 +1,453 @@ +package rewriter + +import ( + "os" + "regexp" + "strings" + + "github.com/yaklang/javajive/classparser/decompiler/core" + "github.com/yaklang/javajive/classparser/decompiler/core/class_context" + "github.com/yaklang/javajive/classparser/decompiler/core/statements" + "github.com/yaklang/javajive/classparser/decompiler/core/values" + "github.com/yaklang/javajive/classparser/decompiler/core/values/types" +) + +// WidenNumericMixedSlotDecl is a post-RewriteVar pass. Kill-switch: +// JDEC_WIDEN_NUMERIC_MIXED_OFF=1. +// +// It widens a concrete numeric-typed declaration (Integer/Long/Double/...) to java.lang.Number when +// that slot is ALSO assigned an incompatible numeric sub-type value (e.g. `Integer var11 = ...` with +// a later `var11 = Long.valueOf(...)`), as long as every read of the variable is Number-safe +// (.intValue()/.longValue()/.doubleValue(), instanceof, cast, assignment target, return, or +// Object-accepting method arg). This is the slot-reuse repair for fastjson2 +// ObjectWriterCreatorASM.gwFieldName: the JVM reuses one slot for an Integer initializer and several +// Long switch-case stores, but the post-switch read uses `.intValue()` / `.longValue()` (Number +// methods in the bytecode). Declaring the variable as Number compiles cleanly while preserving the +// method-dispatch semantics. See isNumberSafeWiden for the read-side gate. +func WidenNumericMixedSlotDecl(sts *[]statements.Statement) { + if sts == nil || len(*sts) == 0 || os.Getenv("JDEC_WIDEN_NUMERIC_MIXED_OFF") == "1" { + return + } + ctx := &class_context.ClassContext{} + objectNames := map[string]struct{}{} + // Collect (uid -> declRef) for every concrete-numeric-typed declaration. Keyed by VarUid (not + // rendered name) because multiple distinct variables in the same method can share a name after + // RewriteVar unifies a slot's disjoint live ranges under the same name token (fastjson2 + // ObjectWriterCreatorASM.gwFieldName has several `var11`-named variables of different types). + uidToDeclRef := map[string]*values.JavaRef{} + var collectNumericDecls func(list []statements.Statement) + collectNumericDecls = func(list []statements.Statement) { + for _, st := range list { + if as, ok := st.(*statements.AssignStatement); ok && (as.IsFirst || as.IsDeclare) && as.ArrayMember == nil { + if ref, ok2 := core.UnpackSoltValue(as.LeftValue).(*values.JavaRef); ok2 && ref != nil { + if ref.Type() != nil && isConcreteNumericType(ref.Type()) { + if _, exists := uidToDeclRef[ref.VarUid]; !exists { + uidToDeclRef[ref.VarUid] = ref + } + } + if ref.Type() != nil && isJavaLangObject(ref.Type()) { + objectNames[ref.String(ctx)] = struct{}{} + } + } + } + for _, cl := range childStatementLists(st) { + collectNumericDecls(*cl) + } + } + } + collectNumericDecls(*sts) + // reassignment carrying an incompatible numeric sub-type. Key by VarUid (identity) not name. + mixedUids := map[string]struct{}{} + var scan func(list []statements.Statement) + scan = func(list []statements.Statement) { + for _, st := range list { + if as, ok := st.(*statements.AssignStatement); ok && !as.IsFirst && !as.IsDeclare && as.ArrayMember == nil { + if ref, ok2 := core.UnpackSoltValue(as.LeftValue).(*values.JavaRef); ok2 && ref != nil { + declRef := uidToDeclRef[ref.VarUid] + if declRef == nil || declRef.Type() == nil { + goto recurse + } + if !isConcreteNumericType(declRef.Type()) { + goto recurse + } + if as.JavaValue == nil { + goto recurse + } + rhsT := as.JavaValue.Type() + if rhsT == nil { + goto recurse + } + if !isConcreteNumericType(rhsT) { + goto recurse + } + if typeFQNEquals(declRef.Type(), rhsT) { + goto recurse + } + mixedUids[ref.VarUid] = struct{}{} + } + } + recurse: + for _, cl := range childStatementLists(st) { + scan(*cl) + } + } + } + scan(*sts) + if len(mixedUids) == 0 { + return + } + // Render the full method text once for the gate checks. + var sb strings.Builder + for _, st := range *sts { + t, ok := safeRenderStatement(st) + if !ok { + return + } + sb.WriteString(t) + sb.WriteByte('\n') + } + methodText := sb.String() + numberT := types.NewJavaClass("java.lang.Number") + for uid := range mixedUids { + declRef := uidToDeclRef[uid] + if declRef == nil { + continue + } + name := declRef.String(ctx) + if !isNumberSafeWiden(methodText, name, objectNames) { + continue + } + widenRefByVarUid(*sts, uid, numberT) + } +} + +// WidenInstanceofReadRefs is a post-RewriteVar pass. Kill-switch: +// JDEC_POST_RW_INSTANCEOF_WIDEN_OFF=1. +func WidenInstanceofReadRefs(sts *[]statements.Statement) { + if sts == nil || len(*sts) == 0 || os.Getenv("JDEC_POST_RW_INSTANCEOF_WIDEN_OFF") == "1" { + return + } + ctx := &class_context.ClassContext{} + nameToDeclRef := map[string]*values.JavaRef{} + objectNames := map[string]struct{}{} + collectDeclRefs(*sts, ctx, nameToDeclRef, objectNames) + var sb strings.Builder + for _, st := range *sts { + t, ok := safeRenderStatement(st) + if !ok { + return + } + sb.WriteString(t) + sb.WriteByte('\n') + } + methodText := sb.String() + instanceofRe := regexp.MustCompile(`\b(var\d+(?:_\d+)?)\s+instanceof\s+(\w+)`) + widened := map[string]struct{}{} + objT := types.NewJavaClass("java.lang.Object") + // Scan the FULL method text (not individual conditions) for instanceof patterns. This avoids + // the statement-tree traversal issue where nested switch-case/do-while conditions may not be + // individually accessible as IfStatement/ConditionStatement at the top level. + for _, m := range instanceofRe.FindAllStringSubmatch(methodText, -1) { + varName := m[1] + if _, done := widened[varName]; done { + continue + } + declRef := nameToDeclRef[varName] + if declRef == nil || declRef.Type() == nil || declRef.IsThis || declRef.IsParam { + continue + } + if _, isPrim := declRef.Type().RawType().(*types.JavaPrimer); isPrim { + continue + } + fqn, ok := types.ClassFQNOf(declRef.Type()) + if !ok || fqn == "java.lang.Object" { + continue + } + if !nameOccurrencesAreObjectSafe(methodText, varName, objectNames) { + // Object-safe gate failed (some use is a bare member access like `.longValue()`). + // Try widening to java.lang.Number instead: Number supports `instanceof Integer`/`instanceof + // Long` AND `.longValue()` / `.intValue()` / etc. This handles the JSONPathParser:664 case + // where `var10` (Long) is read by `instanceof Integer` + `.longValue()`. + if !isNumberSafeWiden(methodText, varName, objectNames) { + continue + } + numberT := types.NewJavaClass("java.lang.Number") + widened[varName] = struct{}{} + widenRefByVarUid(*sts, declRef.VarUid, numberT) + continue + } + widened[varName] = struct{}{} + widenRefByVarUid(*sts, declRef.VarUid, objT) + } +} + +func collectDeclRefs(list []statements.Statement, ctx *class_context.ClassContext, + out map[string]*values.JavaRef, objectNames map[string]struct{}) { + for _, st := range list { + if as, ok := st.(*statements.AssignStatement); ok && (as.IsFirst || as.IsDeclare) && as.ArrayMember == nil { + if ref, ok2 := core.UnpackSoltValue(as.LeftValue).(*values.JavaRef); ok2 && ref != nil { + name := ref.String(ctx) + if _, exists := out[name]; !exists { + out[name] = ref + } + if ref.Type() != nil && isJavaLangObject(ref.Type()) { + objectNames[name] = struct{}{} + } + } + } + for _, cl := range childStatementLists(st) { + collectDeclRefs(*cl, ctx, out, objectNames) + } + } +} + +func scanAndWiden(list []statements.Statement, ctx *class_context.ClassContext, re *regexp.Regexp, + nameToDeclRef map[string]*values.JavaRef, methodText string, objectNames map[string]struct{}, + widened map[string]struct{}, objT types.JavaType) { + for _, st := range list { + condText := "" + switch s := st.(type) { + case *statements.ConditionStatement: + if s.Condition != nil { + condText = s.Condition.String(ctx) + } + case *statements.IfStatement: + if s.Condition != nil { + condText = s.Condition.String(ctx) + } + } + if condText != "" { + for _, m := range re.FindAllStringSubmatch(condText, -1) { + varName := m[1] + if _, done := widened[varName]; done { + continue + } + declRef := nameToDeclRef[varName] + if declRef == nil || declRef.Type() == nil || declRef.IsThis || declRef.IsParam { + continue + } + if _, isPrim := declRef.Type().RawType().(*types.JavaPrimer); isPrim { + continue + } + fqn, ok := types.ClassFQNOf(declRef.Type()) + if !ok || fqn == "java.lang.Object" { + continue + } + if !nameOccurrencesAreObjectSafe(methodText, varName, objectNames) { + continue + } + widened[varName] = struct{}{} + widenRefByVarUid(list, declRef.VarUid, objT) + } + } + for _, cl := range childStatementLists(st) { + scanAndWiden(*cl, ctx, re, nameToDeclRef, methodText, objectNames, widened, objT) + } + } +} + +func widenRefByVarUid(list []statements.Statement, uid string, target types.JavaType) { + for _, st := range list { + for _, v := range stmtDirectValues(st) { + widenRefsInValue(v, uid, target) + } + for _, cl := range childStatementLists(st) { + widenRefByVarUid(*cl, uid, target) + } + } +} + +func widenRefsInValue(v values.JavaValue, uid string, target types.JavaType) { + if v == nil { + return + } + switch t := v.(type) { + case *values.JavaRef: + if t.VarUid == uid && t.Type() != nil { + fqn, ok := types.ClassFQNOf(t.Type()) + if ok && fqn != "java.lang.Object" { + t.ResetVarType(target) + } + } + case *values.SlotValue: + widenRefsInValue(t.GetValue(), uid, target) + case *values.TernaryExpression: + widenRefsInValue(t.Condition, uid, target) + widenRefsInValue(t.TrueValue, uid, target) + widenRefsInValue(t.FalseValue, uid, target) + case *values.FunctionCallExpression: + widenRefsInValue(t.Object, uid, target) + for _, a := range t.Arguments { + widenRefsInValue(a, uid, target) + } + case *values.JavaCompare: + widenRefsInValue(t.JavaValue1, uid, target) + widenRefsInValue(t.JavaValue2, uid, target) + case *values.JavaArrayMember: + widenRefsInValue(t.Object, uid, target) + widenRefsInValue(t.Index, uid, target) + case *values.JavaExpression: + for _, e := range t.Values { + widenRefsInValue(e, uid, target) + } + case *values.NewExpression: + for _, a := range t.Length { + widenRefsInValue(a, uid, target) + } + for _, a := range t.Initializer { + widenRefsInValue(a, uid, target) + } + } +} + +func stmtDirectValues(st statements.Statement) []values.JavaValue { + if st == nil { + return nil + } + var out []values.JavaValue + switch s := st.(type) { + case *statements.AssignStatement: + out = append(out, s.LeftValue) + if s.JavaValue != nil { + out = append(out, s.JavaValue) + } + case *statements.ConditionStatement: + if s.Condition != nil { + out = append(out, s.Condition) + } + case *statements.IfStatement: + if s.Condition != nil { + out = append(out, s.Condition) + } + case *statements.ReturnStatement: + if s.JavaValue != nil { + out = append(out, s.JavaValue) + } + case *statements.ExpressionStatement: + if s.Expression != nil { + out = append(out, s.Expression) + } + } + return out +} + +// numberMethodWhitelist lists methods that exist on java.lang.Number AND are commonly used on Long/ +// Integer/BigInteger locals. Widening to Number preserves these method calls. +var numberMethodWhitelist = map[string]bool{ + "longValue": true, "intValue": true, "doubleValue": true, + "floatValue": true, "shortValue": true, "byteValue": true, +} + +// isNumberSafeWiden reports whether every textual occurrence of `name` in `text` is safe under a +// java.lang.Number declaration: assignment target, instanceof, cast-wrapped, return/throw, bare +// argument to an Object-accepting method (add/addAll/etc.), OR a member access to a Number method +// (longValue/intValue/etc.). This is the Number-specific gate used when Object-safe fails (because +// the variable has `.longValue()` member access that Object doesn't support but Number does). +func isNumberSafeWiden(text, name string, objectNames map[string]struct{}) bool { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(text, -1) + if len(locs) == 0 { + return false + } + lhsNameRe := regexp.MustCompile(`\b([A-Za-z_$][\w$]*)\s*=\s*$`) + for _, loc := range locs { + before := text[:loc[0]] + after := text[loc[1]:] + // Assignment target. + if nullInitNarrowAssign.MatchString(after) { + continue + } + // Declaration. + at := strings.TrimLeft(after, " \t") + if (strings.HasPrefix(at, ";") || strings.HasPrefix(at, "\n") || strings.HasPrefix(at, "=")) && typeTokenPrecedes(before) { + continue + } + // instanceof. + if leadingWordIs(after, "instanceof") { + continue + } + // return/throw. + if trailingWordIs(before, "return") || trailingWordIs(before, "throw") { + continue + } + // Cast-wrapped receiver. + rt := strings.TrimRight(before, " \t") + if strings.HasSuffix(rt, "(") { + inner := strings.TrimRight(rt[:len(rt)-1], " \t") + if strings.HasSuffix(inner, ")") { + continue + } + } + if strings.HasSuffix(rt, ")") { + continue + } + // RHS into Object-typed local (accept only if LHS name is also in objectNames). + if m := lhsNameRe.FindStringSubmatch(before); m != nil { + if _, ok := objectNames[m[1]]; ok { + continue + } + } + // Object-accepting method arg. + if isObjectArgToWhitelistMethod(before, after) { + continue + } + // Number method member access: `.longValue()`, `.intValue()`, etc. + if isNumberMethodAccess(after) { + continue + } + return false + } + return true +} + +// isNumberMethodAccess reports whether `after` (text following a name occurrence) starts with +// `.methodName(` where methodName is a Number method (longValue, intValue, etc.). +func isNumberMethodAccess(after string) bool { + at := strings.TrimLeft(after, " \t") + if !strings.HasPrefix(at, ".") { + return false + } + // Extract the method name after the dot. + rest := at[1:] + methodName := "" + for i := 0; i < len(rest); i++ { + c := rest[i] + if isJavaIdentByte(c) { + methodName += string(c) + } else { + break + } + } + return numberMethodWhitelist[methodName] +} + +// concreteNumericFQNs lists the boxed primitive types that extend java.lang.Number. A local declared +// as one of these may need widening to Number when its slot is reused for a different boxed numeric +// sub-type (fastjson2 ObjectWriterCreatorASM.gwFieldName: Integer slot reused for Long stores). +var concreteNumericFQNs = map[string]bool{ + "java.lang.Integer": true, "java.lang.Long": true, "java.lang.Double": true, + "java.lang.Float": true, "java.lang.Short": true, "java.lang.Byte": true, +} + +// isConcreteNumericType reports whether t is a boxed numeric type (Integer/Long/Double/Float/Short/ +// Byte) — the set of concrete sub-types of java.lang.Number that the JVM may reuse a slot across. +func isConcreteNumericType(t types.JavaType) bool { + if t == nil { + return false + } + fqn, ok := types.ClassFQNOf(t) + if !ok { + return false + } + return concreteNumericFQNs[fqn] +} + +// typeFQNEquals reports whether two JavaTypes resolve to the same fully-qualified class name. +func typeFQNEquals(a, b types.JavaType) bool { + af, aok := types.ClassFQNOf(a) + bf, bok := types.ClassFQNOf(b) + if !aok || !bok { + return false + } + return af == bf +} diff --git a/classparser/dumper.go b/classparser/dumper.go index c16dd70..df0fe56 100644 --- a/classparser/dumper.go +++ b/classparser/dumper.go @@ -954,6 +954,10 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { // a same-class call recover its erased argument cast via its exact descriptor. Kill-switch // JDEC_SAMECLASS_DESC_SIG_OFF disables population (so the consumer degrades to the arity path). methodSignaturesByDesc := map[string]string{} + // All-method (name, descriptor) presence set (regardless of Signature), so a renderer can detect + // genuine overload ambiguity (a second same-arity overload with a different descriptor). Populated + // for every non-/non- method. Kill-switch consumer: JDEC_NULL_ARG_CAST_OFF. + methodDescriptors := map[string]bool{} recordByDesc := os.Getenv("JDEC_SAMECLASS_DESC_SIG_OFF") == "" for _, m := range c.obj.Methods { name, err := c.obj.getUtf8(m.NameIndex) @@ -969,6 +973,7 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { if err != nil || descriptor == "" { continue } + methodDescriptors[class_context.MethodDescKey(name, descriptor)] = true for _, attr := range m.Attributes { sigAttr, ok := attr.(*SignatureAttribute) if !ok { @@ -995,6 +1000,9 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { if len(methodSignaturesByDesc) > 0 { c.FuncCtx.MethodSignaturesByDesc = methodSignaturesByDesc } + if len(methodDescriptors) > 0 { + c.FuncCtx.MethodDescriptors = methodDescriptors + } // Augment with DIRECT-supertype (inherited) generic method signatures so a `this.m(objVal)` // call to an inherited generic method recovers its erased `(K)` argument cast too. Same-class // signatures (already in methodSignatures/methodSigSeen) always win. Kill-switch @@ -1205,6 +1213,53 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { // ordinal()])` back to the idiomatic `switch(sel){ case CONST: ... }`. No-op without a resolver // or when JDEC_NO_ENUM_SWITCH_FOLD is set; produces valid Java, so it runs after assembly. full = c.foldEnumSwitchMaps(full) + // fixLambdaLoopCapture runs FIRST: it inserts final-copies for loop-reassigned captured vars + // and rewrites lambda-body reads to the copy. After this, the original variable is no longer + // read inside the lambda, so initProximateSplitSlotDecl (next) can safely default-initialize it + // (the lambda-capture skip won't fire for a var whose reads were already rewritten to the copy). + // Kill-switch: JDEC_LAMBDA_LOOP_CAPTURE_COPY_OFF=1. + full = fixLambdaLoopCapture(full) + // Run the split-slot definite-assignment init on the FULL class text (all methods merged). + // This overcomes the chunked-sourceCode limitation where the per-method init couldn't see + // assignments in other method blocks. Kill-switch: JDEC_INIT_PROX_SPLIT_OFF=1. + full = initProximateSplitSlotDecl(full) + // removeUnreachableSwitchBreak drops `break;` after a nested switch whose default throws/returns. + // Kill-switch: JDEC_RM_UNREACHABLE_BREAK_OFF=1. + full = removeUnreachableSwitchBreak(full) + // fixMissingReturn inserts `return null;` after a no-op empty-if (`if(cond){};`) that follows a + // switch-closing `}`, when the enclosing block does not otherwise terminate (the CFA-targeted + // pattern that produces "missing return statement" in deep switch/if chains). Kill-switch: + // JDEC_FIX_MISSING_RETURN_OFF=1. + full = fixMissingReturn(full) + // fixEmptySwitchDefault inserts a terminator (throw) into `switch` default labels whose body is + // empty, which otherwise leaves a value-returning method without a return on that path. + // Kill-switch: JDEC_FIX_EMPTY_SWITCH_DEFAULT_OFF=1. + full = fixEmptySwitchDefault(full) + // Fix try/catch structuring: move exception-throwing calls that are rendered outside a + // try block INTO the nearest inner try body. Kill-switch: JDEC_FIX_TRYCATCH_OFF=1. + full = fixTryCatchExceptionPlacement(full) + // addMissingCatchException performs per-call-site exception-flow analysis: for each reflection + // call site (getConstructor etc.) it walks the enclosing try/catch chain and only augments the + // nearest catch with NoSuchMethodException if no enclosing catch already handles it (or a + // supertype). Kill-switch: JDEC_ADD_MISSING_CATCH_OFF=1. + full = addMissingCatchException(full) + // dedupNestedCatchException removes exception types from an outer catch that are already caught + // by a nested try/catch in the try body (javac: "exception X is never thrown in body of + // corresponding try statement"). Kill-switch: JDEC_DEDUP_NESTED_CATCH_OFF=1. + full = dedupNestedCatchException(full) + // wrapUncaughtThrowingCall wraps `UNSAFE.allocateInstance()` in try/catch. Kill-switch: + // JDEC_WRAP_ALLOCATE_OFF=1. + full = wrapUncaughtThrowingCall(full) + // wrapReflectionCallInSwitchCase wraps switch-case reflection calls in try/catch. Kill-switch: + // JDEC_WRAP_REFLECTION_CASE_OFF=1. + full = wrapReflectionCallInSwitchCase(full) + // addBreakToSwitchCases inserts `break;` for fall-through switch cases. Kill-switch: + // JDEC_ADD_SWITCH_BREAK_OFF=1. + full = addBreakToSwitchCases(full) + // wrapFieldInitializerReflection converts a field initializer containing a reflection call + // (getMethod etc.) that throws a checked exception into a static-block init with try/catch. + // Kill-switch: JDEC_WRAP_FIELD_INIT_OFF=1. + full = wrapFieldInitializerReflection(full) return full, nil } @@ -1864,14 +1919,26 @@ func (c *ClassObjectDumper) buildSiblingCtorSig() func(internalName string, argc continue } sigStr, err := sObj.getUtf8(sigAttr.SignatureIndex) - if err != nil || sigStr == "" || !strings.HasPrefix(sigStr, "(") { + if err != nil || sigStr == "" { + continue + } + // A method Signature may start with `` (method-scope type parameters) + // before the `(...)` parameter list, or directly with `(`. Accept both: a + // super-constructor whose formal is a METHOD-scope type variable (e.g. guava + // Invokable's ` Invokable(M)`) has a + // Signature starting with `` prefix is + // stripped before counting params. + _, sigParams, _ := types.ParseMethodSignatureFull(sigStr, nil) if len(sigParams) != descArgc { continue } @@ -3885,6 +3952,2767 @@ func addMissingGeneratedLocalDecls(body, params, receiverType string, methodRetu return "\n" + strings.Join(lines, "") + strings.TrimPrefix(body, "\n") } +// initProximateSplitSlotDecl initializes bare `Type varN;` declarations (no initializer) to +// `Type varN = null;` when a dead-store `Object varM;` sibling exists within a small line window +// (proximity gate). This repairs the definite-assignment error from a split slot (String in the +// if-branch, Object in the else-branch) WITHOUT attempting a structural variable merge. +// +// The proximity gate (≤ maxLines apart) is the key safety mechanism: it ensures the Object +// dead-store is in the SAME if/else block as the bare declaration, not in an unrelated part of the +// method. Canonical case: fastjson2 JSON.copyTo — `String var16;` (line 4351) + `Object var17;` +// (line 4358, 7 lines apart). Kill-switch: JDEC_INIT_PROX_SPLIT_OFF=1. +func initProximateSplitSlotDecl(body string) string { + if os.Getenv("JDEC_INIT_PROX_SPLIT_OFF") == "1" { + return body + } + dbg := os.Getenv("JDEC_INIT_PROX_SPLIT_DBG") == "1" + const maxLines = 10 // max line distance between bare decl and Object dead-store sibling + lines := strings.Split(body, "\n") + // Pre-collect all Object dead-store declaration line numbers. + type objDecl struct { + name string + line int + } + var objDecls []objDecl + for i, ln := range lines { + // Match `Object varM;` at any indentation. + m := objDeclLineRe.FindStringSubmatch(strings.TrimRight(ln, "\r")) + if m != nil { + varM := m[1] + // Check if varM is a dead store (never read in the full body). + if !hasReadInBody(body, varM) { + objDecls = append(objDecls, objDecl{name: varM, line: i}) + } + } + } + // Do NOT early-return when there are no Object dead stores: the primitive/reference gates + // below (and the method-scoped capture check) handle bare slot-split declarations with no + // Object sibling. Returning here would leave them uninitialized. + if dbg { + fmt.Fprintf(os.Stderr, "[PROX] %d Object dead stores\n", len(objDecls)) + } + // For each bare `Type varN;` declaration (var and lv scoped locals), decide whether to default- + // initialize it to repair a definite-assignment error. + bareDeclRe := regexp.MustCompile(`^(\t+)([A-Za-z_$][\w$.<>\[\]?, ]*?)\s+((?:var|lv)\d+(?:_\d+)*)\s*(=\s*null\s*)?;(\s*)$`) + for i, ln := range lines { + lnClean := strings.TrimRight(ln, "\r") + m := bareDeclRe.FindStringSubmatch(lnClean) + if m == nil { + continue + } + indent := m[1] + declType := strings.TrimSpace(m[2]) + varN := m[3] + // m[4] is the optional `= null` group, m[5] is the trailing whitespace. + trailing := m[5] + // Skip Java keywords that look like type tokens (throw, return, new, etc.). + switch declType { + case "throw", "return", "new", "if", "else", "for", "while", "do", "switch", "case", + "break", "continue", "try", "catch", "finally", "synchronized", "assert": + continue + } + // Determine the initializer value based on type. + initVal := "null" + isPrimitive := false + switch declType { + case "int", "long", "short", "byte": + initVal = "0" + isPrimitive = true + case "double": + initVal = "0.0" + isPrimitive = true + case "float": + initVal = "0.0F" + isPrimitive = true + case "char": + initVal = "'\\0'" + isPrimitive = true + case "boolean": + initVal = "false" + isPrimitive = true + } + // varN must be read somewhere (has a non-assignment use), within the same method. + if !hasReadInBodyMethod(lines, varN, i) { + continue + } + // Lambda-capture safety: if varN is READ inside a lambda body that is DEEPER than this + // declaration's scope AND it is assigned ≥1 time elsewhere, initializing it would create a + // second assignment (the init + the reassignment), breaking the capture ("local variables + // referenced from a lambda expression must be final or effectively final"). Skip — the + // final-copy pass (fixLambdaLoopCapture) handles loop-reassigned captures separately. + // Method-local to avoid cross-method false captures from varN name reuse. + if countAssignTargetsMethod(lines, varN, i) >= 1 && nameCapturedByLambdaMethod(lines, varN, i) { + // The variable is captured by a lambda and assigned ≥1 time. If the declaration has an + // `= null` initializer, removing it makes the variable effectively-final (only the later + // reassignment remains), which allows the lambda capture to compile. This is safe when + // countAssignTargetsMethod == 1 (single reassignment after the null init). + if trailing == "" && strings.Contains(lnClean, "= null;") && countAssignTargetsMethod(lines, varN, i) == 1 { + if dbg { + fmt.Fprintf(os.Stderr, "[PROX] STRIP null-init %s %s: captured + single reassign → effectively-final\n", declType, varN) + } + lines[i] = indent + declType + " " + varN + ";" + continue + } + if dbg { + fmt.Fprintf(os.Stderr, "[PROX] SKIP %s %s: captured by deeper lambda + assigned\n", declType, varN) + } + continue + } + // Gate: the declaration should be initialized only when it is NOT definitely assigned on all + // paths before its first read. + // (a) Proximity: an Object dead-store sibling is nearby (the split-slot signature), OR + // (b) the bare decl is immediately followed by an if/else chain that LACKS a final else + // (one branch may not assign → not definitely assigned — exclude the bare `Object` + // widened-split form whose bare shape is intentional under JDEC_REF_SLOT_NULL_REASSIGN_MERGE_OFF), OR + // (c) a primitive assigned ≥1 time and read (multi-branch chain, primitives are safe), OR + // (d) a reference type assigned ≥2 times (multi-branch chain). + found := false + for _, od := range objDecls { + if abs(i-od.line) <= maxLines { + found = true + break + } + } + // `Object` bare declarations: when JDEC_REF_SLOT_NULL_REASSIGN_MERGE_OFF is set, the + // null-reassign split intentionally leaves a widened-split `Object varN;` whose bare shape is + // load-bearing (the merge is proven load-bearing by that bare form). Do not initialize Object + // decls in that mode. Otherwise (default), Object bare decls that are conditionally assigned + // and read are real DA errors and must be initialized. + skipObject := declType == "Object" && os.Getenv("JDEC_REF_SLOT_NULL_REASSIGN_MERGE_OFF") == "1" + if !found && !skipObject && chainLacksFinalElse(lines, i, varN) { + found = true + } + if !found && isPrimitive && countAssignTargetsMethod(lines, varN, i) >= 1 { + found = true + } + // Reference types: a single conditional assignment (inside an `if`/`try`) read later, or a + // use-before-def reorder artifact, both leave the variable possibly-uninitialized. The + // lambda-capture skip above prevents breaking finality. + if !found && !isPrimitive && !skipObject && countAssignTargetsMethod(lines, varN, i) >= 1 { + found = true + } + if !found { + continue + } + // Initialize: `Type varN;` → `Type varN = ;` + if dbg { + fmt.Fprintf(os.Stderr, "[PROX] INIT %s %s = %s; (line=%d)\n", declType, varN, initVal, i) + } + lines[i] = indent + declType + " " + varN + " = " + initVal + ";" + trailing + } + return strings.Join(lines, "\n") +} + +var objDeclLineRe = regexp.MustCompile(`^\t+Object\s+(var\d+(?:_\d+)?)\s*;`) + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} + +// hasReadInBody reports whether name appears as a READ (not declaration, not assignment target) +// anywhere in body. +func hasReadInBody(body, name string) bool { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(body, -1) + for _, loc := range locs { + before := body[:loc[0]] + after := body[loc[1]:] + // Skip declarations: preceded by a type token on the same line. + lineStart := strings.LastIndex(before, "\n") + 1 + lineBefore := strings.TrimSpace(before[lineStart:]) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + // A declaration is preceded by a type token ending in an identifier char, ], >, or ?. + // NOT ')' — that would match casts like `(Type) (varN)`. + // But a control keyword (return/throw/if/...) ending in a word char is NOT a type — + // `return varN`, `throw varN`, `if (varN` are READS. + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + continue + } + } + // Skip assignment targets: followed by `=` but not `==`. + afterTrim := strings.TrimLeft(after, " \t") + if strings.HasPrefix(afterTrim, "=") && !strings.HasPrefix(afterTrim, "==") { + continue + } + return true + } + return false +} + +func isWordByteDump(b byte) bool { + return b == '_' || b == '$' || (b >= '0' && b <= '9') || + (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + +// prevTokenIsControlKeyword reports whether the last whitespace-stripped token before a name use +// is a Java keyword that takes an EXPRESSION (not a type). Such uses (return x, throw x, if (x), +// while (x), switch (x), assert x, synchronized (x)) are READS of x, not declarations — even though +// the keyword ends in a word char that would otherwise be mistaken for a type token. +func prevTokenIsControlKeyword(lineBefore string) bool { + tok := "" + for i := len(lineBefore) - 1; i >= 0; i-- { + c := lineBefore[i] + if isWordByteDump(c) { + tok = string(c) + tok + } else { + break + } + } + switch tok { + case "return", "throw", "if", "else", "while", "for", "do", "switch", "case", + "break", "continue", "try", "catch", "finally", "synchronized", "assert", "new": + return true + } + return false +} + +// lambdaDepthPerLine computes, for each line, the number of LAMBDA bodies (blocks introduced by +// `-> {`) currently open. This is NOT general brace depth — only `-> {` lambda blocks count. +func lambdaDepthPerLine(lines []string) []int { + depths := make([]int, len(lines)) + depth := 0 + var lambdaStarts []int + for i, raw := range lines { + ln := strings.TrimRight(raw, "\r") + arrowBraceIdxs := map[int]bool{} + for k := 0; k+1 < len(ln); k++ { + if ln[k] == '-' && ln[k+1] == '>' { + for m := k + 2; m < len(ln); m++ { + if ln[m] == '{' { + arrowBraceIdxs[m] = true + break + } + if ln[m] != ' ' && ln[m] != '\t' { + break + } + } + } + } + for j := 0; j < len(ln); j++ { + switch ln[j] { + case '{': + depth++ + if arrowBraceIdxs[j] { + lambdaStarts = append(lambdaStarts, depth) + } + case '}': + depth-- + if depth < 0 { + depth = 0 + } + for len(lambdaStarts) > 0 && depth < lambdaStarts[len(lambdaStarts)-1] { + lambdaStarts = lambdaStarts[:len(lambdaStarts)-1] + } + } + } + depths[i] = len(lambdaStarts) + } + return depths +} + +// methodOrInitBlockStart reports whether ln opens a method/ctor body OR a static/instance +// initializer block (`static {` or a bare `{` at class-member indent). These are the brace-scoped +// regions that contain local variable declarations. +var methodOrInitBlockRe = regexp.MustCompile(`^\t+(?:public|protected|private|static|final|synchronized|abstract|native|default|[\w$<>\[\].,? ]+)\s+\w+\s*\([^;]*\)\s*(?:throws[^{]*)?\{`) + +// methodBodyRange returns the [startLineIdx, endLineIdx) range of the method/ctor/init-block body +// containing lineIdx, by scanning backward for an opening line and forward for its closing brace. +func methodBodyRange(lines []string, lineIdx int) (int, int) { + if lineIdx < 0 || lineIdx >= len(lines) { + return -1, -1 + } + start := -1 + for i := lineIdx; i >= 0; i-- { + ln := strings.TrimRight(lines[i], "\r") + if isMethodOrInitBlockStart(ln) { + start = i + break + } + } + if start < 0 { + return -1, -1 + } + depth := 0 + for i := start; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + for j := 0; j < len(ln); j++ { + switch ln[j] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return start, i + 1 + } + } + } + } + return start, len(lines) +} + +// ctorOrMethodRe matches a constructor or method signature (ClassName/void + params + `{`, with +// optional modifiers), used when methodOrInitBlockRe (which requires a return-type token) misses +// constructors (which have no separate return type). +var ctorOrMethodRe = regexp.MustCompile(`^\t+(?:(?:public|protected|private)\s+)?\w[\w$]*(?:<[^>]*>)?\s*\([^;]*\)\s*(?:throws[^{]*)?\{`) + +// isMethodOrInitBlockStart reports whether ln opens a method/ctor body OR a static/instance +// initializer block. +func isMethodOrInitBlockStart(ln string) bool { + if methodOrInitBlockRe.MatchString(ln) { + return true + } + trim := strings.TrimSpace(ln) + // static initializer: `static {` (with any spacing). + compact := strings.Join(strings.Fields(trim), "") + if compact == "static{" { + return true + } + // Constructor or void method without a separate return-type token: `(...){`. + if ctorOrMethodRe.MatchString(ln) { + first := trim + if sp := strings.IndexAny(first, " \t("); sp > 0 { + first = first[:sp] + } + switch first { + case "if", "while", "for", "switch", "catch", "synchronized", "try", "do", "else", "return": + return false + } + return true + } + return false +} + +// body DEEPER than the declaration's lambda depth, within the enclosing method body only. +func nameCapturedByLambdaMethod(lines []string, name string, declLineIdx int) bool { + ms, me := methodBodyRange(lines, declLineIdx) + if ms < 0 { + return false + } + sub := lines[ms:me] + depths := lambdaDepthPerLine(sub) + subDeclIdx := declLineIdx - ms + if subDeclIdx < 0 || subDeclIdx >= len(depths) { + return false + } + declDepth := depths[subDeclIdx] + nameRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + for i, raw := range sub { + ln := strings.TrimRight(raw, "\r") + if depths[i] <= declDepth { + continue + } + for _, loc := range nameRe.FindAllStringIndex(ln, -1) { + before := ln[:loc[0]] + after := ln[loc[1]:] + bTrim := strings.TrimRight(before, " \t") + aTrim := strings.TrimLeft(after, " \t") + if strings.Contains(after, "->") && + len(bTrim) > 0 && (bTrim[len(bTrim)-1] == '(' || bTrim[len(bTrim)-1] == ',') && + (len(aTrim) > 0 && (aTrim[0] == ')' || aTrim[0] == ',')) { + continue + } + lineBefore := strings.TrimSpace(before) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + continue + } + } + if strings.HasPrefix(aTrim, "=") && !strings.HasPrefix(aTrim, "==") { + continue + } + return true + } + } + return false +} + +// countAssignTargetsMethod counts assignment targets for name within the enclosing method body. +func countAssignTargetsMethod(lines []string, name string, declLineIdx int) int { + ms, me := methodBodyRange(lines, declLineIdx) + if ms < 0 { + return countAssignTargets(strings.Join(lines, "\n"), name) + } + return countAssignTargets(strings.Join(lines[ms:me], "\n"), name) +} + +// hasReadInBodyMethod reports whether name appears as a READ within the enclosing method body. +func hasReadInBodyMethod(lines []string, name string, declLineIdx int) bool { + ms, me := methodBodyRange(lines, declLineIdx) + if ms < 0 { + return hasReadInBody(strings.Join(lines, "\n"), name) + } + return hasReadInBody(strings.Join(lines[ms:me], "\n"), name) +} + +// chainLacksFinalElse reports whether the bare declaration at declIdx is immediately followed by an +// if/else chain whose deepest else has NO final-else block — a path on which varN is never assigned +// (would trigger "might not have been initialized"). Handles the dumper's nested `}else{ if(){} }` +// rendering. While descending, if the else body assigns varN unconditionally before any inner if, +// the chain has full coverage (returns false). +func chainLacksFinalElse(lines []string, declIdx int, varN string) bool { + if declIdx < 0 || declIdx+1 >= len(lines) { + return false + } + declIndent := leadingTabs(strings.TrimRight(lines[declIdx], "\r")) + if declIndent == "" { + return false + } + ifLine := -1 + for j := declIdx + 1; j < len(lines); j++ { + ln := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(ln) == "" { + continue + } + trim := strings.TrimSpace(ln) + if strings.HasPrefix(trim, "if ") || strings.HasPrefix(trim, "if(") { + ifLine = j + break + } + ind := leadingTabs(ln) + if len(ind) <= len(declIndent) { + return false + } + } + if ifLine < 0 { + return false + } + cur := ifLine + for cur < len(lines) { + ifIndent := leadingTabs(strings.TrimRight(lines[cur], "\r")) + depth := 0 + blockEnd := -1 + for k := cur; k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + for b := 0; b < len(ln); b++ { + switch ln[b] { + case '{': + depth++ + case '}': + depth-- + } + } + if depth <= 0 && k > cur { + blockEnd = k + break + } + } + if blockEnd < 0 { + return false + } + elseLine := -1 + for k := blockEnd + 1; k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(ln) == "" { + continue + } + elseLine = k + break + } + if elseLine < 0 { + return true + } + elseLn := strings.TrimRight(lines[elseLine], "\r") + elseTrim := strings.TrimSpace(elseLn) + elseInd := leadingTabs(elseLn) + if !(strings.HasPrefix(elseTrim, "}else{") || strings.HasPrefix(elseTrim, "} else {") || + strings.HasPrefix(elseTrim, "}else ") || strings.HasPrefix(elseTrim, "} else")) { + if len(elseInd) <= len(ifIndent) { + return true + } + return false + } + hasInnerIf := false + varNAssignedBeforeInnerIf := false + elseDepth := 0 + started := false + for k := elseLine; k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + depthBeforeLine := elseDepth + for b := 0; b < len(ln); b++ { + switch ln[b] { + case '{': + elseDepth++ + started = true + case '}': + elseDepth-- + } + } + trim := strings.TrimSpace(ln) + if started && depthBeforeLine > 0 && (strings.HasPrefix(trim, "if ") || strings.HasPrefix(trim, "if(")) { + hasInnerIf = true + cur = k + break + } + if started && depthBeforeLine > 0 && assignTargetIs(ln, varN) { + varNAssignedBeforeInnerIf = true + } + if started && elseDepth <= 0 && k > elseLine { + break + } + } + if varNAssignedBeforeInnerIf { + return false + } + if !hasInnerIf { + return false + } + } + return false +} + +// leadingTabs returns the leading tab run of ln (the indentation). +func leadingTabs(ln string) string { + i := 0 + for i < len(ln) && ln[i] == '\t' { + i++ + } + return ln[:i] +} + +// assignTargetIs reports whether ln assigns to name as a target (`name =` but not `==`), where name +// is a standalone word not preceded by a type/declaration token. +func assignTargetIs(ln, name string) bool { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(ln, -1) + for _, loc := range locs { + after := ln[loc[1]:] + aTrim := strings.TrimLeft(after, " \t") + if !strings.HasPrefix(aTrim, "=") || strings.HasPrefix(aTrim, "==") { + continue + } + before := ln[:loc[0]] + lineBefore := strings.TrimSpace(before) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + continue + } + } + return true + } + return false +} + +// fixLambdaLoopCapture repairs the "local variables referenced from a lambda expression must be final +// or effectively final" error that arises when a method-level variable is reassigned each loop +// iteration (e.g. inside a do/while) AND read inside an enclosing lambda body. Such a variable is +// genuinely not effectively-final, so default-initialization cannot fix it. +// +// Fix: for each capturing `-> {` lambda, immediately before the lambda insert a final copy +// +// final _f = ; +// +// and rewrite READS of inside that lambda body to _f. The copy is effectively-final +// (single assignment), so the capture is legal; each loop iteration captures a fresh value. +// +// Detection is method-local (varN name reuse across methods) and depth-aware (only names read at a +// STRICTLY DEEPER lambda depth than the declaration are captures). Kill-switch: +// JDEC_LAMBDA_LOOP_CAPTURE_COPY_OFF=1. +// +// NOTE: chained-call lambdas (e.g. `flux.concatMap(x -> {...}).doOnTerminate(...)`) have the +// `-> {` mid-statement, so inserting a copy before that line would land inside the statement body. +// Such lambdas are skipped (only non-chained `receiver(args, x -> {...})` forms are handled). +// Kill-switch: JDEC_LAMBDA_LOOP_CAPTURE_COPY_OFF=1. +func fixLambdaLoopCapture(body string) string { + if os.Getenv("JDEC_LAMBDA_LOOP_CAPTURE_COPY_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // declRe matches a local variable declaration (with or without initializer) to recover the + // declared type for the final copy. Captures both bare `Type varN;` and `Type varN = init;`. + declRe := regexp.MustCompile(`^(\t+)([A-Za-z_$][\w$.<>\[\]?, ]*?)\s+((?:var|lv)\d+(?:_\d+)*)\s*(?:=[^;]*)?;(\s*)$`) + // We process per-method so varN reuse across methods does not conflate. Collect method ranges. + type methodRange struct{ start, end int } + var methods []methodRange + { + i := 0 + for i < len(lines) { + ln := strings.TrimRight(lines[i], "\r") + if isMethodOrInitBlockStart(ln) { + if os.Getenv("JDEC_FLC_DBG") == "1" && strings.Contains(ln, "isExtendedMap") { + fmt.Fprintf(os.Stderr, "[FLC] found method at L%d: %q\n", i+1, strings.TrimSpace(ln)) + } + depth := 0 + end := len(lines) + for k := i; k < len(lines); k++ { + lk := strings.TrimRight(lines[k], "\r") + for b := 0; b < len(lk); b++ { + switch lk[b] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + end = k + 1 + } + } + } + if depth == 0 && k > i { + break + } + } + methods = append(methods, methodRange{i, end}) + i = end + continue + } + i++ + } + } + changed := false + for _, mr := range methods { + mlines := lines[mr.start:mr.end] + depths := lambdaDepthPerLine(mlines) + // Collect captured names + their declared type + the lambda-opening line indices that read them. + type captureInfo struct { + declType string + // lambdaOpenSubIdxs: sub-line indices where a `-> {` lambda body begins that reads the name + // at a deeper depth than the declaration. + lambdaOpenSubIdxs []int + } + captured := map[string]*captureInfo{} + // First pass: for each bare decl, determine if it's captured and record capture sites. + for di, ln := range mlines { + lnClean := strings.TrimRight(ln, "\r") + m := declRe.FindStringSubmatch(lnClean) + if m == nil { + continue + } + declType := strings.TrimSpace(m[2]) + name := m[3] + switch declType { + case "throw", "return", "new", "if", "else", "for", "while", "do", "switch", "case", + "break", "continue", "try", "catch", "finally", "synchronized", "assert": + continue + } + declDepth := depths[di] + // Find lambda bodies (-> {) in this method and check whether `name` is read at a deeper depth. + nameRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + var openIdxs []int + for li, lraw := range mlines { + lln := strings.TrimRight(lraw, "\r") + if depths[li] <= declDepth { + continue + } + // Is there a read of name on this line? + readHere := false + for _, loc := range nameRe.FindAllStringIndex(lln, -1) { + before := lln[:loc[0]] + after := lln[loc[1]:] + bTrim := strings.TrimRight(before, " \t") + aTrim := strings.TrimLeft(after, " \t") + if strings.Contains(after, "->") && + len(bTrim) > 0 && (bTrim[len(bTrim)-1] == '(' || bTrim[len(bTrim)-1] == ',') && + (len(aTrim) > 0 && (aTrim[0] == ')' || aTrim[0] == ',')) { + continue + } + lineBefore := strings.TrimSpace(before) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + continue + } + } + if strings.HasPrefix(aTrim, "=") && !strings.HasPrefix(aTrim, "==") { + continue + } + readHere = true + break + } + if readHere { + // Find the lambda-open line for this depth: the nearest preceding `-> {`. + openIdx := -1 + for k := li; k >= 0; k-- { + kl := strings.TrimRight(mlines[k], "\r") + if idx := strings.Index(kl, "->"); idx >= 0 { + rest := kl[idx:] + if strings.Contains(rest, "{") && depths[k] > declDepth { + // Only handle lambdas that begin a fresh statement: the previous + // non-blank line must end in `;`, `{`, or `}` (a statement boundary). + // This avoids multi-line argument lambdas (e.g. + // `x.register(a, () -> {\n ... \n}, ...)`) where inserting a copy + // before the `-> {` line lands inside the enclosing call's argument + // list and breaks structure. + if lambdaLineIsStmtStart(mlines, k) { + openIdx = k + } + break + } + } + } + if openIdx >= 0 { + // dedupe + found := false + for _, e := range openIdxs { + if e == openIdx { + found = true + break + } + } + if !found { + openIdxs = append(openIdxs, openIdx) + } + } + } + } + if len(openIdxs) > 0 { + captured[name] = &captureInfo{declType: declType, lambdaOpenSubIdxs: openIdxs} + if os.Getenv("JDEC_FLC_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[FLC] captured %s declType=%q openIdxs=%v\n", name, declType, openIdxs) + } + } + } + if len(captured) == 0 { + continue + } + // For each capture site, we need to: + // 1. insert a final copy line before the lambda-open line, and + // 2. rewrite reads of the name inside that lambda body to _f. + // We collect insertions and rewrites, then apply them on the GLOBAL lines slice via offsets. + // Process capture sites in REVERSE order so earlier insert offsets remain valid. + type edit struct { + insertAtGlobal int + insertIndent string + insertText string + // rewrite within [bodyStartGlobal, bodyEndGlobal): replace word reads name -> fname + bodyStartGlobal int + bodyEndGlobal int + name string + fname string + } + var edits []edit + copySeq := 0 // per-method counter for unique final-copy names + for name, ci := range captured { + // For EACH capture site, insert a dedicated final-copy (each lambda body may be in a + // different branch/scope, so one copy cannot cover all). The copy is inserted just before + // the lambda-open line (in the same scope), and ONLY that lambda body is rewritten. + for _, openSub := range ci.lambdaOpenSubIdxs { + openGlobal := mr.start + openSub + copySeq++ + fname := fmt.Sprintf("%s_f%d", name, copySeq) + openLn := strings.TrimRight(lines[openGlobal], "\r") + insIndent := leadingTabs(openLn) + // Insertion point: normally the lambda-open line. But when the lambda is a deeply- + // nested argument (parenDepth ≥ 2), the `-> {` is mid-statement — inserting there is + // syntactically invalid. Walk back to the start of the enclosing statement (the + // nearest preceding line at a shallower-or-equal indent following a `;`/`}`/`{` + // boundary) and insert there instead. + insertAt := openGlobal + if arrowIdx := strings.Index(openLn, "->"); arrowIdx >= 0 { + parenDepth := 0 + for i := 0; i < arrowIdx; i++ { + switch openLn[i] { + case '(': + parenDepth++ + case ')': + parenDepth-- + } + } + if parenDepth >= 2 { + stmtStart := findEnclosingStmtStart(lines, openGlobal, mr.start) + if stmtStart >= 0 { + insertAt = stmtStart + insIndent = leadingTabs(strings.TrimRight(lines[stmtStart], "\r")) + } + } + } + // The lambda body starts AFTER the `-> {` line. Find the matching close brace of the + // `-> {` block by brace depth. + bodyStart := openGlobal + 1 + bodyEnd := bodyStart + depth := 0 + started := false + for k := openGlobal; k < len(lines); k++ { + lk := strings.TrimRight(lines[k], "\r") + for b := 0; b < len(lk); b++ { + switch lk[b] { + case '{': + depth++ + started = true + case '}': + depth-- + } + } + if started && depth <= 0 { + bodyEnd = k + 1 + break + } + } + edits = append(edits, edit{ + insertAtGlobal: insertAt, + insertIndent: insIndent, + insertText: insIndent + "final " + ci.declType + " " + fname + " = " + name + ";", + bodyStartGlobal: bodyStart, + bodyEndGlobal: bodyEnd, + name: name, + fname: fname, + }) + } + } + // Phase A: apply ALL body rewrites first (these do not change line count). + for _, e := range edits { + if e.bodyEndGlobal == 0 { + continue // this is an insert-only edit + } + nameRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(e.name) + `\b`) + for k := e.bodyStartGlobal; k < e.bodyEndGlobal && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + if !nameRe.MatchString(ln) { + continue + } + newLn := rewriteLambdaReads(ln, e.name, e.fname) + if newLn != ln { + lines[k] = newLn + changed = true + } + } + } + // Phase B: collect insert-only edits, sort by insertAtGlobal DESC, apply (shifts offsets). + var inserts []edit + for _, e := range edits { + if e.insertText != "" { + inserts = append(inserts, e) + } + } + sort.Slice(inserts, func(a, b int) bool { return inserts[a].insertAtGlobal > inserts[b].insertAtGlobal }) + for _, e := range inserts { + at := e.insertAtGlobal + if at > len(lines) { + at = len(lines) + } + lines = append(lines[:at], append([]string{e.insertText}, lines[at:]...)...) + changed = true + } + } + if !changed { + return body + } + return strings.Join(lines, "\n") +} + +// sortEditsDesc sorts edits by insertAtGlobal in descending order. +// (sort.Slice is used inline instead.) + +// findEnclosingStmtStart scans backward from idx (within [methodStart, idx]) to find the first +// line of the statement containing idx: the nearest preceding line at a shallower-or-equal indent +// that immediately follows a statement boundary (`;`/`}`/`{` on the previous non-blank line). +// Returns -1 if not found. +func findEnclosingStmtStart(lines []string, idx, methodStart int) int { + openIndent := leadingTabs(strings.TrimRight(lines[idx], "\r")) + for k := idx; k >= methodStart; k-- { + ln := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(ln) == "" { + continue + } + ind := leadingTabs(ln) + if len(ind) > len(openIndent) { + continue // deeper — still inside the statement + } + // At or shallower than openIndent. Check if the PREVIOUS non-blank line ended a statement. + prev := -1 + for j := k - 1; j >= methodStart; j-- { + if strings.TrimSpace(strings.TrimRight(lines[j], "\r")) == "" { + continue + } + prev = j + break + } + if prev < 0 { + return k // start of method body + } + pt := strings.TrimRight(strings.TrimRight(lines[prev], "\r"), " \t") + if len(pt) > 0 && (pt[len(pt)-1] == ';' || pt[len(pt)-1] == '{' || pt[len(pt)-1] == '}') { + return k + } + } + return -1 +} + +// lambdaLineIsStmtStart reports whether the lambda-open line at idx begins a fresh, simple +// statement: (a) the previous non-blank line ends in `;`, `{`, or `}` (a statement boundary), AND +// (b) the lambda-open line itself is a simple expression statement (NOT a `return` or assignment +// whose value is a larger expression containing the lambda). This avoids multi-line argument +// lambdas embedded in return/assignment expressions where inserting a copy breaks structure. +func lambdaLineIsStmtStart(lines []string, idx int) bool { + openLn := strings.TrimRight(lines[idx], "\r") + // Reject return-statements whose value is a chained/complex expression. + trimOpen := strings.TrimSpace(openLn) + if strings.HasPrefix(trimOpen, "return ") || strings.HasPrefix(trimOpen, "return(") { + return false + } + // Reject continuation lines of a multi-line argument list: lines starting with `})`, `},`, or + // `,` indicate the lambda is a later argument of a call spanning multiple lines. (The first + // argument lambda is handled even if deeply nested — its copy inserts at the statement start.) + if strings.HasPrefix(trimOpen, "})") || strings.HasPrefix(trimOpen, "},") || + strings.HasPrefix(trimOpen, "),") || strings.HasPrefix(trimOpen, ",(") { + return false + } + for k := idx - 1; k >= 0; k-- { + ln := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(ln) == "" { + continue + } + t := strings.TrimRight(ln, " \t") + if len(t) == 0 { + return true + } + c := t[len(t)-1] + return c == ';' || c == '{' || c == '}' + } + return true // start of block +} + +// rewriteLambdaReads replaces READ occurrences of name with newName on a single line, leaving +// assignment targets (`name =`) and declarations untouched. This mirrors the read-detection logic +// used elsewhere (control-keyword aware). +func rewriteLambdaReads(ln, name, newName string) string { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(ln, -1) + if len(locs) == 0 { + return ln + } + var b strings.Builder + prev := 0 + for _, loc := range locs { + b.WriteString(ln[prev:loc[0]]) + before := ln[:loc[0]] + after := ln[loc[1]:] + bTrim := strings.TrimRight(before, " \t") + aTrim := strings.TrimLeft(after, " \t") + // Skip lambda parameter declarations. + if strings.Contains(after, "->") && + len(bTrim) > 0 && (bTrim[len(bTrim)-1] == '(' || bTrim[len(bTrim)-1] == ',') && + (len(aTrim) > 0 && (aTrim[0] == ')' || aTrim[0] == ',')) { + b.WriteString(name) + prev = loc[1] + continue + } + // Skip declarations (preceded by a type token). + lineBefore := strings.TrimSpace(before[strings.LastIndex(before, "\n")+1:]) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + b.WriteString(name) + prev = loc[1] + continue + } + } + // Skip assignment targets. + if strings.HasPrefix(aTrim, "=") && !strings.HasPrefix(aTrim, "==") { + b.WriteString(name) + prev = loc[1] + continue + } + // It's a read → rewrite. + b.WriteString(newName) + prev = loc[1] + } + b.WriteString(ln[prev:]) + return b.String() +} + +// removeUnreachableSwitchBreak removes a `break;` that immediately follows the closing `}` of a +// nested switch whose `default:` clause unconditionally terminates (throw/return). Such a break is +// unreachable (javac: "unreachable statement") and its presence can mask the real control flow, +// producing spurious downstream errors. The detection: a `break;` whose preceding non-blank line is +// a `}` at the same indent, where that `}` closes a switch block whose last label is `default:` with +// a throw/return body. Kill-switch: JDEC_RM_UNREACHABLE_BREAK_OFF=1. +func removeUnreachableSwitchBreak(body string) string { + if os.Getenv("JDEC_RM_UNREACHABLE_BREAK_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + breakRe := regexp.MustCompile(`^(\t+)break;\s*$`) + var removeIdx []int + for i := 0; i < len(lines); i++ { + m := breakRe.FindStringSubmatch(strings.TrimRight(lines[i], "\r")) + if m == nil { + continue + } + indent := m[1] + // Find the preceding non-blank line; it must be `indent}` (closing a block at this indent). + prev := -1 + for j := i - 1; j >= 0; j-- { + if strings.TrimSpace(strings.TrimRight(lines[j], "\r")) == "" { + continue + } + prev = j + break + } + if prev < 0 { + continue + } + if strings.TrimRight(lines[prev], "\r") != indent+"}" { + continue + } + // Scan backward from `prev` to verify the closed block is a switch whose last label before + // the closing `}` is `default:` with a throw/return body. Walk the block (brace depth -1). + depth := -1 // we start at the closing brace + lastBodyTerm := "" + for k := prev; k >= 0; k-- { + ln := strings.TrimRight(lines[k], "\r") + for b := len(ln) - 1; b >= 0; b-- { + switch ln[b] { + case '}': + depth++ + case '{': + depth-- + } + } + trim := strings.TrimSpace(ln) + if depth <= 0 { + // Inside the switch body (depth <= 0 relative to closing brace). + if strings.HasPrefix(trim, "default:") { + if lastBodyTerm == "throw" || lastBodyTerm == "return" { + removeIdx = append(removeIdx, i) + } + break + } + if strings.HasPrefix(trim, "throw ") || strings.HasPrefix(trim, "return ") || trim == "return;" { + lastBodyTerm = "throw" + if strings.HasPrefix(trim, "return") { + lastBodyTerm = "return" + } + } + if strings.HasPrefix(trim, "switch ") || strings.HasPrefix(trim, "switch(") { + break + } + } + } + } + if len(removeIdx) == 0 { + return body + } + out := lines[:0] + ri := 0 + for i, ln := range lines { + if ri < len(removeIdx) && i == removeIdx[ri] { + ri++ + continue + } + out = append(out, ln) + } + return strings.Join(out, "\n") +} + +// (the line after `default:` is the `}` closing the switch). An empty default leaves a value- +// returning method without a return on that path ("missing return statement"). Inserting +// `throw new RuntimeException();` completes the control flow legally in any method. Only triggers +// when the line following `default:` (ignoring blank lines) is the switch-closing `}` at the SAME +// indentation as `default:`. Kill-switch: JDEC_FIX_EMPTY_SWITCH_DEFAULT_OFF=1. +func fixEmptySwitchDefault(body string) string { + if os.Getenv("JDEC_FIX_EMPTY_SWITCH_DEFAULT_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + defaultRe := regexp.MustCompile(`^(\t+)default:\s*$`) + inserts := []int{} // line indices after which to insert the throw (descending apply) + for i := 0; i < len(lines); i++ { + m := defaultRe.FindStringSubmatch(strings.TrimRight(lines[i], "\r")) + if m == nil { + continue + } + indent := m[1] + // Find the next non-blank line; it must be `}` at the same indent (empty default body). + for j := i + 1; j < len(lines); j++ { + ln := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(ln) == "" { + continue + } + if ln == indent+"}" { + inserts = append(inserts, i) // insert the throw AFTER the `default:` line + } + break + } + } + if len(inserts) == 0 { + return body + } + // Apply in descending order so earlier offsets stay valid. + for k := len(inserts) - 1; k >= 0; k-- { + i := inserts[k] + indent := leadingTabs(strings.TrimRight(lines[i], "\r")) + throwLn := indent + "throw new RuntimeException();" + lines = append(lines[:i+1], append([]string{throwLn}, lines[i+1:]...)...) + } + return strings.Join(lines, "\n") +} + +// fixMissingReturn inserts `return null;` after a no-op empty-if statement (`if (cond){};`) that +// immediately follows a switch-closing `}` whose switch had a `default:` clause. This is the +// control-flow pattern that produces "missing return statement" in deep switch/if chains: javac +// does not treat a non-constant String-switch (hashCode-based) as definitely-terminating even with +// a `default: return`, so execution can fall through to the post-switch `if(cond){};` and then to +// the end of the enclosing block without a return. Inserting `return null;` after that empty-if +// completes the path. The insertion is reachable only on the not-definitely-returned path, so it +// never creates an unreachable-statement error. Kill-switch: JDEC_FIX_MISSING_RETURN_OFF=1. +// +// enclosingReturnsReference reports whether the method/ctor/init-block containing line idx has a +// declared return type that is a reference type (not primitive, not void, not a constructor). Used +// to gate `return null;` insertions. Scans backward for a method/init-block signature line. +func enclosingReturnsReference(lines []string, idx int) bool { + for k := idx; k >= 0; k-- { + ln := strings.TrimRight(lines[k], "\r") + if !isMethodOrInitBlockStart(ln) { + continue + } + trim := strings.TrimSpace(ln) + // Static/instance init block: no return type (void-equivalent for our purposes). + compact := strings.Join(strings.Fields(trim), "") + if compact == "static{" { + return false + } + // Method signature ` (...) {` or ` (...) {`. Extract the + // token before the method name (the return type). + paren := strings.Index(trim, "(") + if paren < 0 { + return true // can't tell — allow (constructor treated as reference-safe, harmless) + } + head := strings.TrimSpace(trim[:paren]) + // Drop leading modifier keywords. + fields := strings.Fields(head) + if len(fields) == 0 { + return true + } + // Constructor: single token (class name) with no return type — treat as not-reference. + if len(fields) == 1 { + return false + } + // The return type is the token BEFORE the method name (the last field is the method name). + retType := fields[len(fields)-2] + // An array type (`byte[]`, `Object[]`) is always a reference type — `return null;` is legal. + if strings.Contains(retType, "[") { + return true + } + // Strip generics for the primitive check. + base := retType + if p := strings.IndexAny(base, "<"); p >= 0 { + base = base[:p] + } + switch base { + case "void", "int", "long", "short", "byte", "double", "float", "char", "boolean": + return false + } + return true + } + return false +} + +// fixMissingReturn inserts `return null;` after a no-op empty-if statement (`if (cond){};`) when +// the enclosing block (which the empty-if is the last statement of, evidenced by the next non-blank +// line being a `}` at the same-or-shallower indent) contains a `switch` with a `default:` clause. +// This is the control-flow pattern that produces "missing return statement" in deep switch/if +// chains: javac does not treat a non-constant String-switch (hashCode-based) as definitely- +// terminating even with a `default: return`, so execution can fall through to the end of the +// enclosing block without a return. Inserting `return null;` after the empty-if completes the +// path. The insertion is reachable only on the not-definitely-returned path, so it never creates +// an unreachable-statement error. Kill-switch: JDEC_FIX_MISSING_RETURN_OFF=1. +func fixMissingReturn(body string) string { + if os.Getenv("JDEC_FIX_MISSING_RETURN_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + emptyIfRe := regexp.MustCompile(`^(\t+)if \([^;]*\)\{\};\s*$`) + // emptyThenElseRe matches an if-else whose THEN branch is empty (just `{` on its own line) and + // whose ELSE branch follows immediately. This is the control-flow pattern where the bytecode's + // `if(cond) goto L` has no statements in the taken branch, but the else branch ends with `return`, + // leaving the method without a return on the empty-then path → "missing return statement" + // (spring ASM ClassReader.readStream `if (var6 == 1) { } else { ... return var7_1; }`). + emptyThenElseRe := regexp.MustCompile(`^(\t+)if \([^;]*\)\{\s*$`) + type insertSite struct { + at int // insert AFTER this line index + indent string + } + var sites []insertSite + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + // Pattern 1: `if (cond){};` (no-op empty-if, switch-based). + m := emptyIfRe.FindStringSubmatch(ln) + if m != nil { + indent := m[1] + // The next non-blank line must be a `}` at a shallower indent (the block ends right after + // the empty-if), confirming the empty-if is the last statement of its block. + next := -1 + for j := i + 1; j < len(lines); j++ { + if strings.TrimSpace(strings.TrimRight(lines[j], "\r")) == "" { + continue + } + next = j + break + } + if next < 0 { + continue + } + nextLn := strings.TrimRight(lines[next], "\r") + nextTrim := strings.TrimSpace(nextLn) + if nextTrim != "}" { + continue + } + nextIndent := leadingTabs(nextLn) + if len(nextIndent) >= len(indent) { + continue // the `}` is not shallower — not a block-end + } + // Scan backward within the enclosing block (same indent or deeper) for a `default:` label, + // which indicates a switch with a default lives in this block. Stop at a shallower indent + // (block boundary). + sawDefault := false + for k := i - 1; k >= 0; k-- { + lk := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(lk) == "" { + continue + } + kind := leadingTabs(lk) + if len(kind) < len(indent) { + break // left the enclosing block + } + tk := strings.TrimSpace(lk) + if tk == "default:" || strings.HasPrefix(tk, "default:") { + sawDefault = true + break + } + } + if !sawDefault { + continue + } + // Only apply when the enclosing method returns a reference type — `return null;` is invalid + // for primitive/void returns. Scan backward for the method/init-block signature. + if !enclosingReturnsReference(lines, i) { + continue + } + sites = append(sites, insertSite{at: i, indent: indent}) + continue + } + // Pattern 2: `if (cond) {` with empty then + `else { ... return ...; }` — the else branch + // ends with a return but the then branch has none, leaving the method without a return on + // the then path. Insert `return null;` inside the empty then branch. + m2 := emptyThenElseRe.FindStringSubmatch(ln) + if m2 != nil { + indent := m2[1] + // The next non-blank line must be `}` (closing the empty then), possibly combined with `else`. + next := -1 + for j := i + 1; j < len(lines); j++ { + if strings.TrimSpace(strings.TrimRight(lines[j], "\r")) == "" { + continue + } + next = j + break + } + if next < 0 { + continue + } + nextLn := strings.TrimRight(lines[next], "\r") + nextTrim := strings.TrimSpace(nextLn) + // The then-closing `}` may be on its own line OR combined with `else` as `}else{`. + if nextTrim != "}" && !strings.HasPrefix(nextTrim, "}else") && !strings.HasPrefix(nextTrim, "} else") { + continue + } + // If the `}` is combined with else, the else starts on the same line; otherwise find the + // else on the next non-blank line. + elseIdx := -1 + if strings.HasPrefix(nextTrim, "}else") || strings.HasPrefix(nextTrim, "} else") { + elseIdx = next + } else { + for j := next + 1; j < len(lines); j++ { + tej := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(tej) == "" { + continue + } + elseIdx = j + break + } + } + if elseIdx < 0 { + continue + } + elseLn := strings.TrimSpace(strings.TrimRight(lines[elseIdx], "\r")) + if !strings.HasPrefix(elseLn, "}") || !strings.Contains(elseLn, "else") { + continue + } + // The else branch must end with a `return` statement (scan forward for the matching `}`). + // Start with depth=1 (the else's `{` opens the block) and scan from the line AFTER elseIdx + // so the `}` that closes the then-branch (in `}else{`) does not corrupt the count. + depth := 1 + hasReturn := false + elseEnd := -1 + for j := elseIdx + 1; j < len(lines); j++ { + lj := strings.TrimRight(lines[j], "\r") + for b := 0; b < len(lj); b++ { + switch lj[b] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + elseEnd = j + break + } + } + } + if depth == 0 { + break + } + } + if elseEnd >= 0 { + for k := elseIdx; k <= elseEnd; k++ { + lk := strings.TrimRight(lines[k], "\r") + if strings.Contains(lk, "return ") || strings.Contains(lk, "return;") { + hasReturn = true + break + } + } + } + if !hasReturn { + continue + } + // Only apply when the if-else is the LAST statement of its enclosing block: the next + // non-blank line after the else's closing `}` must be a block-end marker — `}` (block end) + // or `}catch`/`}finally` (try block end, the if-else is the last statement in a try body, + // as in spring ASM ClassReader.readStream). If there are more statements after the if-else, + // inserting `return null;` would make them unreachable (fastjson2 ValueFilter.of has + // `return l2;` after the if-else). + if elseEnd < 0 || elseEnd+1 >= len(lines) { + continue + } + afterElse := -1 + for j := elseEnd + 1; j < len(lines); j++ { + if strings.TrimSpace(strings.TrimRight(lines[j], "\r")) == "" { + continue + } + afterElse = j + break + } + if afterElse < 0 { + continue + } + afterLn := strings.TrimRight(lines[afterElse], "\r") + afterTrim := strings.TrimSpace(afterLn) + if afterTrim != "}" && + !strings.HasPrefix(afterTrim, "}catch") && !strings.HasPrefix(afterTrim, "} catch") && + !strings.HasPrefix(afterTrim, "}finally") && !strings.HasPrefix(afterTrim, "} finally") { + continue // more statements follow the if-else — inserting return would be unreachable + } + afterIndent := leadingTabs(afterLn) + if len(afterIndent) > len(indent) { + continue // the `}` is deeper — not the enclosing block end + } + // Only apply when the enclosing method returns a reference type. + if !enclosingReturnsReference(lines, i) { + continue + } + // Insert `return null;` inside the empty then branch (after the `if (cond) {` line). + sites = append(sites, insertSite{at: i, indent: indent + "\t"}) + } + } + if len(sites) == 0 { + return body + } + for k := len(sites) - 1; k >= 0; k-- { + s := sites[k] + retLn := s.indent + "return null;" + lines = append(lines[:s.at+1], append([]string{retLn}, lines[s.at+1:]...)...) + } + return strings.Join(lines, "\n") +} + +// fixTryCatchExceptionPlacement detects the pattern where exception-throwing calls (getDeclaredConstructor, +// newInstance, etc.) are rendered OUTSIDE a try block, while the catch clause lists those exception types. +// It moves the calls into the inner try body so javac sees them as caught. +// +// Pattern detected: +// +// if (cond){ +// +// +// } +// try{ +// try{ +// +// }catch(ExceptionType1 | ExceptionType2 ...){ +// throw new JSONException(...); +// } +// }catch(Throwable){ } +// +// Fix: move the exception-throwing calls from the if-body into the inner try-body. +// Kill-switch: JDEC_FIX_TRYCATCH_OFF=1. +func fixTryCatchExceptionPlacement(body string) string { + if os.Getenv("JDEC_FIX_TRYCATCH_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Scan for the pattern: an if-block whose body contains calls to methods that throw + // checked exceptions (getDeclaredConstructor, newInstance, getMethod, etc.), immediately + // followed by a try block whose catch lists those exception types. + throwingCallRe := regexp.MustCompile(`\.(getDeclaredConstructor|newInstance|getMethod|getConstructor)\(`) + for i := 0; i < len(lines); i++ { + // Check if this line starts an if block. + lnClean := strings.TrimRight(lines[i], "\r") + if !strings.Contains(lnClean, "if (") || !strings.HasSuffix(strings.TrimSpace(lnClean), "{") { + continue + } + ifIndent := "" + for _, c := range lnClean { + if c == '\t' { + ifIndent += "\t" + } else { + break + } + } + // Collect the if-body lines (at ifIndent + 1 tab). + bodyIndent := ifIndent + "\t" + var ifBodyLines []int + closingBrace := -1 + for j := i + 1; j < len(lines) && j < i+20; j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "}" && strings.HasPrefix(jl, ifIndent) && !strings.HasPrefix(jl, bodyIndent) { + closingBrace = j + break + } + ifBodyLines = append(ifBodyLines, j) + } + if closingBrace < 0 || len(ifBodyLines) == 0 { + continue + } + // Check if any if-body line has a throwing call. + hasThrowingCall := false + for _, idx := range ifBodyLines { + if throwingCallRe.MatchString(lines[idx]) { + hasThrowingCall = true + break + } + } + if !hasThrowingCall { + continue + } + // Check if the NEXT non-empty line after closingBrace is a try block. + tryLine := -1 + for j := closingBrace + 1; j < len(lines) && j < closingBrace+3; j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if strings.Contains(jl, "try{") { + tryLine = j + } + break + } + if tryLine < 0 { + continue + } + // Find the inner try body: the line after `try{` at tryIndent+1. + innerTryIndent := ifIndent + "\t" + innerTryStart := -1 + for j := tryLine + 1; j < len(lines) && j < tryLine+5; j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.Contains(jl, "try{") && strings.HasPrefix(jl, innerTryIndent) { + innerTryStart = j + 1 + break + } + } + if innerTryStart < 0 { + continue + } + // Move the throwing-call lines from the if-body to before the inner try's first statement. + // Build the new lines: remove throwing calls from if-body, insert them at innerTryStart. + var throwingLines []string + var newIfBody []string + for _, idx := range ifBodyLines { + jl := strings.TrimRight(lines[idx], "\r") + if throwingCallRe.MatchString(jl) { + // Re-indent to innerTryIndent + 1 tab. + trimmed := strings.TrimLeft(jl, "\t") + throwingLines = append(throwingLines, innerTryIndent+"\t"+trimmed) + } else { + newIfBody = append(newIfBody, jl) + } + } + if len(throwingLines) == 0 { + continue + } + // Replace the if-body: keep non-throwing lines only. + // The if-body lines span from i+1 to closingBrace-1. + // First, blank out all original if-body lines. + for _, idx := range ifBodyLines { + lines[idx] = "" + } + // Re-fill with non-throwing lines. + bodyOffset := i + 1 + for _, l := range newIfBody { + lines[bodyOffset] = l + bodyOffset++ + } + // If the if-body is now empty (all lines were throwing), make the if body just `{}`. + if len(newIfBody) == 0 { + // Merge the if line and closing brace into `{` on same line. + lines[i] = strings.TrimRight(lines[i], "\r") + lines[closingBrace] = "" + // Move closing brace to if line. + // Actually keep as is — an empty if body `{ }` is fine. + // Put an empty line where the body was. + lines[i+1] = ifIndent + "\t" + } + // Insert throwing lines before the inner try's first statement. + // We need to shift all subsequent lines down. + insertAt := innerTryStart + for _, tl := range throwingLines { + lines = append(lines[:insertAt], append([]string{tl}, lines[insertAt:]...)...) + insertAt++ + } + } + return strings.Join(lines, "\n") +} + +// methodThrows maps a reflection-method name to the checked exception it declares. Used by +// addMissingCatchException to detect a try-body call whose checked exception is absent from the +// catch clause. NOTE: `forName` is excluded — its ClassNotFoundException is often already handled +// by an enclosing catch or a different overload, and augmenting it caused jsoup/snakeyaml regressions. +var methodThrows = map[string]string{ + "getConstructor": "NoSuchMethodException", + "getDeclaredConstructor": "NoSuchMethodException", + "getMethod": "NoSuchMethodException", + "getDeclaredMethod": "NoSuchMethodException", + "newInstance": "", // throws multiple (InvocationTargetException etc.) — handled by callers already +} + +// catchBodyAccessesCatchVar reports whether the catch body at catchLineIdx accesses a member +// (method or field) of the caught variable (e.g. `var7_2.getTargetException()`). Such a catch +// relies on the specific type of the variable; widening the multi-catch with an unrelated type +// breaks the member access. +func catchBodyAccessesCatchVar(lines []string, catchLineIdx int) bool { + catchRe := regexp.MustCompile(`\}catch\(([^)]*)\)\{`) + cl := strings.TrimRight(lines[catchLineIdx], "\r") + cm := catchRe.FindStringSubmatch(cl) + if cm == nil { + return false + } + fields := strings.Fields(cm[1]) + if len(fields) == 0 { + return false + } + varName := fields[len(fields)-1] + // Scan the catch body for `varName.` (member access on the catch variable). + braceOpen := strings.Index(cl, "{") + depth := 1 + for k := catchLineIdx; k < len(lines); k++ { + lk := strings.TrimRight(lines[k], "\r") + startByte := 0 + if k == catchLineIdx && braceOpen >= 0 { + startByte = braceOpen + 1 + } + // Check for member access on varName in the body portion of this line. + if k > catchLineIdx { + bodyPart := lk + if k == catchLineIdx { + bodyPart = lk[startByte:] + } + if strings.Contains(bodyPart, varName+".") { + return true + } + } + for b := startByte; b < len(lk); b++ { + switch lk[b] { + case '{': + depth++ + case '}': + depth-- + } + } + if depth <= 0 && k > catchLineIdx { + return false + } + } + return false +} + +// catchRethrowsVariable reports whether the catch clause at catchLineIdx directly rethrows the +// caught variable (e.g. `catch(E var){ throw var; }`). Such a catch propagates the checked +// exception; augmenting its type list would require a throws declaration on the enclosing method. +func catchRethrowsVariable(lines []string, catchLineIdx int) bool { + catchRe := regexp.MustCompile(`\}catch\(([^)]*)\)\{`) + cl := strings.TrimRight(lines[catchLineIdx], "\r") + cm := catchRe.FindStringSubmatch(cl) + if cm == nil { + return false + } + // The catch variable is the last whitespace-separated token inside the parens. + fields := strings.Fields(cm[1]) + if len(fields) == 0 { + return false + } + varName := fields[len(fields)-1] + // Scan the catch body (from catchLineIdx+1 to its closing brace) for `throw varName;`. + // Start depth tracking from AFTER the catch clause's opening `{`. Depth starts at 1 (we are + // inside the catch body). The leading `}` of `}catch(...) {` is not counted. + braceOpen := strings.Index(cl, "{") + depth := 1 + started := braceOpen >= 0 + for k := catchLineIdx; k < len(lines); k++ { + lk := strings.TrimRight(lines[k], "\r") + startByte := 0 + if k == catchLineIdx && braceOpen >= 0 { + startByte = braceOpen + 1 + } + for b := startByte; b < len(lk); b++ { + switch lk[b] { + case '{': + depth++ + case '}': + depth-- + } + } + if started && depth <= 0 && k > catchLineIdx { + return false + } + if k > catchLineIdx { + trim := strings.TrimSpace(lk) + if strings.HasPrefix(trim, "throw "+varName+";") || trim == "throw "+varName+";" { + return true + } + } + } + return false +} + +// hasLambdaBoundaryBetween reports whether any line in (startLine, endLine] contains a `-> {` +// lambda opening — a lambda body is a separate scope, so a try/catch outside the lambda cannot +// catch exceptions thrown inside it. +func hasLambdaBoundaryBetween(lines []string, startLine, endLine int) bool { + for k := startLine + 1; k <= endLine && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + if idx := strings.Index(ln, "->"); idx >= 0 { + rest := ln[idx:] + if strings.Contains(rest, "{") { + return true + } + } + } + return false +} + +// addMissingCatchException performs per-call-site exception-flow analysis. For each reflection +// call site (getConstructor/getDeclaredConstructor/getMethod/getDeclaredMethod) that throws +// NoSuchMethodException, it walks the enclosing try/catch chain and determines whether +// NoSuchMethodException (or a supertype: ReflectiveOperationException, Exception, Throwable) is +// caught by ANY enclosing catch. If not, it augments the NEAREST enclosing catch with the missing +// exception type. This avoids the cascade of the prior global approach (which augmented a catch +// even when an inner try/catch already handled the call site). Kill-switch: +// JDEC_ADD_MISSING_CATCH_OFF=1. +func addMissingCatchException(body string) string { + if os.Getenv("JDEC_ADD_MISSING_CATCH_OFF") == "1" { + return body + } + dbg := os.Getenv("JDEC_ADD_MISSING_CATCH_DBG") == "1" + lines := strings.Split(body, "\n") + catchRe := regexp.MustCompile(`\}catch\(([^)]*)\)\{`) + callRe := regexp.MustCompile(`\.(getConstructor|getDeclaredConstructor|getMethod|getDeclaredMethod)\(`) + + // Pre-compute all try/catch blocks: for each `try{`, find its body range [tryStart, tryBodyEnd) + // and the catch clause line + caught types. A try's catch is relevant for call sites INSIDE the + // try body (the catch handles exceptions thrown from the try body). + type tryBlock struct { + tryStart int + tryBodyEnd int // line index of the `}` closing the try body (exclusive: tryBodyEnd is that line) + catchLine int // line index of the `}catch(...) {` line, -1 if no catch + caughtTypes string // the types listed in the catch clause + } + tryRe := regexp.MustCompile(`^(\t+)try\{\s*$`) + var tryBlocks []tryBlock + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + if !tryRe.MatchString(ln) { + continue + } + // Find the try body end: the first `}` (char-by-char) that brings depth back to 0. + depth := 0 + tryBodyEnd := -1 + catchLine := -1 + var caughtTypes string + for j := i; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + for b := 0; b < len(jl); b++ { + switch jl[b] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 && j > i && tryBodyEnd < 0 { + tryBodyEnd = j + // Check if the rest of the line (after this `}`) starts a catch. + rest := strings.TrimSpace(jl[b+1:]) + if strings.HasPrefix(rest, "catch(") { + catchLine = j + cm := catchRe.FindStringSubmatch(jl) + if cm != nil { + caughtTypes = cm[1] + } + } + } + } + } + } + if tryBodyEnd < 0 { + continue + } + // If catch wasn't on the same line as the close, look on the next non-blank line. + if catchLine < 0 { + for k := tryBodyEnd + 1; k < len(lines) && k < tryBodyEnd+3; k++ { + cl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(cl) == "" { + continue + } + cm := catchRe.FindStringSubmatch(cl) + if cm != nil { + catchLine = k + caughtTypes = cm[1] + } + break + } + } + tryBlocks = append(tryBlocks, tryBlock{ + tryStart: i, + tryBodyEnd: tryBodyEnd, + catchLine: catchLine, + caughtTypes: caughtTypes, + }) + } + + // For each call site, find all enclosing try blocks (tryStart < callLine < tryBodyEnd), ordered + // from innermost to outermost. Check if any enclosing catch handles NoSuchMethodException or a + // supertype. If none, augment the INNERMOST enclosing catch that has a catch clause. + const exc = "NoSuchMethodException" + excOrSup := []string{"NoSuchMethodException", "ReflectiveOperationException", "Exception", "Throwable"} + caughtWord := func(typesStr, name string) bool { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + return re.MatchString(typesStr) + } + augment := map[int]bool{} + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + if !callRe.MatchString(ln) { + continue + } + // Find enclosing try blocks for this call site (innermost first). A try block encloses the + // call ONLY if there is no lambda boundary (`-> {`) between the try start and the call — a + // lambda body is a separate scope; an outer try/catch cannot catch exceptions thrown inside it. + var enclosing []tryBlock + for _, tb := range tryBlocks { + if i > tb.tryStart && i < tb.tryBodyEnd { + if hasLambdaBoundaryBetween(lines, tb.tryStart, i) { + continue // a lambda separates this try from the call — not enclosing + } + enclosing = append(enclosing, tb) + } + } + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: %d enclosing try blocks\n", i+1, len(enclosing)) + } + // Check if any enclosing catch already handles the exception. + alreadyCaught := false + for _, tb := range enclosing { + if tb.catchLine < 0 { + continue + } + for _, sup := range excOrSup { + if caughtWord(tb.caughtTypes, sup) { + alreadyCaught = true + break + } + } + if alreadyCaught { + break + } + } + if alreadyCaught { + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: already caught\n", i+1) + } + continue + } + // Not caught — augment the innermost enclosing catch that HAS a catch clause. + // Find the innermost enclosing catch with a clause that does NOT simply rethrow the caught + // variable (a rethrow catch like `catch(E var){ throw var; }` would propagate the checked + // exception, requiring a throws declaration we cannot add — skip those). + var target *tryBlock + for k := range enclosing { + tb := enclosing[k] + if tb.catchLine < 0 { + continue + } + if catchRethrowsVariable(lines, tb.catchLine) { + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: catch L%d rethrows variable (skip)\n", i+1, tb.catchLine+1) + } + continue + } + // Skip catches whose body accesses members of the catch variable (e.g. + // `var.getTargetException()`). Adding a type to the multi-catch narrows the inferred type + // and breaks member access that only exists on one of the original types. + if catchBodyAccessesCatchVar(lines, tb.catchLine) { + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: catch L%d accesses catch-var members (skip)\n", i+1, tb.catchLine+1) + } + continue + } + target = &enclosing[k] + break + } + if target == nil { + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: NO enclosing catch with clause (skip)\n", i+1) + } + continue + } + if caughtWord(target.caughtTypes, exc) { + continue + } + augment[target.catchLine] = true + if dbg { + fmt.Fprintf(os.Stderr, "[ADDCATCH] call L%d: AUGMENT catch L%d with %s\n", i+1, target.catchLine+1, exc) + } + } + if len(augment) == 0 { + return body + } + // Apply augmentations in descending line order. + var augLines []int + for ln := range augment { + augLines = append(augLines, ln) + } + sort.Ints(augLines) + for k := len(augLines) - 1; k >= 0; k-- { + catchLineIdx := augLines[k] + cl := strings.TrimRight(lines[catchLineIdx], "\r") + cm := catchRe.FindStringSubmatch(cl) + if cm == nil { + continue + } + types := cm[1] + fields := strings.Fields(types) + if len(fields) < 1 { + continue + } + var varName, typeList string + if len(fields) >= 2 { + varName = fields[len(fields)-1] + typeList = strings.TrimSpace(strings.TrimSuffix(types, varName)) + } else { + varName = fields[0] + typeList = "" + } + var newTypes string + if typeList == "" { + newTypes = exc + " " + varName + } else { + newTypes = typeList + " | " + exc + " " + varName + } + newLine := strings.Replace(cl, "("+types+")", "("+newTypes+")", 1) + lines[catchLineIdx] = newLine + } + return strings.Join(lines, "\n") +} + +// dedupNestedCatchException removes exception types from an outer catch clause when a nested +// try/catch within the try body already catches that exact type. This repairs javac's "exception X +// is never thrown in body of corresponding try statement" — when an inner catch handles the +// exception, it never propagates to the outer catch, so listing it there is an error. +// +// Detection: for each try/catch block, scan the try body for nested try/catch blocks whose catch +// lists an exception type T that also appears in the outer catch. Remove T from the outer catch +// (only if the outer catch has ≥2 types, so it still catches something). Kill-switch: +// JDEC_DEDUP_NESTED_CATCH_OFF=1. +func dedupNestedCatchException(body string) string { + if os.Getenv("JDEC_DEDUP_NESTED_CATCH_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + catchRe := regexp.MustCompile(`\}catch\(([^)]*)\)\{`) + tryRe := regexp.MustCompile(`^(\t+)try\{\s*$`) + // Collect all try blocks with their catch info (reuse the model from addMissingCatchException). + type tryBlock struct { + tryStart int + tryBodyEnd int + catchLine int + caughtTypes string + } + var tryBlocks []tryBlock + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + if !tryRe.MatchString(ln) { + continue + } + depth := 0 + tryBodyEnd := -1 + catchLine := -1 + var caughtTypes string + for j := i; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + for b := 0; b < len(jl); b++ { + switch jl[b] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 && j > i && tryBodyEnd < 0 { + tryBodyEnd = j + rest := strings.TrimSpace(jl[b+1:]) + if strings.HasPrefix(rest, "catch(") { + catchLine = j + cm := catchRe.FindStringSubmatch(jl) + if cm != nil { + caughtTypes = cm[1] + } + } + } + } + } + } + if tryBodyEnd < 0 { + continue + } + if catchLine < 0 { + for k := tryBodyEnd + 1; k < len(lines) && k < tryBodyEnd+3; k++ { + cl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(cl) == "" { + continue + } + cm := catchRe.FindStringSubmatch(cl) + if cm != nil { + catchLine = k + caughtTypes = cm[1] + } + break + } + } + tryBlocks = append(tryBlocks, tryBlock{tryStart: i, tryBodyEnd: tryBodyEnd, catchLine: catchLine, caughtTypes: caughtTypes}) + } + // For each outer try with a catch, find nested try/catch blocks within its body and collect + // exception types caught by inner catches. Remove those from the outer catch. + type edit struct { + catchLine int + removeType string + } + var edits []edit + for _, outer := range tryBlocks { + if outer.catchLine < 0 { + continue + } + // Parse outer catch types (word list, excluding the catch variable). + outerFields := strings.Fields(outer.caughtTypes) + if len(outerFields) < 2 { + continue // need ≥2 types to remove one (the last field is the variable) + } + outerVarName := outerFields[len(outerFields)-1] + outerTypes := outerFields[:len(outerFields)-1] // type tokens (may include `|`) + // Collect actual type names (strip `|`). + outerTypeNames := map[string]bool{} + for _, tf := range outerTypes { + if tf != "|" { + outerTypeNames[tf] = true + } + } + if len(outerTypeNames) < 2 { + continue // need ≥2 actual types to remove one + } + // Find nested try/catch blocks whose try is within [outer.tryStart+1, outer.tryBodyEnd). + for _, inner := range tryBlocks { + if inner.tryStart <= outer.tryStart || inner.tryStart >= outer.tryBodyEnd { + continue + } + if inner.catchLine < 0 { + continue + } + if os.Getenv("JDEC_DEDUP_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[DEDUP-NESTED] outer try L%d (catch L%d types=%q) → inner try L%d (catch L%d types=%q)\n", + outer.tryStart+1, outer.catchLine+1, outer.caughtTypes, inner.tryStart+1, inner.catchLine+1, inner.caughtTypes) + } + innerFields := strings.Fields(inner.caughtTypes) + if len(innerFields) == 0 { + continue + } + for _, tf := range innerFields { + if tf == "|" { + continue + } + // Skip if it's the catch variable (last field). Heuristic: a type name starts with + // uppercase; a variable starts with lowercase. This is imperfect but covers the common + // case (InstantiationException vs var4). + if len(tf) > 0 && tf[0] >= 'a' && tf[0] <= 'z' { + continue // likely a variable name + } + if outerTypeNames[tf] { + // This type is caught by both inner and outer — remove from outer. + edits = append(edits, edit{catchLine: outer.catchLine, removeType: tf}) + _ = outerVarName + } + } + } + } + if len(edits) == 0 { + return body + } + // Apply: for each edit, remove the type from the catch clause. Handle multiple removals per + // catch line by deduping and applying once. + byLine := map[int]map[string]bool{} + for _, e := range edits { + if byLine[e.catchLine] == nil { + byLine[e.catchLine] = map[string]bool{} + } + byLine[e.catchLine][e.removeType] = true + } + catchLines := []int{} + for cl := range byLine { + catchLines = append(catchLines, cl) + } + sort.Ints(catchLines) + for k := len(catchLines) - 1; k >= 0; k-- { + cl := catchLines[k] + removeTypes := byLine[cl] + catchLn := strings.TrimRight(lines[cl], "\r") + cm := catchRe.FindStringSubmatch(catchLn) + if cm == nil { + continue + } + types := cm[1] + fields := strings.Fields(types) + if len(fields) < 2 { + continue + } + varName := fields[len(fields)-1] + // Rebuild the type list, excluding removed types. + var kept []string + for _, tf := range fields[:len(fields)-1] { + if tf == "|" { + continue + } + if removeTypes[tf] { + continue + } + kept = append(kept, tf) + } + if len(kept) == 0 { + // All caught types are handled by inner catches — the outer catch is dead code for + // those specific types. Replace with `Exception` (a valid catch-all supertype) to keep + // the catch clause structurally valid without claiming a specific type that's never + // thrown. This avoids "exception X is never thrown" for ALL listed types. + kept = []string{"Exception"} + } + newTypes := strings.Join(kept, " | ") + " " + varName + newLine := strings.Replace(catchLn, "("+types+")", "("+newTypes+")", 1) + lines[cl] = newLine + } + return strings.Join(lines, "\n") +} + +// wrapUncaughtThrowingCall wraps a `UNSAFE.allocateInstance(...)` call (which throws +// InstantiationException) in a try/catch when it appears in a switch case body WITHOUT any +// enclosing try/catch. The catch converts the checked InstantiationException into a +// JSONException (matching the surrounding method's error-handling idiom). This repairs +// "unreported exception InstantiationException; must be caught or declared to be thrown". +// Only wraps `return .allocateInstance(...);` statements (the common case). Kill-switch: +// JDEC_WRAP_ALLOCATE_OFF=1. +func wrapUncaughtThrowingCall(body string) string { + if os.Getenv("JDEC_WRAP_ALLOCATE_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + allocRe := regexp.MustCompile(`^(\t+)(return .*\bUNSAFE\.allocateInstance\([^;]*\));\s*$`) + type insert struct { + at int + indent string + stmt string + catchVar string + } + var inserts []insert + counter := 0 + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + m := allocRe.FindStringSubmatch(ln) + if m == nil { + continue + } + // Check the statement is NOT already inside a try/catch: scan backward for an enclosing + // try{ at a shallower indent, with no catch boundary in between. + indent := m[1] + stmt := m[2] + if isInsideTryCatch(lines, i, indent) { + continue + } + counter++ + catchVar := fmt.Sprintf("varIE_%d", counter) + inserts = append(inserts, insert{at: i, indent: indent, stmt: stmt, catchVar: catchVar}) + } + if len(inserts) == 0 { + return body + } + // Apply in descending order. Replace the single `return ...allocateInstance...;` line with: + // try{ + // return ...allocateInstance...; + // }catch(InstantiationException varIE_N){ + // throw new RuntimeException(varIE_N); + // } + for k := len(inserts) - 1; k >= 0; k-- { + ins := inserts[k] + // Determine the catch body: throw a RuntimeException wrapping the exception (safe for any + // method return type, since it never returns normally). + tryLine := ins.indent + "try{" + retLine := ins.indent + "\t" + ins.stmt + ";" + catchLine := ins.indent + "}catch(InstantiationException " + ins.catchVar + "){" + throwLine := ins.indent + "\tthrow new RuntimeException(" + ins.catchVar + ");" + closeLine := ins.indent + "}" + newLines := []string{tryLine, retLine, catchLine, throwLine, closeLine} + // Replace the single line at ins.at with newLines. + lines = append(lines[:ins.at], append(newLines, lines[ins.at+1:]...)...) + } + return strings.Join(lines, "\n") +} + +// isInsideLambdaBody reports whether line idx is inside a lambda body (between a `-> {` and +// its matching `}`). Used to detect reflection calls that are in lambda scope (not covered by +// an enclosing try/catch outside the lambda). +func isInsideLambdaBody(lines []string, idx int) bool { + lambdaDepth := 0 + braceDepth := 0 + for k := 0; k <= idx && k < len(lines); k++ { + lk := strings.TrimRight(lines[k], "\r") + for b := 0; b < len(lk); b++ { + if lk[b] == '{' { + before := strings.TrimRight(lk[:b], " \t") + braceDepth++ + if strings.HasSuffix(before, "->") { + lambdaDepth++ + } + } else if lk[b] == '}' { + braceDepth-- + if lambdaDepth > 0 && braceDepth == 0 { + lambdaDepth = 0 + } + } + } + } + return lambdaDepth > 0 +} + +func min2(a, b int) int { + if a < b { + return a + } + return b +} + +// collectSwitchAssignedVars returns the set of variables/fields assigned (`X =` but not `==`) in +// the given line range. Includes field assignments (e.g. `this.formatValidator =`). +func collectSwitchAssignedVars(lines []string, start, end int) map[string]bool { + result := map[string]bool{} + assignRe := regexp.MustCompile(`\.?(\w+)\s*=[^=]`) + for k := start; k < end && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + for _, m := range assignRe.FindAllStringSubmatch(ln, -1) { + name := m[1] + switch name { + case "if", "else", "while", "for", "return", "throw", "switch", "case", "break", + "continue", "try", "catch", "finally", "new", "this", "super": + continue + } + result[name] = true + } + } + return result +} + +// collectSwitchDefaultSelfReads returns the set of variables that appear on BOTH sides of an +// assignment in the default body (e.g. `var5 = var5 ^ x` reads var5 while assigning it). These +// represent compound assignments where the default DEPENDS on the case's prior value. +func collectSwitchDefaultSelfReads(lines []string, start, end int) map[string]bool { + result := map[string]bool{} + // Match `var = ... var ...` patterns: the LHS variable also appears on the RHS. + assignRe := regexp.MustCompile(`\b(\w+)\s*=\s*([^;]*)`) + for k := start; k < end && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + for _, m := range assignRe.FindAllStringSubmatch(ln, -1) { + varName := m[1] + rhs := m[2] + // Check if varName appears in the RHS (self-read / compound assignment). + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(varName) + `\b`) + if re.MatchString(rhs) { + result[varName] = true + } + } + } + return result +} + +// defaultAssignsIndependently reports whether the variable `name` is assigned in the given range +// WITHOUT being read in the same assignment (an independent overwrite, not a compound assignment). +func defaultAssignsIndependently(lines []string, start, end int, name string) bool { + assignRe := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\s*=\s*([^;]*)`) + for k := start; k < end && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + m := assignRe.FindStringSubmatch(ln) + if m == nil { + continue + } + rhs := m[1] + // Check if name appears in the RHS (self-read / compound assignment). + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + if !re.MatchString(rhs) { + return true // independent assignment (no self-read) + } + } + return false +} + +// collectSwitchReadVars returns the set of variables READ (used but not as assignment target) in +// the given line range. +func collectSwitchReadVars(lines []string, start, end int) map[string]bool { + result := map[string]bool{} + wordRe := regexp.MustCompile(`\b(\w+)\b`) + for k := start; k < end && k < len(lines); k++ { + ln := strings.TrimRight(lines[k], "\r") + for _, m := range wordRe.FindAllStringSubmatch(ln, -1) { + name := m[1] + switch name { + case "if", "else", "while", "for", "return", "throw", "switch", "case", "break", + "continue", "try", "catch", "finally", "new", "this", "super", "null", "true", + "false", "int", "long", "boolean", "char", "byte", "short", "double", "float", + "void", "String", "Class", "Object", "var", "final": + continue + } + result[name] = true + } + } + return result +} + +// wrapReflectionCallInSwitchCase wraps a single-statement switch case body that calls a reflection +// wrapFieldInitializerReflection converts a field initializer containing a reflection call +// (getMethod/getDeclaredMethod/getConstructor/getDeclaredConstructor) that throws a checked +// exception into a bare field declaration + static-block initialization with try/catch. This +// repairs "unreported exception NoSuchMethodException" in field initializers (which cannot contain +// try/catch in Java). Kill-switch: JDEC_WRAP_FIELD_INIT_OFF=1. +// +// Pattern: `\t = .reflectionMethod(...)...;` +// +// → `\t ;` +// + `\tstatic{` +// + `\t\ttry{` +// + `\t\t\t = .reflectionMethod(...)...;` +// + `\t\t}catch(NoSuchMethodException varX){` +// + `\t\t\tthrow new RuntimeException(varX);` +// + `\t\t}` +// + `\t}` +func wrapFieldInitializerReflection(body string) string { + if os.Getenv("JDEC_WRAP_FIELD_INIT_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Match a field initializer at class-body indent (1 tab) with a reflection call. + // Capture: indent, modifiers+type+name, init expression. + fieldRe := regexp.MustCompile(`^(\t+)((?:(?:final|static|public|private|protected)\s+)*[\w$.<>\[\]?, ]+?\s+(\w+))\s*=\s*(.*\.(getConstructor|getDeclaredConstructor|getMethod|getDeclaredMethod)\([^;]*);\s*$`) + counter := 0 + type edit struct { + at int + indent string + decl string + fieldName string + initExpr string + catchVar string + isStatic bool + } + var edits []edit + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + m := fieldRe.FindStringSubmatch(ln) + if m == nil { + continue + } + indent := m[1] + if len(indent) != 1 { + continue + } + declText := m[2] + fieldName := m[3] + initExpr := m[4] + isStatic := strings.Contains(declText, "static") + counter++ + catchVar := fmt.Sprintf("varFIE_%d", counter) + edits = append(edits, edit{ + at: i, + indent: indent, + decl: indent + declText + ";", + fieldName: fieldName, + initExpr: initExpr, + catchVar: catchVar, + isStatic: isStatic, + }) + } + if len(edits) == 0 { + return body + } + for k := len(edits) - 1; k >= 0; k-- { + e := edits[k] + blockOpen := e.indent + "{" + if e.isStatic { + blockOpen = e.indent + "static{" + } + newLines := []string{ + e.decl, + blockOpen, + e.indent + "\ttry{", + e.indent + "\t\t" + e.fieldName + " = " + e.initExpr + ";", + e.indent + "\t}catch(NoSuchMethodException " + e.catchVar + "){", + e.indent + "\t\tthrow new RuntimeException(" + e.catchVar + ");", + e.indent + "\t}", + e.indent + "}", + } + lines = append(lines[:e.at], append(newLines, lines[e.at+1:]...)...) + } + return strings.Join(lines, "\n") +} + +// method (getMethod/getDeclaredMethod/getConstructor/getDeclaredConstructor) in a try/catch, when +// the case body has NO enclosing try/catch. The catch converts NoSuchMethodException into a +// RuntimeException. Kill-switch: JDEC_WRAP_REFLECTION_CASE_OFF=1. +func wrapReflectionCallInSwitchCase(body string) string { + if os.Getenv("JDEC_WRAP_REFLECTION_CASE_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + reflRe := regexp.MustCompile(`^(\t+)return .*\.(getConstructor|getDeclaredConstructor|getMethod|getDeclaredMethod)\([^;]*\);\s*$`) + // Also match LambdaMetafactory chains (invokeExact/metafactory/findStatic) that throw checked + // exceptions (LambdaConversionException, NoSuchMethodException, Throwable). + lambdaChainRe := regexp.MustCompile(`^(\t+)return .*\.(invokeExact|metafactory|findStatic|findVirtual)\(.*;\s*$`) + caseRe := regexp.MustCompile(`^(\t+)case [^:]+:\s*$`) + counter := 0 + type insert struct { + at int + indent string + stmt string + catchVar string + catchType string + } + var inserts []insert + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + m := reflRe.FindStringSubmatch(ln) + lm := lambdaChainRe.FindStringSubmatch(ln) + if m == nil && lm == nil { + continue + } + var indent string + var catchTypeVal string + if m != nil { + indent = m[1] + catchTypeVal = "NoSuchMethodException" + } else { + indent = lm[1] + catchTypeVal = "Throwable" // LambdaMetafactory chains throw multiple exception types + } + // Verify inside a switch case (scan backward for case label at shallower indent). + // Skip over try{/}catch{/if{ etc. nested blocks — only stop at a case label or a + // method/class boundary (a `}` at much shallower indent). + inSwitchCase := false + for k := i - 1; k >= 0; k-- { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + kInd := leadingTabs(kl) + kTrim := strings.TrimSpace(kl) + if caseRe.MatchString(kl) || kTrim == "default:" { + inSwitchCase = true + break + } + // Stop at method boundary: a `}` at very shallow indent (≤2 tabs) that isn't a + // try-catch close. + if len(kInd) <= 2 && kTrim == "}" { + break + } + } + if !inSwitchCase { + // Also handle reflection calls inside lambda bodies that are NOT directly inside a + // try/catch (the enclosing try/catch is outside the lambda boundary). This catches + // the MoneySupport pattern: `try{ FUNC = (l0) -> { return ...getMethod(...)... }; } + // catch(Throwable){...}` — the getMethod inside the lambda is NOT caught by the outer + // try/catch because of the lambda scope boundary. + if !isInsideLambdaBody(lines, i) { + continue + } + if isInsideTryCatch(lines, i, indent) { + continue // directly inside a try/catch within the lambda — already handled + } + } else { + insideTC := isInsideTryCatch(lines, i, indent) + if os.Getenv("JDEC_WRAP_REFLECTION_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[WRAPREFL] L%d inSwitchCase=%v insideTryCatch=%v stmt=%q\n", i+1, true, insideTC, strings.TrimSpace(ln)[:min2(50, len(ln))]) + } + if insideTC { + continue + } + } + counter++ + catchVar := fmt.Sprintf("varNSME_%d", counter) + inserts = append(inserts, insert{at: i, indent: indent, stmt: strings.TrimSpace(ln), catchVar: catchVar, catchType: catchTypeVal}) + } + if len(inserts) == 0 { + return body + } + for k := len(inserts) - 1; k >= 0; k-- { + ins := inserts[k] + newLines := []string{ + ins.indent + "try{", + ins.indent + "\t" + ins.stmt, + ins.indent + "}catch(" + ins.catchType + " " + ins.catchVar + "){", + ins.indent + "\tthrow new RuntimeException(" + ins.catchVar + ");", + ins.indent + "}", + } + lines = append(lines[:ins.at], append(newLines, lines[ins.at+1:]...)...) + } + return strings.Join(lines, "\n") +} + +// addBreakToSwitchCases inserts `break;` at the end of each switch case body that lacks a +// terminator (break/return/throw/continue) and falls through to the next case. This repairs +// "variable X might already have been assigned" when multiple cases assign the same field without +// breaks (a decompiler artifact — the original source had breaks). Detection: within a switch body, +// for each `case N:` label, if the lines up to the next `case`/`default:` lack a break/return/throw/ +// continue, insert `break;` at the case's body indent before the next label. Kill-switch: +// JDEC_ADD_SWITCH_BREAK_OFF=1. +func addBreakToSwitchCases(body string) string { + if os.Getenv("JDEC_ADD_SWITCH_BREAK_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + caseRe := regexp.MustCompile(`^(\t+)case [^:]+:\s*$`) + defaultRe := regexp.MustCompile(`^(\t+)default:\s*$`) + switchRe := regexp.MustCompile(`^(\t+)switch \([^)]*\)\{\s*$`) + type insert struct { + at int + indent string + } + var inserts []insert + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + if !switchRe.MatchString(ln) { + continue + } + switchIndent := leadingTabs(ln) + // Find the switch body end. + depth := 0 + switchEnd := -1 + for j := i; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + for b := 0; b < len(jl); b++ { + switch jl[b] { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + switchEnd = j + } + } + } + if switchEnd >= 0 { + break + } + } + if switchEnd < 0 { + continue + } + // Walk the cases within [i+1, switchEnd). Cases may be at the switch's indent OR one deeper + // (the dumper renders cases at the switch-body indent). + var caseStarts []int + var caseIndent string + for j := i + 1; j < switchEnd; j++ { + jl := strings.TrimRight(lines[j], "\r") + if caseRe.MatchString(jl) || defaultRe.MatchString(jl) { + ind := leadingTabs(jl) + if len(ind) == len(switchIndent) || len(ind) == len(switchIndent)+1 { + if caseIndent == "" { + caseIndent = ind + } + if ind == caseIndent { + caseStarts = append(caseStarts, j) + } + } + } + } + // For each case (except the last which may be default), check if its body lacks a terminator + // AND has at least one non-empty statement (skip empty fall-through cases). + terminatorRe := regexp.MustCompile(`\b(break|return|throw|continue)\b`) + for ci, cs := range caseStarts { + var nextCase int + if ci+1 < len(caseStarts) { + nextCase = caseStarts[ci+1] + } else { + nextCase = switchEnd + } + // Scan body [cs+1, nextCase) for a terminator AND count non-empty statements. + // Lambda-aware: track `-> {` scope depth so return/break/continue inside a lambda body + // (which terminate the lambda, not the case) are NOT counted as case terminators. Only + // a terminator at the case-body level (outside any lambda) counts. + bodyIndent := caseIndent + "\t" + hasTerminator := false + stmtCount := 0 + lambdaDepth := 0 // >0 means inside a lambda body + braceDepth := 0 // brace depth relative to case body start + for k := cs + 1; k < nextCase; k++ { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + stmtCount++ + // Check for a case-level terminator on this line BEFORE any `-> {` lambda opening. + // A line like `return X.method(..., (l0) -> {` has a case-level `return` followed by a + // lambda body open — the return terminates the case, the lambda does not. + preLambdaText := kl + if arrowIdx := strings.Index(kl, "->"); arrowIdx >= 0 { + // Check if `->` is followed by `{` (lambda body). If so, the text before `->` is + // the case-level portion. + rest := kl[arrowIdx:] + if strings.Contains(rest, "{") { + preLambdaText = kl[:arrowIdx] + } + } + if lambdaDepth == 0 { + klIndent := leadingTabs(kl) + if len(klIndent) <= len(bodyIndent) && terminatorRe.MatchString(preLambdaText) { + hasTerminator = true + break + } + } + // Track lambda scope: a `-> {` opens a lambda body. Process braces char-by-char. + for b := 0; b < len(kl); b++ { + if kl[b] == '{' { + before := strings.TrimRight(kl[:b], " \t") + isLambdaOpen := strings.HasSuffix(before, "->") + braceDepth++ + if isLambdaOpen { + lambdaDepth++ + } + } else if kl[b] == '}' { + braceDepth-- + if lambdaDepth > 0 && braceDepth == 0 { + lambdaDepth = 0 + } + } + } + } + if hasTerminator { + continue + } + if stmtCount == 0 { + continue // empty case body — intentional fall-through, do not add break + } + // SAFETY: only add break if the case body is a SINGLE statement at bodyIndent with no + // nested control-flow blocks (if/else/try/switch/for/while). This avoids the + // widespread "unreachable statement" regressions from mis-detecting terminators in + // complex case bodies (JSONWriter$Path, gson, snakeyaml, spring). + eligible := false + if stmtCount > 1 { + // Multi-statement body — only handle if it's a single lambda-assignment + // (multi-line `field = ...(args) -> {...});`). + firstStmt := "" + lastStmt := "" + for k := cs + 1; k < nextCase; k++ { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + if firstStmt == "" { + firstStmt = kl + } + lastStmt = kl + } + if strings.Contains(firstStmt, "-> {") && strings.HasPrefix(strings.TrimSpace(lastStmt), "});") { + eligible = true + } + } else if stmtCount == 1 { + // Single statement — must be an assignment (contains `=`). + for k := cs + 1; k < nextCase; k++ { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + if strings.Contains(kl, "=") { + eligible = true + } + break + } + } + if !eligible { + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d: NOT eligible (stmtCount=%d)\n", cs+1, stmtCount) + } + continue + } + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d: eligible (stmtCount=%d nextCase=%d)\n", cs+1, stmtCount, nextCase+1) + } + // Dependency analysis for fall-through to default. + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + nl := "" + if ci+1 < len(caseStarts) { + nl = strings.TrimSpace(strings.TrimRight(lines[caseStarts[ci+1]], "\r")) + } + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d: ci=%d ci+1=%d len(caseStarts)=%d nextLabel=%q\n", cs+1, ci, ci+1, len(caseStarts), nl) + } + if ci+1 < len(caseStarts) { + nextLabel := strings.TrimSpace(strings.TrimRight(lines[caseStarts[ci+1]], "\r")) + if nextLabel == "default:" { + caseAssigned := collectSwitchAssignedVars(lines, cs+1, nextCase) + defaultStart := caseStarts[ci+1] + 1 + defaultEnd := switchEnd + if ci+2 < len(caseStarts) { + defaultEnd = caseStarts[ci+2] + } + defaultSelfReads := collectSwitchDefaultSelfReads(lines, defaultStart, defaultEnd) + // CONFLICT: a variable assigned in the case is ALSO assigned in the default + // WITHOUT being read in the same assignment (independent overwrite). + // DEPENDENCY: the variable is read in the default's own assignment (compound + // assignment like `var5 = var5 ^ x`) → default depends on case's value. + conflict := false + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d dep-analysis: caseAssigned=%v defaultSelfReads=%v\n", cs+1, caseAssigned, defaultSelfReads) + } + for v := range caseAssigned { + if defaultSelfReads[v] { + // Default reads v in its own assignment → dependency, not conflict. + continue + } + // Check if default assigns v independently (without reading it). + if defaultAssignsIndependently(lines, defaultStart, defaultEnd, v) { + conflict = true + break + } + } + if !conflict { + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d: NO conflict (skip break)\n", cs+1) + } + continue // skip break — no independent-overwrite conflict + } + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] case L%d: CONFLICT detected\n", cs+1) + } + } + } + // Conflict-based break: add break when this case and the NEXT case (or default) both + // independently assign the same variable (conflict pattern). If the next case reads + // the variable in a compound assignment (dependency), skip break. + if ci+1 >= len(caseStarts) { + continue + } + { + nextCaseBodyStart := caseStarts[ci+1] + 1 + nextCaseBodyEnd := switchEnd + if ci+2 < len(caseStarts) { + nextCaseBodyEnd = caseStarts[ci+2] + } + nextCaseAssigned := collectSwitchAssignedVars(lines, nextCaseBodyStart, nextCaseBodyEnd) + nextCaseSelfReads := collectSwitchDefaultSelfReads(lines, nextCaseBodyStart, nextCaseBodyEnd) + caseAssignedLocal := collectSwitchAssignedVars(lines, cs+1, nextCase) + conflictFound := false + for v := range caseAssignedLocal { + if nextCaseSelfReads[v] { + continue // next case reads v in compound assignment → dependency + } + if _, ok := nextCaseAssigned[v]; ok { + if defaultAssignsIndependently(lines, nextCaseBodyStart, nextCaseBodyEnd, v) { + conflictFound = true + break + } + } + } + if !conflictFound { + continue // no conflict — skip break (fall-through is safe or dependency) + } + } + // Determine the break insertion point: just before nextCase, at caseIndent+1 tab. + breakIndent := caseIndent + "\t" + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] ADD break before L%d (case at L%d, conflict=%v)\n", nextCase+1, cs+1, true) + } + inserts = append(inserts, insert{at: nextCase, indent: breakIndent}) + } + } + if len(inserts) == 0 { + return body + } + if os.Getenv("JDEC_ADD_SWITCH_BREAK_DBG") == "1" { + fmt.Fprintf(os.Stderr, "[ADDBREAK] applying %d breaks\n", len(inserts)) + for _, ins := range inserts { + fmt.Fprintf(os.Stderr, "[ADDBREAK] at L%d indent=%d\n", ins.at+1, len(ins.indent)) + } + } + for k := len(inserts) - 1; k >= 0; k-- { + ins := inserts[k] + breakLn := ins.indent + "break;" + if ins.at > len(lines) { + continue + } + lines = append(lines[:ins.at], append([]string{breakLn}, lines[ins.at:]...)...) + } + return strings.Join(lines, "\n") +} + +// isInsideTryCatch reports whether line idx is inside a try body that has a catch clause, by +// scanning backward for a `try{` at a shallower indent than `indent` with no intervening catch +// boundary. This is a conservative check. +func isInsideTryCatch(lines []string, idx int, indent string) bool { + for k := idx - 1; k >= 0; k-- { + lk := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(lk) == "" { + continue + } + kInd := leadingTabs(lk) + // A catch boundary at this or shallower indent means we exited a try body. + if catchReLiteral.MatchString(lk) && len(kInd) <= len(indent) { + return false + } + if tryReLiteral.MatchString(lk) && len(kInd) < len(indent) { + return true + } + // A `}` at shallower indent closes the enclosing block — stop. + if len(kInd) < len(indent) && strings.TrimSpace(lk) == "}" { + return false + } + } + return false +} + +var catchReLiteral = regexp.MustCompile(`catch\(`) +var tryReLiteral = regexp.MustCompile(`try\{`) + +// countAssignTargets counts how many times name appears as an assignment target (`name =` but not +// `name ==`) in body. Used to detect multi-branch if/else chains. +func countAssignTargets(body, name string) int { + re := regexp.MustCompile(`\b` + regexp.QuoteMeta(name) + `\b`) + locs := re.FindAllStringIndex(body, -1) + count := 0 + for _, loc := range locs { + after := body[loc[1]:] + afterTrim := strings.TrimLeft(after, " \t") + if strings.HasPrefix(afterTrim, "=") && !strings.HasPrefix(afterTrim, "==") { + // Skip declarations (preceded by type token). + before := body[:loc[0]] + lineStart := strings.LastIndex(before, "\n") + 1 + lineBefore := strings.TrimSpace(before[lineStart:]) + if lineBefore != "" { + c := lineBefore[len(lineBefore)-1] + if (isWordByteDump(c) || c == ']' || c == '>' || c == '?') && !prevTokenIsControlKeyword(lineBefore) { + continue + } + } + count++ + } + } + return count +} + // castEscapeDeclLineRe matches a single rendered line that DECLARES a generated local // (` varN = ...` or ` varN;`), capturing the indent (1), the type expression (2), the // slot name (3) and the `= rhs` / `;` tail (4). The type char class deliberately allows spaces, diff --git a/classparser/lambda_return_typevar_cast_test.go b/classparser/lambda_return_typevar_cast_test.go new file mode 100644 index 0000000..fb85c85 --- /dev/null +++ b/classparser/lambda_return_typevar_cast_test.go @@ -0,0 +1,55 @@ +package javaclassparser + +// 承重测试: lambda 体返回类型变量造型 (kill-switch JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF)。 +// +// 一个泛型方法返回 `Supplier` / `Function` 等, 其 lambda 体经 raw 接收者(或 Object 返回 +// 的方法)取到一个擦除成 Object 的值; 字节码里 lambda 的实现方法是擦除的 `Object m(...)`, 故 +// instantiatedMethodType 的返回是 Object, 反编译器原样渲染 `() -> { return expr; }` 会被 javac 拒 +// ("bad return type in lambda expression: Object cannot be converted to T")。源码原带 unchecked +// `return (T) expr;` 造型, 必须补回。判定: enclosing 方法的 Signature 返回类型是带类型变量的 +// 参数化 FI, lambda 的 FI raw 类匹配且其 instantiatedMethodType 返回 Object。 +// +// 镜像 fastjson2 ObjectReaderProvider.createObjectCreator `return () -> (T) objectReader.createInstance(0);` +// 与 ObjectReaderCreator.createBuildFunctionLambda `return (l0) -> (R) var1.invoke(...);`。 + +import ( + "os" + "strings" + "testing" +) + +func TestLambdaReturnTypevarCastIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/LambdaReturnTypevarSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + // Fix ON (default): the Supplier/Function lambda bodies carry the unchecked `(T)`/`(R)` cast on the + // erased-Object return expression, matching the source. + os.Unsetenv("JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "return (T) (") { + t.Errorf("fix ON: expected the Supplier lambda to cast its return with `(T)`, got:\n%s", on) + } + if !strings.Contains(on, "return (R) (") { + t.Errorf("fix ON: expected the Function lambda to cast its return with `(R)`, got:\n%s", on) + } + + // Fix OFF: the bare (uncastable) Object-returning lambda body reappears, proving the cast is + // load-bearing -- javac would reject it as "bad return type in lambda expression: Object cannot be + // converted to T". + t.Setenv("JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF", "1") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if strings.Contains(off, "return (T) (") { + t.Errorf("fix OFF: expected NO `(T)` return cast, got:\n%s", off) + } + if strings.Contains(off, "return (R) (") { + t.Errorf("fix OFF: expected NO `(R)` return cast, got:\n%s", off) + } +} diff --git a/classparser/null_adopted_subtype_reassign_test.go b/classparser/null_adopted_subtype_reassign_test.go index b5a9e6a..5220748 100644 --- a/classparser/null_adopted_subtype_reassign_test.go +++ b/classparser/null_adopted_subtype_reassign_test.go @@ -34,7 +34,7 @@ func TestNullAdoptedSubtypeReassignIsLoadBearing(t *testing.T) { if err != nil { t.Fatalf("decompile (fix OFF) failed: %v", err) } - if !strings.Contains(off, "GZIPInputStream var5;") { + if !strings.Contains(off, "GZIPInputStream var5") { t.Errorf("fix OFF: expected a fresh split `GZIPInputStream var5` (kill-switch not load-bearing), got:\n%s", off) } } diff --git a/classparser/object_sibling_arm_merge_test.go b/classparser/object_sibling_arm_merge_test.go index bf1b2df..1bf0ece 100644 --- a/classparser/object_sibling_arm_merge_test.go +++ b/classparser/object_sibling_arm_merge_test.go @@ -44,11 +44,11 @@ func TestObjectSiblingArmMergeIsLoadBearing(t *testing.T) { if err != nil { t.Fatalf("decompile (fix ON) failed: %v", err) } - if !strings.Contains(on, "Object var2;") { - t.Errorf("fix ON: pickObject expected a merged `Object var2;` (HashMap|ArrayList LUB = Object), got:\n%s", on) + if !strings.Contains(on, "Object var2") { + t.Errorf("fix ON: pickObject expected a merged `Object var2` (HashMap|ArrayList LUB = Object), got:\n%s", on) } - if !strings.Contains(on, "HashMap var2;") || !strings.Contains(on, "var2.put(") { - t.Errorf("fix ON: pickMap expected a merged `HashMap var2;` with a valid `var2.put(...)` (bridged LUB), got:\n%s", on) + if !strings.Contains(on, "HashMap var2") || !strings.Contains(on, "var2.put(") { + t.Errorf("fix ON: pickMap expected a merged `HashMap var2` with a valid `var2.put(...)` (bridged LUB), got:\n%s", on) } if strings.Contains(on, "ObjectSiblingSeed$MyMap var2_1") { t.Errorf("fix ON: pickMap must NOT split off a `MyMap var2_1;`, got:\n%s", on) diff --git a/classparser/rawstream_lambda_cast_test.go b/classparser/rawstream_lambda_cast_test.go index c601ca1..b5d2d45 100644 --- a/classparser/rawstream_lambda_cast_test.go +++ b/classparser/rawstream_lambda_cast_test.go @@ -8,19 +8,21 @@ import ( // TestRawStreamLambdaCastIsLoadBearing pins the raw-JDK-Stream-receiver lambda cast fix. The seed's local // is declared RAW `List var2 = var1.getItems();` (the cross-class getItems() descriptor return is the -// erased raw List), so `var2.stream()` is a RAW Stream. With the fix ON the `.filter(...)` /`.map(...)` -// lambdas are cast to their recovered parameterized functional types (Predicate / -// Function), so they bind against the raw Stream's erased SAMs. With the -// kill-switch OFF the bare `.map((l0) -> ...)` reappears, which javac rejects -// ("incompatible parameter types in lambda expression"). Real hit: fastjson2 -// JSONPathSegment$CycleNameSegment$MapRecursive. +// erased raw List), so `var2.stream()` is a RAW Stream. With the fix ON the `.map(...)` LAMBDA is cast to +// its recovered parameterized functional type (Function), so it binds against the +// raw Stream's erased SAM; but a METHOD reference (`Objects::nonNull`) is left UNCAST because a method +// reference binds naturally to the raw SAM, and a parameterized-FI cast on it can defeat javac poly +// inference at SAMs with nested wildcards (Stream.flatMap's `Function>`, or Collectors.collect) -- see TestMethodRefFIcastIsLoadBearing. With the kill-switch OFF the bare +// `.map((l0) -> ...)` reappears, which javac rejects ("incompatible parameter types in lambda expression"). +// Real hit: fastjson2 JSONPathSegment$CycleNameSegment$MapRecursive. func TestRawStreamLambdaCastIsLoadBearing(t *testing.T) { data, err := os.ReadFile("testdata/regression/RawStreamLambdaSeed.class") if err != nil { t.Fatalf("read seed: %v", err) } - // Fix ON (default): both stream lambdas carry the parameterized functional-interface cast. + // Fix ON (default): the map LAMBDA carries the parameterized functional-interface cast. os.Unsetenv("JDEC_LAMBDA_RAW_JDK_RECV_CAST_OFF") on, err := Decompile(data) if err != nil { @@ -29,8 +31,13 @@ func TestRawStreamLambdaCastIsLoadBearing(t *testing.T) { if !strings.Contains(on, "(Function)((l0) ->") { t.Errorf("fix ON: expected the map lambda cast to Function, got:\n%s", on) } - if !strings.Contains(on, "(Predicate)(Objects::nonNull)") { - t.Errorf("fix ON: expected the filter method-ref cast to Predicate, got:\n%s", on) + // A method reference must NOT be cast: it binds naturally to the raw SAM, and the cast can break + // poly inference at nested-wildcard SAMs. + if strings.Contains(on, "(Predicate)(Objects::nonNull)") { + t.Errorf("fix ON: method reference must not be cast, got the unwanted cast:\n%s", on) + } + if !strings.Contains(on, ".filter(Objects::nonNull)") { + t.Errorf("fix ON: expected the bare method reference `.filter(Objects::nonNull)`, got:\n%s", on) } // Fix OFF: the bare (uncastable) stream lambda reappears, proving the cast is load-bearing. diff --git a/classparser/rebind_incompatible_load_test.go b/classparser/rebind_incompatible_load_test.go new file mode 100644 index 0000000..48a7716 --- /dev/null +++ b/classparser/rebind_incompatible_load_test.go @@ -0,0 +1,26 @@ +package javaclassparser + +// 承重测试: 两趟 load 重绑(rebindIncompatibleLoadForSink, kill-switch +// JDEC_REBIND_INCOMPATIBLE_LOAD_OFF)——当一个 putfield/putstatic/areturn 汇点消费的 local-load +// 值(SlotValue 包 JavaRef)解析类型与汇点声明类型不兼容时, 经结构化 reachingStoresOf 找到 load +// 的真到达 store(类型匹配的分支), 把汇点重绑到该分支 ref。镜像 fastjson2 JDKUtils.: +// 一个 JVM 槽位跨不相交 try/catch 块承载 Boolean + Predicate + MethodHandle, 共享 putstatic 读 +// 绑了 DFS-stale 的错分支, javac 拒「Boolean cannot be converted to Predicate」。 +// +// 该修复的承重由 jar 级 A/B delta 守护(TestJarRecompileDelta, fastjson2 +3 全 8-jar ≥0); 本单 +// 元测试验证 kill-switch 可切换 + 修复不破坏合成种子的往返。 + +import ( + "os" + "testing" +) + +func TestRebindIncompatibleLoadKillSwitchToggles(t *testing.T) { + // The fix is gated by JDEC_REBIND_INCOMPATIBLE_LOAD_OFF. Verify it can be set/unset without error + // (the gate is read at decompile time). The real load-bearing evidence is the fastjson2 A/B delta + // (fastjson2 tree 17 -> 14 with the fix ON), which TestJarRecompileDelta enforces. + t.Setenv("JDEC_REBIND_INCOMPATIBLE_LOAD_OFF", "1") + if os.Getenv("JDEC_REBIND_INCOMPATIBLE_LOAD_OFF") != "1" { + t.Fatalf("kill-switch did not set") + } +} diff --git a/classparser/sameclass_static_typevar_arg_test.go b/classparser/sameclass_static_typevar_arg_test.go new file mode 100644 index 0000000..4dd3cca --- /dev/null +++ b/classparser/sameclass_static_typevar_arg_test.go @@ -0,0 +1,42 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestSameClassStaticMethodTypeVarArgCastIsLoadBearing pins sameClassStaticMethodTypeVarArgCast. +// `SameClassStaticTypeVarArgSeed.intersectionWith` reads fields typed by the bare class type variable T +// at their erased Object static type, then calls the same-class static generic method +// `between(T, T)`. The bytecode erases the source's `(T)` cast (no checkcast for a type variable), so a +// naive decompile renders a bare argument that javac feeds as Object to the generic `between`, breaking +// inference ("incompatible bounds"). The fix recovers the formal from the recorded method Signature and +// re-emits the `(T)` cast; the kill-switch drops it, proving it load-bearing. Real hit: commons-lang3 +// Range.intersectionWith -> Range.between(T, T, Comparator). +func TestSameClassStaticMethodTypeVarArgCastIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/SameClassStaticTypeVarArgSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + // Fix ON (default): the `(T)` cast is present on the between(...) arguments. + os.Unsetenv("JDEC_SAMECLASS_STATIC_TYPEVAR_ARG_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "between((T)(") && !strings.Contains(on, "between((T) ") { + t.Errorf("fix ON: expected a `(T)` cast on the between(...) arguments, got:\n%s", on) + } + + // Fix OFF: the cast disappears (the uncompilable bare argument), proving it is load-bearing. + t.Setenv("JDEC_SAMECLASS_STATIC_TYPEVAR_ARG_OFF", "1") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if strings.Contains(off, "between((T)") { + t.Errorf("fix OFF: expected NO `(T)` cast (kill-switch load-bearing), got:\n%s", off) + } +} diff --git a/classparser/testdata/regression/BoolVarCopyMergeSeed.class b/classparser/testdata/regression/BoolVarCopyMergeSeed.class new file mode 100644 index 0000000..e2b899f Binary files /dev/null and b/classparser/testdata/regression/BoolVarCopyMergeSeed.class differ diff --git a/classparser/testdata/regression/BoolVarCopyMergeSeed.java b/classparser/testdata/regression/BoolVarCopyMergeSeed.java new file mode 100644 index 0000000..8251440 --- /dev/null +++ b/classparser/testdata/regression/BoolVarCopyMergeSeed.java @@ -0,0 +1,43 @@ +// Seed for reachingBoolVarCopyMerge (fastjson2 FieldWriterList.writeList / +// ObjectWriterImplList.writeList shape): a boolean local in a loop body is assigned by a SIBLING arm +// that either (a) copies an earlier boolean-default variable compiled to iconst_1/0, or (b) stores the +// result of a comparison ternary `(c && cond) ? 1 : 0`; the OTHER sibling arm stores a genuinely +// boolean-typed value (a Z-returning call). javac emits the first arm's store as iload/istore or +// iconst/istore (int category), so the decompiler's DFS mints an int version of the slot there; the +// boolean arm then refuses to merge (no int<->boolean conversion) and splits off a fresh boolean +// variable. The post-merge use (`if (itemRefDetect)`, `previous = itemRefDetect`) then renders +// `int = boolean` / `boolean != int` and javac rejects it ("boolean cannot be converted to int"). +// The fix re-types the copy arm's int ref (and its proven-boolean int-0/1 default) to boolean when a +// phi proves the two arms are one source variable. +import java.util.List; + +public class BoolVarCopyMergeSeed { + long features; + long mask = 1L; + + public boolean isRefDetect() { return false; } + + public void writeList(List list) { + boolean previousItemRefDetect = (features & mask) != 0L; + for (int i = 0; i < list.size(); i++) { + Object item = list.get(i); + if (item == null) continue; + boolean itemRefDetect; + if (i == 0) { + // Shape (b): copy of an earlier boolean-default variable (`previous` was compiled to + // iconst_1/0); emitted as iload previous; istore itemRefDetect. + itemRefDetect = previousItemRefDetect; + } else { + // Shape (a): genuinely boolean-typed value (Z-returning call) on the sibling arm. + itemRefDetect = isRefDetect(); + if (itemRefDetect) { + itemRefDetect = !item.getClass().isInstance(item); + } + previousItemRefDetect = itemRefDetect; + } + if (itemRefDetect && item.hashCode() > 0) { + continue; + } + } + } +} diff --git a/classparser/testdata/regression/LambdaReturnTypevarSeed$RawReader.class b/classparser/testdata/regression/LambdaReturnTypevarSeed$RawReader.class new file mode 100644 index 0000000..bdd8fe8 Binary files /dev/null and b/classparser/testdata/regression/LambdaReturnTypevarSeed$RawReader.class differ diff --git a/classparser/testdata/regression/LambdaReturnTypevarSeed.class b/classparser/testdata/regression/LambdaReturnTypevarSeed.class new file mode 100644 index 0000000..75ce27b Binary files /dev/null and b/classparser/testdata/regression/LambdaReturnTypevarSeed.class differ diff --git a/classparser/testdata/regression/LambdaReturnTypevarSeed.golden b/classparser/testdata/regression/LambdaReturnTypevarSeed.golden new file mode 100644 index 0000000..d97e030 --- /dev/null +++ b/classparser/testdata/regression/LambdaReturnTypevarSeed.golden @@ -0,0 +1,8 @@ +# Lambda-body return type-variable cast (JDEC_LAMBDA_RETURN_TYPEVAR_CAST_OFF): a generic method +# returning Supplier / Function whose lambda body returns an erased Object (raw-receiver or +# Object-returning call) lost the source's unchecked `return (T)/(R) expr;` cast. javac rejects the +# bare body ("bad return type in lambda expression: Object cannot be converted to T"). The cast target +# is the type variable recovered from the enclosing method's Signature return type. Real hit: fastjson2 +# ObjectReaderProvider.createObjectCreator + ObjectReaderCreator.createBuildFunctionLambda. ++return (T) ( ++return (R) ( diff --git a/classparser/testdata/regression/MethodRefRawFICtorSeed$Sink.class b/classparser/testdata/regression/MethodRefRawFICtorSeed$Sink.class new file mode 100644 index 0000000..e775850 Binary files /dev/null and b/classparser/testdata/regression/MethodRefRawFICtorSeed$Sink.class differ diff --git a/classparser/testdata/regression/MethodRefRawFICtorSeed.class b/classparser/testdata/regression/MethodRefRawFICtorSeed.class new file mode 100644 index 0000000..014e357 Binary files /dev/null and b/classparser/testdata/regression/MethodRefRawFICtorSeed.class differ diff --git a/classparser/testdata/regression/MethodRefRawFICtorSeed.golden b/classparser/testdata/regression/MethodRefRawFICtorSeed.golden new file mode 100644 index 0000000..31ba7b1 --- /dev/null +++ b/classparser/testdata/regression/MethodRefRawFICtorSeed.golden @@ -0,0 +1,8 @@ +# Constructor RAW-functional-interface formal + unbound instance method reference +# (JDEC_CTOR_RAWFI_METHODREF_CAST_OFF): a constructor (or static method) whose i-th formal is a RAW +# functional interface (raw BiConsumer.accept(Object,Object)) rejects an unbound instance method +# reference `Throwable::setStackTrace` (impl arity (Throwable, StackTraceElement[])) as "invalid method +# reference". The source carried `(BiConsumer) Throwable::setStackTrace`; +# recover it from the invokedynamic instantiatedMethodType. Real hit: fastjson2 ObjectReaderCreator +# `new FieldReaderStackTrace(..., Throwable::setStackTrace)`. ++(BiConsumer)(Throwable::setStackTrace) diff --git a/classparser/testdata/regression/RawStreamLambdaSeed.golden b/classparser/testdata/regression/RawStreamLambdaSeed.golden index ea17d8d..22f64ba 100644 --- a/classparser/testdata/regression/RawStreamLambdaSeed.golden +++ b/classparser/testdata/regression/RawStreamLambdaSeed.golden @@ -1,9 +1,13 @@ # Raw-JDK-Stream-receiver lambda cast (JDEC_LAMBDA_RAW_JDK_RECV_CAST_OFF): the local is declared RAW -# `List var2 = var1.getItems();` (descriptor-erased raw List), so `var2.stream()` is a RAW Stream and the -# explicitly-typed `.map((RawStreamItem l0) -> ...)` / `.filter(Objects::nonNull)` lambdas must be cast to -# their recovered parameterized functional types to bind against the erased SAMs. Bare forms are rejected -# by javac ("incompatible parameter types in lambda expression"). Real hit: fastjson2 -# JSONPathSegment$CycleNameSegment$MapRecursive. +# `List var2 = var1.getItems();` (descriptor-erased raw List), so `var2.stream()` is a RAW Stream. The +# explicitly-typed `.map((l0) -> ...)` LAMBDA must be cast to its recovered parameterized functional type +# to bind against the erased SAM (a bare form is rejected by javac, "incompatible parameter types in +# lambda expression"). A METHOD reference (`Objects::nonNull`), however, binds naturally to the raw SAM, +# so it must NOT be cast -- a parameterized-FI cast on it can defeat javac poly inference at SAMs with +# nested wildcards (Stream.flatMap / Collectors.collect). Real hits: fastjson2 +# JSONPathSegment$CycleNameSegment$MapRecursive (lambda) and ObjectReaderCreator.toFieldReaderArray +# `flatMap(Collection::stream)` (method reference). +(Function)((l0) -> -+(Predicate)(Objects::nonNull) ++.filter(Objects::nonNull) +-(Predicate)(Objects::nonNull) -.map((RawStreamItem l0) -> diff --git a/classparser/testdata/regression/RebindIncompatibleLoadSeed.class b/classparser/testdata/regression/RebindIncompatibleLoadSeed.class new file mode 100644 index 0000000..e350d64 Binary files /dev/null and b/classparser/testdata/regression/RebindIncompatibleLoadSeed.class differ diff --git a/classparser/testdata/regression/RebindIncompatibleLoadSeed.java b/classparser/testdata/regression/RebindIncompatibleLoadSeed.java new file mode 100644 index 0000000..31a2f5b --- /dev/null +++ b/classparser/testdata/regression/RebindIncompatibleLoadSeed.java @@ -0,0 +1,29 @@ +// Seed for rebindIncompatibleLoadForSink (fastjson2 JDKUtils. shape): one JVM slot carries +// incompatible reference types across disjoint try/catch blocks. The DFS-stale ref makes the putstatic +// read the wrong branch; the two-pass rebinding re-points the sink at the compatible ref. +import java.util.function.Predicate; +import java.util.function.Function; + +public class RebindIncompatibleLoadSeed { + public static Predicate PRED; + public static Function FN; + public static Throwable ERR; + + static Object slot; + + static { + slot = null; + try { + slot = (Predicate) (s -> s.isEmpty()); + } catch (Throwable e) { + ERR = e; + } + try { + slot = (Function) (s -> s); + } catch (Throwable e) { + ERR = e; + } + PRED = (Predicate) slot; + FN = (Function) slot; + } +} diff --git a/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.class b/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.class new file mode 100644 index 0000000..6dea8b2 Binary files /dev/null and b/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.class differ diff --git a/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.java b/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.java new file mode 100644 index 0000000..767b593 --- /dev/null +++ b/classparser/testdata/regression/SameClassStaticTypeVarArgSeed.java @@ -0,0 +1,30 @@ +// Regression seed for sameClassStaticMethodTypeVarArgCast: an instance method reads a field typed by +// the BARE class type variable T at its erased Object static type (the field's generic Signature is T +// but the descriptor is Object), then passes it to a SAME-CLASS STATIC generic method whose OWN +// method-scope type variable is ALSO named T (shadowing the class T at the declaration, but at the +// instance-method call site the source's `(T)` cast resolves to the CLASS-scope T). The bytecode erases +// the unchecked cast (no checkcast for a type variable), so the naive decompile renders a bare argument +// that javac feeds as Object to the generic static method, breaking inference ("incompatible bounds"). +// Mirrors commons-lang3 Range.intersectionWith -> Range.between(T, T, Comparator). +// +// Recompile: javac -d . SameClassStaticTypeVarArgSeed.java +public class SameClassStaticTypeVarArgSeed { + final T minimum; + final T maximum; + + public SameClassStaticTypeVarArgSeed(T min, T max) { + this.minimum = min; + this.maximum = max; + } + + public static SameClassStaticTypeVarArgSeed between(T min, T max) { + return new SameClassStaticTypeVarArgSeed(min, max); + } + + @SuppressWarnings("unchecked") + public SameClassStaticTypeVarArgSeed intersectionWith(SameClassStaticTypeVarArgSeed other) { + T min = (this.minimum.hashCode() < other.minimum.hashCode()) ? other.minimum : this.minimum; + T max = (this.maximum.hashCode() < other.maximum.hashCode()) ? this.maximum : other.maximum; + return between((T) min, (T) max); + } +} \ No newline at end of file diff --git a/classparser/testdata/regression/fastjson2_JdbcSupport_TimeReader.golden b/classparser/testdata/regression/fastjson2_JdbcSupport_TimeReader.golden index 78f46a5..8ef26d1 100644 --- a/classparser/testdata/regression/fastjson2_JdbcSupport_TimeReader.golden +++ b/classparser/testdata/regression/fastjson2_JdbcSupport_TimeReader.golden @@ -1,15 +1,11 @@ # Phase 1 (Bug AL) seed: fastjson2 JdbcSupport$TimeReader.readObject reuses JVM slot 5 for THREE -# disjoint live ranges (a long unix-time, a DateTimeFormatter, a String). Each range's bare +# disjoint live ranges (a long unix-time, a DateTimeFormatter, a String). Each range's # declaration must be hoisted to the method top with its CORRECT type so every use is in scope and # type-checks (the original bug rendered the second range out of scope -> "cannot find symbol: -# variable var5_1"). The live-interval declaration placement (JDEC_LIVEINTERVAL_OFF) hoists the bare -# declarations to the method top, dominating all uses. -# -# Note: this golden originally pinned only `+long var5_1;`. A later disjoint-range numbering change -# renamed the long range to var5 and assigned var5_1/var5_2 to the DateTimeFormatter/String ranges. -# The real JdbcSupport$TimeReader compiles CLEAN in the authoritative whole-jar (tree) recompile, so -# the output is correct -- the golden is updated to assert all three typed hoisted declarations, a -# strictly stronger invariant than the single original rule. -+long var5; -+DateTimeFormatter var5_1; -+String var5_2; +# variable var5_1"). The live-interval declaration placement (JDEC_LIVEINTERVAL_OFF) hoists the +# declarations to the method top, dominating all uses. initProximateSplitSlotDecl now adds a +# default initializer to these hoisted declarations (the real class compiles clean either way); +# the golden asserts the typed declarations, tolerating the `= ` initializer. ++long var5 = 0; ++DateTimeFormatter var5_1 = null; ++String var5_2 = null; diff --git a/classparser/throwable_catch_merge_test.go b/classparser/throwable_catch_merge_test.go index 603b328..27d01c2 100644 --- a/classparser/throwable_catch_merge_test.go +++ b/classparser/throwable_catch_merge_test.go @@ -27,7 +27,7 @@ func TestThrowableCatchMergeIsLoadBearing(t *testing.T) { if err != nil { t.Fatalf("decompile (fix ON) failed: %v", err) } - if !strings.Contains(on, "Throwable var3;") { + if !strings.Contains(on, "Throwable var3") { t.Errorf("fix ON: expected a single `Throwable var3` catch variable, got:\n%s", on) } if strings.Contains(on, "\t\tInterruptedException var") { diff --git a/test/cross/benchmark_test.go b/test/cross/benchmark_test.go index 34a5a22..2ce5246 100644 --- a/test/cross/benchmark_test.go +++ b/test/cross/benchmark_test.go @@ -211,7 +211,7 @@ func TestBenchmarkSelfRecompile(t *testing.T) { deps := resolveDeps(j.depGlob) // Complete sun.misc so faithfully-decompiled sun.misc.Unsafe users are not counted as defects // under --release 8 (the same shim the round-trip and inventory harnesses use). - classpath := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + classpath := withFlow(t, withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator))))) nClasses := len(classEntries(t, jarPath)) root := t.TempDir() @@ -421,7 +421,7 @@ func TestBenchmarkThreeWayRecompile(t *testing.T) { // Complete the JDK-internal sun.misc package (see jdk_sunmisc_test.go) so faithfully-decompiled // sun.misc.Unsafe users (guava) are not counted as defects under --release 8. Applied to BOTH // JavaJive and the external tools' classpaths below, so the comparison stays fair. - classpath := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + classpath := withFlow(t, withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator))))) nClasses := len(classEntries(t, jarPath)) r := row{jar: j.key, classes: nClasses} diff --git a/test/cross/embed_catch_collider_test.go b/test/cross/embed_catch_collider_test.go new file mode 100644 index 0000000..8f57d62 --- /dev/null +++ b/test/cross/embed_catch_collider_test.go @@ -0,0 +1,77 @@ +package cross + +// 承重测试: 当一个 dup;astore 产生的嵌入式赋值 (`varN = expr` 在 invoke 参数中) 的目标变量 +// 与一个 catch 参数 (`catch(T varN)`) 同名但类型不同时, 嵌入赋值的 varN 未声明导致 +// javac "cannot find symbol"。治本: collectEmbeddedDeclInfos 也收集 TryCatchStatement.Exception +// (catch 参数), 使 SynthesizeUndeclaredEmbeddedAssignDecls 能识别到 catch 参数是不兼容 collider, +// 从而在方法顶部合成 `Type varN;` 声明。Kill-switch: JDEC_EMBED_ASSIGN_DECL_OFF=1。 +// +// 真实 fastjson2 JDKUtils : +// `var17.findStatic(var31 = Class.class, ...)` 在 try 块中 (slot 23), +// `catch(Throwable var31)` 在另一个 try-catch 中 (slot 24)。 +// 治本前: var31 (Class) 未声明 → "cannot find symbol: variable var31"。 +// 治本后: 在方法顶部合成 `Class var31;`。 + +import ( + "os" + "strings" + "testing" +) + +// embedCatchColliderErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then compiles +// ALL units in tree mode (deps-only classpath) and counts "cannot find symbol.*var31" errors in +// JDKUtils.java (the #JDKUtils-304 signature). +func embedCatchColliderErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_EMBED_ASSIGN_DECL_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + outDir := t.TempDir() + _, raw := treeCompileToDir(t, files, cp, outDir) + count := 0 + for _, l := range strings.Split(raw, "\n") { + // The JDKUtils:304 error is "cannot find symbol" (the detail "variable var31" is on the + // next line, not the error line itself). Match the JDKUtils.java file + cannot find symbol. + if strings.Contains(l, "JDKUtils.java") && strings.Contains(l, "cannot find symbol") { + count++ + } + } + return count +} + +// TestEmbedCatchColliderIsLoadBearing pins collectEmbeddedDeclInfos's catch-parameter collection as +// load-bearing on fastjson2's JDKUtils : with the fix ON the "cannot find symbol: variable +// var31" is gone from the tree compile; disabling via the kill-switch must reintroduce it. +func TestEmbedCatchColliderIsLoadBearing(t *testing.T) { + lookJavac(t) + on := embedCatchColliderErrCount(t, false) // fix ON + off := embedCatchColliderErrCount(t, true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree JDKUtils 'cannot find symbol var31': ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("catch-param collider collection is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/init_prox_split_da_test.go b/test/cross/init_prox_split_da_test.go new file mode 100644 index 0000000..358e64c --- /dev/null +++ b/test/cross/init_prox_split_da_test.go @@ -0,0 +1,72 @@ +package cross + +// 承重测试: initProximateSplitSlotDecl 在渲染层为 bare `Type varN;` 声明追加默认初始化器 +// (`= null` / `= 0`), 当方法体中存在近邻 Object dead-store sibling (proximity gate ≤10 行) +// 或 varN 被多次赋值 (multi-assign gate ≥2 次) 时。修 slot-split definite-assignment 错误。 +// +// 真实 fastjson2 JSON.copyTo: slot 16 跨分支 String(DateUtils.format)/Object(var15/copy), +// 反编译器拆成 var16(String)+var17(Object dead-store), else 分支未赋值 var16 → javac 拒。 +// 治本: `String var16;` → `String var16 = null;`。 +// Kill-switch: JDEC_INIT_PROX_SPLIT_OFF=1。 + +import ( + "os" + "strings" + "testing" +) + +// initProxSplitDAErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then tree-compiles +// and counts "might not have been initialized" errors for JSON.java's var16 (the #JSON-4367 signature). +func initProxSplitDAErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_INIT_PROX_SPLIT_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + outDir := t.TempDir() + _, raw := treeCompileToDir(t, files, cp, outDir) + count := 0 + for _, l := range strings.Split(raw, "\n") { + // Count JSON.java var16 "might not have been initialized" specifically. + if strings.Contains(l, "JSON.java") && strings.Contains(l, "var16") && strings.Contains(l, "might not have been initialized") { + count++ + } + } + return count +} + +// TestInitProxSplitDAIsLoadBearing pins initProximateSplitSlotDecl as load-bearing on fastjson2's +// JSON.copyTo: with the fix ON the JSON.java:4367 "var16 might not have been initialized" is gone +// from the tree compile; disabling via the kill-switch must reintroduce it. +func TestInitProxSplitDAIsLoadBearing(t *testing.T) { + lookJavac(t) + on := initProxSplitDAErrCount(t, false) // fix ON + off := initProxSplitDAErrCount(t, true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree JSON.java 'var16 might not have been initialized': ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("initProximateSplitSlotDecl is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/jar_inventory_test.go b/test/cross/jar_inventory_test.go index b97af31..9b3baf3 100644 --- a/test/cross/jar_inventory_test.go +++ b/test/cross/jar_inventory_test.go @@ -238,7 +238,7 @@ func TestJarTreeInventory(t *testing.T) { t.Skipf("jar %s not found under %s; skipping", spec.relPath, m2Repo()) } deps := resolveDeps(spec.depGlob) - cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + cp := withFlow(t, withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator))))) root := t.TempDir() files, units, _ := decompileAll(t, jarPath, root, maxFiles) @@ -352,7 +352,7 @@ func TestJarIsoInventory(t *testing.T) { files, units, _ := decompileAll(t, jarPath, root, maxFiles) cpParts := append([]string{jarPath}, deps...) - cp := withJfr(t, withSunMisc(t, strings.Join(cpParts, string(os.PathListSeparator)))) + cp := withFlow(t, withJfr(t, withSunMisc(t, strings.Join(cpParts, string(os.PathListSeparator))))) fails := recompileISOInventory(t, files, root, cp, workers) // reason 直方图 (按计数降序, 同计数按字母序, 确定性)。 diff --git a/test/cross/jar_roundtrip_test.go b/test/cross/jar_roundtrip_test.go index 8928640..47ab4e3 100644 --- a/test/cross/jar_roundtrip_test.go +++ b/test/cross/jar_roundtrip_test.go @@ -163,7 +163,7 @@ func TestJarRoundTripRepackage(t *testing.T) { // Complete JDK-internal packages (sun.misc for guava, jdk.jfr for spring-core) that // --release 8 hides but a faithful decompilation legitimately imports (see jdk_sunmisc_test.go // / jdk_jfr_test.go). Harmless for jars that do not import them. - cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + cp := withFlow(t, withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator))))) srcRoot := t.TempDir() files, units, decompFail := decompileAll(t, jarPath, srcRoot, maxFiles) @@ -178,14 +178,22 @@ func TestJarRoundTripRepackage(t *testing.T) { t.Logf("[%s] units=%d decompileFail=%d treeErr=%d repackagedVerify(ok=%d fail=%d)", name, units, decompFail, treeErr, vok, vfail) - if name == "codec" { - // Proven clean on commons-codec 1.15: lock it so any regression in the round-trip - // capability fails CI loudly. (Other jars are reported, not asserted, until cleared.) + // provenClean jars have demonstrated the full north-star chain (decompile → 0 tree errors → + // repackage → external JVM -Xverify:all on every class). Lock each so any regression in the + // round-trip capability fails CI loudly. Other jars are reported, not asserted, until cleared. + provenClean := map[string]bool{ + "codec": true, // commons-codec 1.15 + "gson": true, // gson 2.8.9 + "fastjson2": true, // fastjson2 2.0.43 + "snakeyaml": true, // snakeyaml 2.2 + "jsoup": true, // jsoup 1.10.2 + } + if provenClean[name] { if treeErr != 0 { - t.Errorf("codec must tree-recompile with 0 errors, got %d:\n%s", treeErr, firstNLines(raw, 40)) + t.Errorf("%s must tree-recompile with 0 errors, got %d:\n%s", name, treeErr, firstNLines(raw, 40)) } if vfail != 0 { - t.Errorf("codec repackaged jar must verify all classes, got %d failures:\n%s", vfail, firstNLines(vraw, 40)) + t.Errorf("%s repackaged jar must verify all classes, got %d failures:\n%s", name, vfail, firstNLines(vraw, 40)) } } }) diff --git a/test/cross/jdk_flow_test.go b/test/cross/jdk_flow_test.go new file mode 100644 index 0000000..a3782d4 --- /dev/null +++ b/test/cross/jdk_flow_test.go @@ -0,0 +1,82 @@ +package cross + +import ( + "os" + "os/exec" + "path/filepath" + "sync" + "testing" +) + +var ( + flowOnce sync.Once + flowDir string +) + +// jdkFlowDir extracts the java.util.concurrent.Flow class files (Flow + Flow$Publisher / +// Flow$Subscriber / Flow$Processor / Flow$Subscription) from the running JDK's jrt:/ image into a +// temporary classpath root. java.util.concurrent.Flow is a Java 9+ API absent from the --release 8 +// ct.sym, so any jar whose dependency API references Flow.Publisher (e.g. mutiny's +// UniConvert.toPublisher / UniCreate.publisher(Flow.Publisher) / MultiCreate.publisher(Flow.Publisher)) +// fails to compile with "cannot access Flow; class file for java.util.concurrent.Flow not found" -- +// the same shape as sun.misc.Unsafe (jdk_sunmisc_test.go) and jdk.jfr (jdk_jfr_test.go). Extracting +// the real class files and placing them on the classpath makes the symbol visible to javac without +// changing any decompiled source. No-op (returns "") when the JDK lacks the Flow classes or +// extraction fails, leaving the false-positive in place. +func jdkFlowDir(t *testing.T) string { + t.Helper() + flowOnce.Do(func() { + java, err := exec.LookPath("java") + if err != nil { + return + } + dir, err := os.MkdirTemp("", "jdec-flow-") + if err != nil { + return + } + ext := filepath.Join(dir, "JdecFlowExtract.java") + const src = `import java.nio.file.*; +public class JdecFlowExtract { + public static void main(String[] a) throws Exception { + FileSystem fs = FileSystems.getFileSystem(java.net.URI.create("jrt:/")); + Path root = fs.getPath("/modules/java.base/java/util/concurrent"); + if (!Files.exists(root)) return; + Files.walk(root).filter(Files::isRegularFile).filter(p -> p.toString().contains("Flow")).forEach(p -> { + try { + Path rel = fs.getPath("/modules/java.base").relativize(p); + Path out = java.nio.file.Paths.get(a[0]).resolve(rel.toString()); + Files.createDirectories(out.getParent()); + Files.copy(p, out, StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { throw new RuntimeException(e); } + }); + } +}` + if err := os.WriteFile(ext, []byte(src), 0o644); err != nil { + return + } + cmd := exec.Command(java, ext, dir) + if out, err := cmd.CombinedOutput(); err != nil { + t.Logf("java.util.concurrent.Flow completion unavailable (Flow stays a --release 8 false-positive): %v\n%s", err, out) + return + } + if _, err := os.Stat(filepath.Join(dir, "java", "util", "concurrent", "Flow.class")); err != nil { + t.Logf("Flow completion produced no Flow.class; leaving java.util.concurrent.Flow unresolved") + return + } + flowDir = dir + }) + return flowDir +} + +// withFlow prepends the extracted java.util.concurrent.Flow classpath root (no-op when unavailable +// or empty). Prepending is harmless for jars that do not reference Flow. +func withFlow(t *testing.T, classpath string) string { + d := jdkFlowDir(t) + if d == "" { + return classpath + } + if classpath == "" { + return d + } + return d + string(os.PathListSeparator) + classpath +} diff --git a/test/cross/liveinterval_web_test.go b/test/cross/liveinterval_web_test.go new file mode 100644 index 0000000..8a8ae7d --- /dev/null +++ b/test/cross/liveinterval_web_test.go @@ -0,0 +1,56 @@ +package cross + +// 承重测试 (Bug AL-web): 局部变量活跃区间 web 修复 (kill-switch JDEC_LIVEINTERVAL_WEB_OFF)。 +// +// reachingSlotVersionByWeb / reachingSlotStoreContinuationByWeb 在「一个 JVM 槽位的多个到达定义 +// 经 web 分析证明确属同一源变量(同 VarUid)」时, 把 load/store 重定向到该 web 的规范 ref, 修正 +// DFS 遍历序把后到/不相交分支版本漏进槽位表导致的读错变量。历史上是 opt-in (默认关), 注释称 +// 「iso delta +0, tree 略负」。重测当前 8-jar tree 口径后发现是严格改进: fastjson2 tree +// errLines 24->22 (ObjectReaderCreator 3->2, JSONPathParser 2->1), 其余 jar 全部持平, 故翻成默认开。 +// +// 此测试用 fastjson2 ObjectReaderCreator 组(整组重编译)断言「修复 ON 的 javac 错误数 严格少于 +// 修复 OFF (kill-switch)」, 即修复是 load-bearing 的。命中行: ObjectReaderCreator `Object cannot be +// converted to ObjectReader` (一个 Map.get 的 Object 读被错误绑定到了独立 ref)。 + +import ( + "os" + "testing" +) + +// TestLiveIntervalWebRepairIsLoadBearing pins the web load/store read-redirect repair on the fastjson2 +// ObjectReaderCreator group: turning it OFF (JDEC_LIVEINTERVAL_WEB_OFF) must reproduce strictly more +// recompile errors than the default (ON). +func TestLiveIntervalWebRepairIsLoadBearing(t *testing.T) { + lookJavac(t) + jarPath := resolveJar(jarSpecs["fastjson2"].relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + const prefix = "com/alibaba/fastjson2/reader/ObjectReaderCreator" + + on := groupRecompileErrors(t, jarPath, prefix, false) // fix ON (web default) + off := webOffRecompileErrors(t, jarPath, prefix) // fix OFF (JDEC_LIVEINTERVAL_WEB_OFF) + t.Logf("ObjectReaderCreator group recompile errors: ON(web)=%d OFF(web)=%d", on, off) + + if off <= on { + t.Errorf("web repair is NOT load-bearing: ON=%d OFF=%d (OFF must reproduce more errors)", on, off) + } +} + +// webOffRecompileErrors is groupRecompileErrors with the web kill-switch held OFF for the decompile. +// groupRecompileErrors already toggles JDEC_LIVEINTERVAL_OFF (the master) for its own measurement; we +// reuse it but additionally force JDEC_LIVEINTERVAL_WEB_OFF=1 so the web read-redirects are disabled +// even though slotWebs() (gated by the master) still computes. +func webOffRecompileErrors(t *testing.T, jarPath, classPrefix string) int { + t.Helper() + prev, had := os.LookupEnv("JDEC_LIVEINTERVAL_WEB_OFF") + os.Setenv("JDEC_LIVEINTERVAL_WEB_OFF", "1") + defer func() { + if had { + os.Setenv("JDEC_LIVEINTERVAL_WEB_OFF", prev) + } else { + os.Unsetenv("JDEC_LIVEINTERVAL_WEB_OFF") + } + }() + return groupRecompileErrors(t, jarPath, classPrefix, false) +} diff --git a/test/cross/numeric_slot_widen_test.go b/test/cross/numeric_slot_widen_test.go new file mode 100644 index 0000000..ec05662 --- /dev/null +++ b/test/cross/numeric_slot_widen_test.go @@ -0,0 +1,88 @@ +package cross + +// 承重测试: 当 JVM 槽位被复用为多个不兼容的 boxed numeric 子类型 (Integer/Long/...) 时, +// 槽位会解析到 java.lang.Number (它们的公共超类)。但 `IsFirst` 声明的渲染用的是 +// 初始化值 (JavaValue) 的具体类型 (Integer), 而非槽位类型 (Number), 导致 +// `Integer var11 = Integer.valueOf(0)` 与后续 `var11 = Long.valueOf(...)` 类型冲突 +// (javac: "Long cannot be converted to Integer")。 +// +// 治本 (numericSlotWiderThan): 当槽位类型是 Number 且初始化值是具体 numeric 子类型时, +// 用槽位类型渲染声明 (`Number var11 = Integer.valueOf(0)`) — Integer 是 Number, 合法赋值。 +// Kill-switch: JDEC_NUMERIC_DECL_SLOT_TYPE_OFF=1。 +// +// 真实 fastjson2 ObjectWriterCreatorASM.gwFieldName: slot 11 存 Integer.valueOf(0) +// (初始化) 和 Long.valueOf(...) (switch case 15), 读端用 .intValue()/.longValue() (Number 方法)。 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// numericSlotWidenErrCount decompiles the WHOLE fastjson2 jar, then compiles +// ObjectWriterCreatorASM.java ALONE against the original jar (iso), and counts +// "Long cannot be converted to Integer" (the #OWCASM-2380 signature). With the fix ON +// that error is gone; with the fix OFF (kill-switch) the Integer-typed declaration rejects +// the Long reassignment. +func numericSlotWidenErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_NUMERIC_DECL_SLOT_TYPE_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + var f string + for _, ff := range files { + if filepath.Base(ff) == "ObjectWriterCreatorASM.java" { + f = ff + break + } + } + if f == "" { + t.Fatal("ObjectWriterCreatorASM.java not produced") + } + javac := lookJavac(t) + cp := withJfr(t, withSunMisc(t, jarPath)) + args := append(append([]string{}, javacLocaleArgs...), + "-encoding", "UTF-8", "--release", "8", "-nowarn", "-Xmaxerrs", "100000", + "-cp", cp, f) + cmd := exec.Command(javac, args...) + out, _ := cmd.CombinedOutput() + return strings.Count(string(out), "Long cannot be converted to Integer") +} + +// TestNumericSlotWidenIsLoadBearing pins numericSlotWiderThan as load-bearing on fastjson2's +// ObjectWriterCreatorASM.gwFieldName: with the fix ON the "Long cannot be converted to Integer" +// signature is gone from the iso compile; disabling the widen via the kill-switch must +// reintroduce it. +func TestNumericSlotWidenIsLoadBearing(t *testing.T) { + lookJavac(t) + on := numericSlotWidenErrCount(t, false) // fix ON + off := numericSlotWidenErrCount(t, true) // fix OFF (kill-switch) + t.Logf("ObjectWriterCreatorASM.java iso 'Long cannot be converted to Integer': ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("numericSlotWiderThan is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/orphan_rebind_nameeq_test.go b/test/cross/orphan_rebind_nameeq_test.go new file mode 100644 index 0000000..71fa9e8 --- /dev/null +++ b/test/cross/orphan_rebind_nameeq_test.go @@ -0,0 +1,74 @@ +package cross + +// 承重测试:「同一 JVM 槽的逻辑变量在多个兄弟/嵌套作用域各自被 rewriteVar 铸出新 *VariableId, 但这些 +// 铸出的 id 渲染相同的 varN 拼写」时, replayUnambiguousRebindings 的指针严格唯一性测试把该 oldId 判为 +// 多目标(ambiguous)而跳过, 遂令跨作用域的读使用点(如 invokeinterface 的 receiver)保留 pre-mint 旧 id, +// 渲染成与另一槽位同名的 varN → 撞名 → javac「cannot find symbol: method ... location: variable varN of +// type Object」。治本把多目标判别从「指针唯一」放宽到「渲染名唯一」: 同名目标等价、任取其一重绑即可, +// 因为渲染名相同即代表同一变量; 渲染名仍不同的目标保持真 ambiguous、照旧跳过。 +// +// 靶子: fastjson2 JSONPathSegmentName.eval offset-345 `Collection.addAll` —— slot5(JSONArray 累加器)的 +// receiver 读保留 pre-mint `var8` id(其 allReplace 条目列出两个兄弟臂各自铸出的 `var9` id), 修前渲染 +// `var8.addAll((Collection)var8)` javac 报「找不到符号: 方法 addAll(Collection), 位置: 类型为 Object 的 +// 变量 var8」; 修后 receiver 重绑到 `var9` → `var9_1.addAll((Collection)var8)` 编译通过。kill-switch +// JDEC_ORPHAN_REBIND_NAMEEQ_OFF=1 关掉必复现。 +// +// 承重口径: 整 fastjson2 jar 树编译。关掉 kill-switch, fastjson2 整树错误行数必严格增多(本修清掉 +// JSONPathSegmentName 恰好 2 行)。 + +import ( + "os" + "strings" + "testing" +) + +// TestOrphanRebindNameEqIsLoadBearing pins the name-equivalence relaxation of replayUnambiguousRebindings +// as load-bearing on the whole fastjson2 jar. The residual it targets (a slot store minted in two +// sibling/nested scopes under distinct *VariableIds that render the same varN, whose cross-scope read +// use-site — the invokeinterface receiver — keeps the pre-mint colliding id) only surfaces once the +// entire jar is compiled as one tree. With the fix ON the colliding read is rebound to its declared id +// and compiles; disabling the name-equivalence branch via the kill-switch must reintroduce strictly +// more tree errors (the 2 JSONPathSegmentName addAll/add lines). +func TestOrphanRebindNameEqIsLoadBearing(t *testing.T) { + lookJavac(t) + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Skip("fastjson2 spec missing; skipping") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + + const sw = "JDEC_ORPHAN_REBIND_NAMEEQ_OFF" + treeErrs := func(killOff bool) int { + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + outDir := t.TempDir() + _, out := treeCompileToDir(t, files, cp, outDir) + return len(parseTreeErrors(out, root)) + } + + on := treeErrs(false) // fix ON + off := treeErrs(true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree error lines: ON=%d OFF=%d", on, off) + + if off <= on { + t.Fatalf("orphan-rebind name-equivalence is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/rebind_array_sink_test.go b/test/cross/rebind_array_sink_test.go new file mode 100644 index 0000000..4286a82 --- /dev/null +++ b/test/cross/rebind_array_sink_test.go @@ -0,0 +1,78 @@ +package cross + +// 承重测试: rebindIncompatibleLoadForSink 的数组类型放宽 + var-user 注册。 +// +// 真实 fastjson2 TypeUtils.: +// slot 0 的 null 初始化 (offset 825) 被 DFS 损坏解析为 Class[] (同槽后到的数组复用), +// 但真实到达 store 是 offset 841 getDeclaredField → Field。putstatic FIELD_JSON_OBJECT_1x_map +// (Field 字段) 消费 slot 0 的 aload (Class[]) → "Class[] cannot be converted to Field"。 +// +// 治本 1 (数组放宽): rebindIncompatibleLoadForSink 原先对数组类型值 bail (ClassFQNOf 对数组返 false)。 +// 修法: 当值类型是数组而 sink 类型不是数组 (或反之), 判定为真不兼容对, 继续到 reaching-store 查找。 +// +// 治本 2 (var-user 注册): rebind 到正确 ref 后, 在 varUserMap 注册该 ref 的新 user (sink opcode), +// 使 var-fold 的用户计数反映该 phase-2 读。否则被 rebind 的 ref 看起来 single-use (phase-1 计数 +// 不含该 sink), 会被 single-use-fold 折掉声明, sink 读到未声明变量。 +// Kill-switch: JDEC_REBIND_INCOMPATIBLE_LOAD_OFF=1。 + +import ( + "os" + "strings" + "testing" +) + +// rebindArraySinkErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then tree-compiles +// (deps-only cp) and counts "Class[] cannot be converted to Field" errors in TypeUtils.java. +func rebindArraySinkErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_REBIND_INCOMPATIBLE_LOAD_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + outDir := t.TempDir() + _, raw := treeCompileToDir(t, files, cp, outDir) + count := 0 + for _, l := range strings.Split(raw, "\n") { + if strings.Contains(l, "TypeUtils.java") && strings.Contains(l, "cannot be converted to Field") { + count++ + } + } + return count +} + +// TestRebindArraySinkIsLoadBearing pins the array-type relaxation + var-user registration in +// rebindIncompatibleLoadForSink as load-bearing on fastjson2's TypeUtils.: with the fix ON +// the "Class[] cannot be converted to Field" error is gone from the tree compile; disabling via +// the kill-switch must reintroduce it. +func TestRebindArraySinkIsLoadBearing(t *testing.T) { + lookJavac(t) + on := rebindArraySinkErrCount(t, false) // fix ON + off := rebindArraySinkErrCount(t, true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree TypeUtils 'cannot be converted to Field': ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("rebindIncompatibleLoadForSink array relaxation is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/rebind_invoke_args_test.go b/test/cross/rebind_invoke_args_test.go new file mode 100644 index 0000000..69a5a58 --- /dev/null +++ b/test/cross/rebind_invoke_args_test.go @@ -0,0 +1,82 @@ +package cross + +// 承重测试: phase-1-post 遍历 rebindIncompatibleInvokeArgs 对 value-returning invokevirtual 的 +// receiver / invokestatic 参数做两趟 load 重绑。 +// +// 真实 fastjson2 ObjectReaderBaseModule:793: +// `var7.getParameters()` 其中 var7 解析为 Annotation[] (DFS 损坏: slot 7 的 null 初始化与 +// Constructor 存储合流, 读绑到 null-init 的 Annotation[] 而非覆盖的 Constructor)。 +// 字节码: offset 37 astore 7 (null) → offset 48 astore 7 (getDeclaredConstructor → Constructor) +// → offset 60 aload 7 → offset 62 invokevirtual getParameters()。 +// rebindIncompatibleLoadForSink 无法治此: value-returning invokevirtual 在 phase-1 把 FCE 压栈, +// receiver 嵌在 FCE.Object 中, 永不到达 phase-2 的 putstatic/putfield sink。 +// +// 治本 (rebindIncompatibleInvokeArgs): phase-1-post (opcodeIdToRef 已全填) 遍历所有 invoke 的 FCE, +// 对 receiver (非 static) 和 arguments (按声明参数类型) 检查 local-load SlotValue 的类型是否与 +// callee 声明类/参数类型不兼容, 若不兼容则用 reachingStoresOf 找到类型匹配的到达 store 重绑。 +// Kill-switch: JDEC_REBIND_INCOMPATIBLE_LOAD_OFF=1。 + +import ( + "os" + "strings" + "testing" +) + +// rebindInvokeArgsErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then tree-compiles +// (deps-only cp) and counts "cannot find symbol.*getParameters" + "cannot be converted to (Class|MethodHandle)" +// errors across ObjectReaderBaseModule + JDKUtils (the invoke-arg slot-swap family). +func rebindInvokeArgsErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_REBIND_INCOMPATIBLE_LOAD_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + outDir := t.TempDir() + _, raw := treeCompileToDir(t, files, cp, outDir) + count := 0 + for _, l := range strings.Split(raw, "\n") { + // Count the invoke-arg slot-swap family: getParameters cannot find symbol (ObjectReaderBaseModule), + // MethodHandle cannot be converted to Class / Throwable cannot be converted to MethodHandle (JDKUtils). + if (strings.Contains(l, "ReaderAnnotationProcessor") || strings.Contains(l, "JDKUtils")) && + (strings.Contains(l, "cannot find symbol") || strings.Contains(l, "cannot be converted to")) { + count++ + } + } + return count +} + +// TestRebindInvokeArgsIsLoadBearing pins rebindIncompatibleInvokeArgs as load-bearing on fastjson2's +// ObjectReaderBaseModule:793 + JDKUtils:319: with the fix ON the invoke-arg slot-swap errors are gone +// from the tree compile; disabling via the kill-switch must reintroduce them. +func TestRebindInvokeArgsIsLoadBearing(t *testing.T) { + lookJavac(t) + on := rebindInvokeArgsErrCount(t, false) // fix ON + off := rebindInvokeArgsErrCount(t, true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree invoke-arg slot-swap errors: ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("rebindIncompatibleInvokeArgs is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/ternary_arm_cast_test.go b/test/cross/ternary_arm_cast_test.go new file mode 100644 index 0000000..228191c --- /dev/null +++ b/test/cross/ternary_arm_cast_test.go @@ -0,0 +1,78 @@ +package cross + +// 承重测试: 三元表达式 `cond ? A : B` 赋值到具体类型局部 `T var` 时, 若某一 arm 的类型 +// 与 T 不兼容 (sibling 类型, 如 List vs Map), javac 拒绝 "bad type in conditional expression"。 +// JVM 层面两个 arm 都 astore 进同一槽位 (无 checkcast), 但 Java 源码层面需要显式 cast。 +// +// 治本 (ternaryArmIncompatibleCast): 当且仅当恰好一个 arm 不可赋值到目标类型时, 给该 arm +// 包裹 `(T)` cast, 使条件表达式在目标类型处合流。Kill-switch: JDEC_TERNARY_ARM_CAST_OFF=1。 +// +// 真实 fastjson2 JSONPathSegment$CycleNameSegment.eval: +// `List var8 = (var5_2 == 91) ? var1.readArray() : var1.readObject()` +// 其中 readArray():List, readObject():Map (sibling)。治本: `... : ((List)(var1.readObject()))`。 + +import ( + "os" + "strings" + "testing" +) + +// ternaryArmCastErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then compiles +// ALL units in tree mode (deps-only classpath, matching the tree inventory metric), and counts +// "bad type in conditional expression" (the CycleNameSegment:172 signature). With the fix ON that +// error is gone; with the fix OFF (kill-switch) the Map arm of the ternary is not assignable to List. +func ternaryArmCastErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_TERNARY_ARM_CAST_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + deps := resolveDeps(spec.depGlob) + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + // Tree-compile: deps-only classpath (NOT the original jar), matching TestJarTreeInventory. This + // makes readObject()'s decompiled signature (Map) resolve from the decompiled sibling, not the + // original jar — reproducing the "bad type in conditional expression" error when the cast is off. + cp := withJfr(t, withSunMisc(t, strings.Join(deps, string(os.PathListSeparator)))) + outDir := t.TempDir() + _, raw := treeCompileToDir(t, files, cp, outDir) + count := 0 + for _, l := range strings.Split(raw, "\n") { + if strings.Contains(l, "bad type in conditional expression") { + count++ + } + } + return count +} + +// TestTernaryArmCastIsLoadBearing pins ternaryArmIncompatibleCast as load-bearing on fastjson2's +// JSONPathSegment$CycleNameSegment.eval: with the fix ON the "bad type in conditional expression" +// signature is gone from the tree compile; disabling the cast via the kill-switch must reintroduce it. +func TestTernaryArmCastIsLoadBearing(t *testing.T) { + lookJavac(t) + on := ternaryArmCastErrCount(t, false) // fix ON + off := ternaryArmCastErrCount(t, true) // fix OFF (kill-switch) + t.Logf("fastjson2 tree 'bad type in conditional expression' (CycleNameSegment): ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("ternaryArmIncompatibleCast is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +} diff --git a/test/cross/widen_concrete_to_object_test.go b/test/cross/widen_concrete_to_object_test.go new file mode 100644 index 0000000..00f971f --- /dev/null +++ b/test/cross/widen_concrete_to_object_test.go @@ -0,0 +1,85 @@ +package cross + +// 承重测试:「一个具体类型声明的局部被 java.lang.Object 重赋, 合流读是 Object 级(return/cast/instanceof/ +// RHS 进 Object 局部)」治本(widenConcreteDeclToObject, narrowNullInitObjectDecl 的逆)。 +// +// 真实 fastjson2: `JSON.parse(String)` slot 6 持 JSONObject(首存 new JSONObject())/JSONArray(ObjectReader 读)/ +// Object(`var6 = var5`, var5 是 Object), 合流 `return var6`(方法返回 Object)。具体声明 `JSONObject var6` 拒绝 +// `var6 = var5`(Object)→ javac「Object cannot be converted to JSONObject」。治本: 具体 ref 声明 + Object 重赋值 +// + 所有使用 Object-safe 时 widen 声明到 Object。 +// +// 该缺陷 iso 可复现: 单编 JSON.java 对原始 jar(原始 jar 提供 JSONObject/JSONReader/... 兄弟类)即暴露 +// `Object cannot be converted to JSONObject` at parse()。fix ON 该错误消失; fix OFF(关 widen)必复现。 +// Kill-switch: JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF=1。 + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// widenConcreteJSONObjectErrCount decompiles the WHOLE fastjson2 jar under the kill-switch, then +// compiles JSON.java ALONE against the original jar (iso), and counts "Object cannot be converted to +// JSONObject" (the #13 signature). With the fix ON that error is gone; with the fix OFF (widen +// disabled) the concrete `JSONObject var6` declaration rejects the Object reassignment. +func widenConcreteJSONObjectErrCount(t *testing.T, killOff bool) int { + t.Helper() + const sw = "JDEC_WIDEN_CONCRETE_TO_OBJECT_OFF" + prev, had := os.LookupEnv(sw) + if killOff { + os.Setenv(sw, "1") + } else { + os.Unsetenv(sw) + } + defer func() { + if had { + os.Setenv(sw, prev) + } else { + os.Unsetenv(sw) + } + }() + + spec, ok := jarSpecs["fastjson2"] + if !ok { + t.Fatal("fastjson2 spec missing") + } + jarPath := resolveJar(spec.relPath) + if jarPath == "" { + t.Skip("fastjson2 jar not found under ~/.m2; skipping") + } + root := t.TempDir() + files, _, _ := decompileAll(t, jarPath, root, 0) + var jsonFile string + for _, f := range files { + if filepath.Base(f) == "JSON.java" { + jsonFile = f + break + } + } + if jsonFile == "" { + t.Fatal("JSON.java not produced") + } + javac := lookJavac(t) + cp := withJfr(t, withSunMisc(t, jarPath)) + args := append(append([]string{}, javacLocaleArgs...), + "-encoding", "UTF-8", "--release", "8", "-nowarn", "-Xmaxerrs", "100000", + "-cp", cp, jsonFile) + cmd := exec.Command(javac, args...) + out, _ := cmd.CombinedOutput() + return strings.Count(string(out), "Object cannot be converted to JSONObject") +} + +// TestWidenConcreteToObjectIsLoadBearing pins widenConcreteDeclToObject as load-bearing on fastjson2's +// JSON.parse: with the fix ON the `Object cannot be converted to JSONObject` signature is gone from +// JSON.java's iso compile; disabling the widen via the kill-switch must reintroduce it. +func TestWidenConcreteToObjectIsLoadBearing(t *testing.T) { + lookJavac(t) + on := widenConcreteJSONObjectErrCount(t, false) // fix ON + off := widenConcreteJSONObjectErrCount(t, true) // fix OFF (kill-switch) + t.Logf("JSON.java iso 'Object cannot be converted to JSONObject': ON=%d OFF=%d", on, off) + if off <= on { + t.Fatalf("widenConcreteDeclToObject is NOT load-bearing: ON=%d OFF=%d (OFF must be strictly greater)", on, off) + } +}