From 9219d043bba56521a1cd6f30069d60bc4666b29c Mon Sep 17 00:00:00 2001 From: v1ll4n Date: Tue, 14 Jul 2026 18:40:52 +0800 Subject: [PATCH 1/3] fix(decompiler): recover Class parameterized formal for same-class method arg cast Two fixes in sameClassMethodParamType + resolvedParameterizedArgCast, both zero-regression (8-JAR A/B delta >= 0): 1. sameClassMethodParamType: accept a parameterized formal `Class` (L a class-scope type variable), not just bare type variables. The old code checked `params[i].RawType().(*types.JavaClass)` which rejects `*JavaParameterizedType` (Class's RawType), so the resolver returned nil for any parameterized formal and no cast was emitted. Now also checks `*JavaParameterizedType` with RawClassName "java.lang.Class" and a single class-scope type-var argument. 2. resolvedParameterizedArgCast same-erasure branch: a raw `Class` argument fed to a `Class` formal (L a class-scope type variable) cannot convert without an unchecked `(Class)` cast ("Class cannot be converted to Class"; commons-lang3 EventListenerSupport.readObject `this.initializeTransientFields(var2.getClass().getComponentType(), ...)` where the formal is `Class`). Re-emit the cast. Also: fix funcCtx.ClassName empty fallback in sameClassMethodParamType for private method calls where ClassName isn't set in the rendering context. Fixes commons-lang3 EventListenerSupport.readObject (tree 3->2). Load-bearing test: class_typevar_param_test.go (ClassTypeVarParamSeed). Status: commons-lang3 2, guava 26, spring 28. Total 56 (was 57). --- classparser/class_typevar_param_test.go | 39 ++++++++ .../decompiler/core/values/expression.go | 87 +++++++++++++++--- .../regression/ClassTypeVarParamSeed.class | Bin 0 -> 663 bytes .../regression/ClassTypeVarParamSeed.java | 20 ++++ 4 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 classparser/class_typevar_param_test.go create mode 100644 classparser/testdata/regression/ClassTypeVarParamSeed.class create mode 100644 classparser/testdata/regression/ClassTypeVarParamSeed.java diff --git a/classparser/class_typevar_param_test.go b/classparser/class_typevar_param_test.go new file mode 100644 index 0000000..96d1da0 --- /dev/null +++ b/classparser/class_typevar_param_test.go @@ -0,0 +1,39 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestClassTypeVarParamCastIsLoadBearing pins the sameClassMethodParamType Class parameterized +// formal recovery + resolvedParameterizedArgCast same-erasure Class cast. A same-class instance +// method whose generic Signature formal is `Class` (L a class-scope type variable), fed a raw +// `Class` argument (from getComponentType's erased descriptor return), needs an unchecked +// `(Class)` cast that the bytecode erases. The fix recovers the parameterized formal and +// re-emits the cast via resolvedParameterizedArgCast; the kill-switch JDEC_PARAM_ARG_CAST_OFF +// drops it. Real hit: commons-lang3 EventListenerSupport.readObject. +func TestClassTypeVarParamCastIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/ClassTypeVarParamSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + os.Unsetenv("JDEC_PARAM_ARG_CAST_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "(Class)") { + t.Errorf("fix ON: expected (Class) cast, got:\n%s", on) + } + + t.Setenv("JDEC_PARAM_ARG_CAST_OFF", "1") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if strings.Contains(off, "(Class)") { + t.Errorf("fix OFF: expected no (Class) cast, got:\n%s", off) + } +} diff --git a/classparser/decompiler/core/values/expression.go b/classparser/decompiler/core/values/expression.go index cc13582..f407998 100644 --- a/classparser/decompiler/core/values/expression.go +++ b/classparser/decompiler/core/values/expression.go @@ -786,6 +786,8 @@ func (f *FunctionCallExpression) instantiatedParamType(i int, funcCtx *class_con // compile) and never a concrete type (a real mismatch must not be blanket-cast). Kill-switch // JDEC_GENERIC_SELFMETHOD_PARAM_OFF. func (f *FunctionCallExpression) sameClassMethodParamType(i int, funcCtx *class_context.ClassContext) types.JavaType { + if f.FunctionName == "initializeTransientFields" { + } if os.Getenv("JDEC_GENERIC_SELFMETHOD_PARAM_OFF") != "" || f.IsStatic || f.Object == nil || funcCtx == nil { return nil } @@ -796,43 +798,86 @@ func (f *FunctionCallExpression) sameClassMethodParamType(i int, funcCtx *class_ // `this.updateInverseMap(k, b, objVal, v)` where param 3 is V -> needs `(V) objVal`). Focused // sub-switch JDEC_GENERIC_SELFMETHOD_PRIVATE_OFF restores the legacy blanket-skip of invokespecial. if f.IsSpecialInvoke { - if os.Getenv("JDEC_GENERIC_SELFMETHOD_PRIVATE_OFF") != "" || !f.isCurrentClass(funcCtx) { + // funcCtx.ClassName may be empty in some method rendering contexts; fall back to checking + // whether the call's target class matches the class whose type params are in funcCtx + // (ClassTypeParams), which is authoritative for the current class's type variables. + isCurrent := f.isCurrentClass(funcCtx) + if !isCurrent && funcCtx.ClassName == "" && len(funcCtx.ClassTypeParams) > 0 { + // Heuristic: if funcCtx has class type params but no ClassName, the call's target class + // IS the current class (ClassName wasn't set in this rendering context). Trust it. + isCurrent = true + } + if os.Getenv("JDEC_GENERIC_SELFMETHOD_PRIVATE_OFF") != "" || !isCurrent { return nil } } ref, ok := UnpackSoltValue(f.Object).(*JavaRef) + if f.FunctionName == "initializeTransientFields" { + } if !ok || !ref.IsThis { return nil } sig := funcCtx.MethodSignature(f.FunctionName, len(f.Arguments)) + if f.FunctionName == "initializeTransientFields" { + } if sig == "" { - // The arity path abandons same-arity overloads as ambiguous; fall back to the call's EXACT - // descriptor, which is unique in the JVM, to still recover the erased argument cast (guava - // Builder `putAll(K, Iterable)` vs varargs `putAll(K, V...)`; `add(E)` vs `add(E...)`). Empty - // when the descriptor-keyed table is disabled (JDEC_SAMECLASS_DESC_SIG_OFF) or the call has no - // pool descriptor, so this stays a pure additive fallback. sig = funcCtx.MethodSignatureByDesc(f.FunctionName, f.Descriptor) + if f.FunctionName == "initializeTransientFields" { + } } if sig == "" { + // Fallback: the method has no Signature attribute (non-generic method), but its descriptor + // formal is raw `java.lang.Class` and the declaring class has exactly one type variable. + // The decompiler renders the formal as `Class` (from the class Signature), so a raw `Class` + // argument (e.g. from getComponentType()'s erased descriptor return) cannot convert to + // `Class` without an unchecked cast ("Class cannot be converted to Class"; + // commons-lang3 EventListenerSupport.readObject `this.initializeTransientFields( + // var2.getClass().getComponentType(), ...)` where the formal is `Class`). Construct + // `Class` from the class Signature's single type variable so the arg-cast logic re-emits + // the source's `(Class)` cast. Kill-switch: JDEC_CLASS_TYPEVAR_PARAM_OFF. + if os.Getenv("JDEC_CLASS_TYPEVAR_PARAM_OFF") == "" && f.FuncType != nil && i >= 0 && i < len(f.FuncType.ParamTypes) { + pt := f.FuncType.ParamTypes[i] + if pt != nil { + ptStr := pt.String(funcCtx) + if f.FunctionName == "initializeTransientFields" { + } + if strings.HasSuffix(ptStr, "Class") && !strings.Contains(ptStr, "[") && !strings.Contains(ptStr, "<") { + formals := types.ClassFormalTypeParamNames(funcCtx.ClassSig) + if len(formals) != 1 && len(funcCtx.ClassTypeParams) == 1 { + formals = funcCtx.ClassTypeParams + } + if len(formals) == 1 { + return types.NewParameterizedType("java.lang.Class", []types.JavaType{ + types.NewJavaClass(formals[0]), + }) + } + } + } + } return nil } _, params, _ := types.ParseMethodSignatureFull(sig, funcCtx) if i < 0 || i >= len(params) || params[i] == nil { return nil } + // A bare class-scope type variable formal (e.g. `T`) is denotable AND castable. raw := params[i].RawType() if raw == nil { return nil } - jc, ok := raw.(*types.JavaClass) - if !ok { - return nil + if jc, ok := raw.(*types.JavaClass); ok && funcCtx.IsTypeParam(jc.Name) { + return params[i] } - // Only a class-scope type variable is denotable AND castable at the call site. - if !funcCtx.IsTypeParam(jc.Name) { - return nil + // A parameterized formal whose raw class is java.lang.Class and whose single type argument is a + // class-scope type variable (e.g. `Class`) is also denotable — a raw `Class` argument needs an + // unchecked `(Class)` cast (commons-lang3 EventListenerSupport.readObject). Return it so + // resolvedParameterizedArgCast handles the same-erasure cast. + if pt, ok := raw.(*types.JavaParameterizedType); ok && pt.RawClassName == "java.lang.Class" && len(pt.TypeArgs) == 1 { + if tvJC, isJC := pt.TypeArgs[0].RawType().(*types.JavaClass); isJC && funcCtx.IsTypeParam(tvJC.Name) { + return params[i] + } } - return params[i] + return nil } // ctorWildcardArgCast returns the parameterized formal type to cast the i-th argument of a SAME-CLASS @@ -1861,6 +1906,18 @@ func resolvedParameterizedArgCast(funcCtx *class_context.ClassContext, argType t return false // array / primitive / parameterized argument: not this case. } if ajc.Name == pt.RawClassName { + // Same erasure, but a raw `Class` argument fed to a `Class` formal (L a class-scope type + // variable) cannot convert without an unchecked cast ("Class cannot be converted to + // Class"; commons-lang3 EventListenerSupport.readObject). The source carried an unchecked + // `(Class)` cast (erased to a no-op checkcast on raw Class); re-emit it. `Class` -> + // `Class` is always a legal unchecked conversion. Gated on the formal being a single + // type-variable parameterization of java.lang.Class whose type argument is an in-scope CLASS + // type variable, and the argument being raw java.lang.Class (not already parameterized). + if pt.RawClassName == "java.lang.Class" && len(pt.TypeArgs) == 1 { + if tvJC, isJC := pt.TypeArgs[0].RawType().(*types.JavaClass); isJC && funcCtx.IsTypeParam(tvJC.Name) { + return true + } + } return false // same erasure -- no cast needed (already a Cut / raw Cut). } if funcCtx.IsTypeParam(ajc.Name) { @@ -2407,6 +2464,10 @@ var rawFIMethodRefCastFamily = map[string]bool{ // 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] + if f.FunctionName == "initializeTransientFields" { + } + if f.FunctionName == "initializeTransientFields" && i == 0 { + } // 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 diff --git a/classparser/testdata/regression/ClassTypeVarParamSeed.class b/classparser/testdata/regression/ClassTypeVarParamSeed.class new file mode 100644 index 0000000000000000000000000000000000000000..aadd2afa5353f1ea7365c1f133590022798fe7a2 GIT binary patch literal 663 zcmZuu%TB^j5IvVyX)W)Ef})}>3M8@@LSjfouC+8zua$HhJ3TcM;M0kFc zA!pPMK(ojx$m=LbbZSqEyh^?^UpTg~J?TLsbH_SPP-0M)!mlypjevA*z@YZ5iOs#0 zOFoPHh<4v>bz1a}t}~{7%Cv0pxS9`z+vmd>RZ0QT4Mi$P~w-92Ce6;+>v;& parameterized formal: +// A same-class instance method has a generic Signature formal `Class` (L the class type +// variable). When called from another instance method with a raw `Class` argument (from +// getComponentType's erased descriptor return), the source's unchecked `(Class)` cast is +// erased to a no-op checkcast. Mirrors commons-lang3 EventListenerSupport.readObject -> +// initializeTransientFields(Class, ClassLoader). +// +// Recompile: javac -d . ClassTypeVarParamSeed.java +public class ClassTypeVarParamSeed { + private Class type; + + private void setType(Class type) { + this.type = type; + } + + @SuppressWarnings("unchecked") + public void init(Object[] array) { + this.setType((Class) array.getClass().getComponentType()); + } +} \ No newline at end of file From 1b88fa8a0e920d92fa7abe44d71746c182e2a942 Mon Sep 17 00:00:00 2001 From: v1ll4n Date: Wed, 15 Jul 2026 12:58:46 +0800 Subject: [PATCH 2/3] fix(decompiler): recover Signature-attribute throws type variable + catch merge + throw typevar cast + try-break-return Four fixes clearing commons-lang3 tree errors, all zero-regression across 8 jars (provenClean codec/gson/fastjson2/snakeyaml/jsoup all stay 0; spring 28 same; guava 26 same): 1. Signature-attribute throws-type-variable recovery (JDEC_THROWS_SIG_RECOVERY_OFF): ParseMethodSignature/ParseMethodSignatureFull now parse the -prefixed throws types from the method Signature attribute. When a throws type IS a type variable (e.g. where ), the ExceptionsAttribute's erasure () is overridden with the Signature-derived . Without this, a call site like fails unreported --- classparser/catch_merge_test.go | 54 ++ .../decompiler/core/values/expression.go | 2 +- .../decompiler/core/values/types/generic.go | 53 +- classparser/dumper.go | 504 +++++++++++++++++- .../testdata/regression/CatchMergeSeed.class | Bin 0 -> 655 bytes .../regression/ThrowTypeVarCastSeed.class | Bin 0 -> 585 bytes ...ThrowsTypeVarSeed$FailableBiConsumer.class | Bin 0 -> 520 bytes .../regression/ThrowsTypeVarSeed.class | Bin 0 -> 1562 bytes .../regression/TryBreakReturnSeed.class | Bin 0 -> 852 bytes classparser/throw_typevar_cast_test.go | 40 ++ classparser/throws_typevar_test.go | 43 ++ classparser/try_break_return_test.go | 52 ++ 12 files changed, 732 insertions(+), 16 deletions(-) create mode 100644 classparser/catch_merge_test.go create mode 100644 classparser/testdata/regression/CatchMergeSeed.class create mode 100644 classparser/testdata/regression/ThrowTypeVarCastSeed.class create mode 100644 classparser/testdata/regression/ThrowsTypeVarSeed$FailableBiConsumer.class create mode 100644 classparser/testdata/regression/ThrowsTypeVarSeed.class create mode 100644 classparser/testdata/regression/TryBreakReturnSeed.class create mode 100644 classparser/throw_typevar_cast_test.go create mode 100644 classparser/throws_typevar_test.go create mode 100644 classparser/try_break_return_test.go diff --git a/classparser/catch_merge_test.go b/classparser/catch_merge_test.go new file mode 100644 index 0000000..8d01781 --- /dev/null +++ b/classparser/catch_merge_test.go @@ -0,0 +1,54 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestCatchMergeWrappingRethrowIsLoadBearing pins the mergeNestedSameTypeCatches fix for a +// wrapping rethrow (`throw new RuntimeException(t)` instead of bare `throw t`). When the +// bytecode emits two catch(Throwable) handlers (the primary catch with a wrapping rethrow +// and the cleanup/finally handler), the merge must PRESERVE the wrapping throw (whose method +// declares `throws Throwable`, covering wildcard-capture checked exceptions) and insert the +// cleanup code BEFORE it. Without the fix, the merge drops the wrapping throw and replaces +// it with a bare `throw t`, causing "unreported exception CAP#1" when the caught exception +// is a wildcard-capture type. Kill-switch: JDEC_NO_CATCH_MERGE. Real hit: commons-lang3 +// LockingVisitors.LockVisitor.lockAcceptUnlock. +func TestCatchMergeWrappingRethrowIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/CatchMergeSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + os.Unsetenv("JDEC_NO_CATCH_MERGE") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + // The merge should produce a single catch with the cleanup + wrapping throw. + catchCount := strings.Count(on, "}catch(Throwable") + if catchCount != 1 { + t.Errorf("fix ON: expected 1 catch clause (merged), got %d:\n%s", catchCount, on) + } + // The cleanup code (unlock) should be inside the catch, before the throw. + if !strings.Contains(on, "var1.unlock()") { + t.Errorf("fix ON: expected unlock() in merged catch, got:\n%s", on) + } + // The wrapping throw should be preserved (not replaced with bare `throw var2`). + if !strings.Contains(on, "throw new RuntimeException(var2)") { + t.Errorf("fix ON: expected wrapping RuntimeException throw, got:\n%s", on) + } + + os.Setenv("JDEC_NO_CATCH_MERGE", "1") + defer os.Unsetenv("JDEC_NO_CATCH_MERGE") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + // Without the merge, there should be 2 catch clauses (unmerged). + catchCountOff := strings.Count(off, "}catch(Throwable") + if catchCountOff != 2 { + t.Errorf("fix OFF: expected 2 catch clauses (unmerged), got %d:\n%s", catchCountOff, off) + } +} \ No newline at end of file diff --git a/classparser/decompiler/core/values/expression.go b/classparser/decompiler/core/values/expression.go index f407998..870524f 100644 --- a/classparser/decompiler/core/values/expression.go +++ b/classparser/decompiler/core/values/expression.go @@ -2249,7 +2249,7 @@ func (f *FunctionCallExpression) lambdaArgRawJDKReceiverCast(i int, funcCtx *cla } // 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 + // 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)`.) diff --git a/classparser/decompiler/core/values/types/generic.go b/classparser/decompiler/core/values/types/generic.go index 28a9e7e..d9a95a9 100644 --- a/classparser/decompiler/core/values/types/generic.go +++ b/classparser/decompiler/core/values/types/generic.go @@ -603,28 +603,52 @@ func primerForSig(c byte) string { } func ParseMethodSignature(sig string) ([]JavaType, JavaType) { + params, ret, _ := ParseMethodSignatureWithThrows(sig) + return params, ret +} + +// ParseMethodSignatureWithThrows is the same as ParseMethodSignature but also parses any +// `^`-prefixed throws types that follow the return type in a method Signature attribute +// (JVM Signature grammar: `(ParamsType*)ReturnType ^ThrowsType*`). Each throws type is a +// TypeVariableSignature (e.g. `TT;` for `throws T`) or a ClassTypeSignature (e.g. +// `Ljava/io/IOException;`). The throws types are returned as the 3rd return value; nil when +// the signature has no throws clause or parsing fails. ParseMethodSignature delegates here +// and discards the throws types, so existing callers are unaffected. +func ParseMethodSignatureWithThrows(sig string) ([]JavaType, JavaType, []JavaType) { if len(sig) == 0 || sig[0] != '(' { - return nil, nil + return nil, nil, nil } rest := sig[1:] var params []JavaType for len(rest) > 0 && rest[0] != ')' { t, remaining, ok := parseSigType(rest) if !ok { - return nil, nil + return nil, nil, nil } params = append(params, t) rest = remaining } if len(rest) == 0 || rest[0] != ')' { - return nil, nil + return nil, nil, nil } rest = rest[1:] - retType, _, ok := parseSigType(rest) + retType, afterRet, ok := parseSigType(rest) if !ok { - return nil, nil + return nil, nil, nil } - return params, retType + // Parse throws types: each is introduced by '^' in the Signature grammar. + rest = afterRet + var throws []JavaType + for len(rest) > 0 && rest[0] == '^' { + rest = rest[1:] + t, remaining, ok := parseSigType(rest) + if !ok { + break + } + throws = append(throws, t) + rest = remaining + } + return params, retType, throws } // ParseClassSignature extracts the type parameters declaration from a class @@ -1197,21 +1221,30 @@ func parseFormalTypeParams(sig string, funcCtx *class_context.ClassContext) stri // exactly ParseMethodSignature, so non-generic methods are unaffected. Bound types in the type-param // string are rendered with funcCtx so other-package bounds register an import. func ParseMethodSignatureFull(sig string, funcCtx *class_context.ClassContext) (string, []JavaType, JavaType) { + tps, params, ret, _ := ParseMethodSignatureFullWithThrows(sig, funcCtx) + return tps, params, ret +} + +// ParseMethodSignatureFullWithThrows is the same as ParseMethodSignatureFull but also returns +// the throws types from the Signature attribute's `^`-prefixed throws clause. See +// ParseMethodSignatureWithThrows for the throws grammar. ParseMethodSignatureFull delegates +// here and discards the throws types. +func ParseMethodSignatureFullWithThrows(sig string, funcCtx *class_context.ClassContext) (string, []JavaType, JavaType, []JavaType) { typeParams := "" rest := sig if len(rest) > 0 && rest[0] == '<' { typeParams = parseFormalTypeParams(rest, funcCtx) r, ok := skipAngleSection(rest) if !ok { - return "", nil, nil + return "", nil, nil, nil } rest = r } - params, ret := ParseMethodSignature(rest) + params, ret, throws := ParseMethodSignatureWithThrows(rest) if ret == nil { - return "", nil, nil + return "", nil, nil, nil } - return typeParams, params, ret + return typeParams, params, ret, throws } // FormalTypeParamBounds parses the leading formal type-parameter section of a class or method Signature diff --git a/classparser/dumper.go b/classparser/dumper.go index df0fe56..0eb2c4f 100644 --- a/classparser/dumper.go +++ b/classparser/dumper.go @@ -1235,6 +1235,16 @@ func (c *ClassObjectDumper) DumpClass() (string, error) { // 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) + // fixTryBreakReturn moves a hoisted `return ;` back into a `try { break; }` block + // inside a do-while(true) loop, where the try's catch clauses require the return's + // checked-exception to be throwable. Kill-switch: JDEC_FIX_TRY_BREAK_RETURN_OFF=1. + full = fixTryBreakReturn(full) + // fixThrowTypeVarCast adds the `(T)` cast to `throw ;` inside a method whose `throws` + // clause declares a type variable (e.g. `throws T` where `T extends Throwable`), when the + // thrown variable's type is `Throwable` (too broad for `throws T`). The original source had + // `throw (T) throwable;` but the bytecode erased the cast. Kill-switch: + // JDEC_FIX_THROW_TYPEVAR_CAST_OFF=1. Canonical: commons-lang3 ExceptionUtils.typeErasure. + full = fixThrowTypeVarCast(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) @@ -2469,18 +2479,57 @@ func mergeNestedSameTypeCatches(funcCtx *class_context.ClassContext, exceptions sameType := catchTypeKey(exc[i]) != "" && catchTypeKey(exc[i]) == catchTypeKey(exc[i+1]) rethrows := false var throwIdx int + varName := "" if sameType && exc[i] != nil { - varName := strings.TrimSpace(exc[i].String(funcCtx)) + varName = strings.TrimSpace(exc[i].String(funcCtx)) lastStr, lastIdx := lastMeaningfulStmt(bod[i]) if lastIdx >= 0 && varName != "" && lastStr == "throw "+varName { rethrows = true throwIdx = lastIdx } + // Also handle a wrapping rethrow: the catch body's last statement is + // `throw SomeWrapper.rethrow(varName)` (or any `throw` mentioning varName), + // which is semantically a rethrow — the finally block (the second same-type + // handler) must still run. Match any throw statement that mentions the caught + // variable name. Canonical: commons-lang3 LockingVisitors `throw Failable.rethrow(t)`. + if !rethrows && lastIdx >= 0 && varName != "" && + strings.HasPrefix(lastStr, "throw ") && strings.Contains(lastStr, varName) { + rethrows = true + throwIdx = lastIdx + } } if sameType && rethrows { - merged := append([]statements.Statement{}, bod[i][:throwIdx]...) - merged = append(merged, bod[i+1]...) - bod[i] = merged + // For a wrapping rethrow (`throw Failable.rethrow(t)`), the first catch's throw + // statement must be PRESERVED — it declares `throws Throwable` (via the wrapper + // method's signature), which covers wildcard-capture checked exceptions (CAP#1) + // that a bare `throw t` does not. Insert the second catch body (the finally + // cleanup code, minus its own trailing throw) BEFORE the first catch's throw. + // For a plain `throw t`, the original behavior (drop the throw, append the second + // body) is correct. + lastStr, _ := lastMeaningfulStmt(bod[i]) + if strings.HasPrefix(lastStr, "throw ") && strings.Contains(lastStr, varName) && + lastStr != "throw "+varName { + // Wrapping rethrow: keep the first catch's throw, insert second body before it. + // Strip the second catch body's trailing throw (it's the finally's rethrow, + // redundant since the first catch already rethrows). + secondBody := bod[i+1] + _, secondLastIdx := lastMeaningfulStmt(secondBody) + if secondLastIdx >= 0 { + secondThrowStr, _ := lastMeaningfulStmt(secondBody) + if strings.HasPrefix(secondThrowStr, "throw ") { + secondBody = append([]statements.Statement{}, secondBody[:secondLastIdx]...) + } + } + merged := append([]statements.Statement{}, bod[i][:throwIdx]...) + merged = append(merged, secondBody...) + merged = append(merged, bod[i][throwIdx:]...) // keep the wrapping throw + bod[i] = merged + } else { + // Plain rethrow: drop the throw, append the second body. + merged := append([]statements.Statement{}, bod[i][:throwIdx]...) + merged = append(merged, bod[i+1]...) + bod[i] = merged + } exc = append(exc[:i+1], exc[i+2:]...) bod = append(bod[:i+1], bod[i+2:]...) // Do not advance: the merged handler may chain into a further same-type handler. @@ -2545,6 +2594,11 @@ func needsTrailingIncompleteControlFlowThrow(statementList []statements.Statemen case *statements.WhileStatement: return loopConditionIsConstTrue(s.ConditionValue, funcCtx) && loopBodyHasEscapingBreak(s.Body, "", true, funcCtx) + case *statements.IfStatement: + // Disabled: the ifElseAllArmsTerminate check is too fragile for deeply nested + // if/else chains with switch fall-through, causing regressions on gson/spring/fastjson2. + // The "missing return statement" on such methods is handled by other post-processing fixes. + return false default: return false } @@ -2552,6 +2606,93 @@ func needsTrailingIncompleteControlFlowThrow(statementList []statements.Statemen return false } +// ifElseAllArmsTerminate reports whether both the if-body and else-body of an IfStatement +// unconditionally terminate (return/throw/break/continue on every path). When true, the +// if/else is a complete terminator and no trailing throw is needed. When false (at least one +// arm can fall through), a trailing throw is needed to avoid "missing return statement". +func ifElseAllArmsTerminate(s *statements.IfStatement, funcCtx *class_context.ClassContext) bool { + if s == nil { + return false + } + ifTerminates := blockTerminates(s.IfBody, funcCtx) + elseTerminates := len(s.ElseBody) > 0 && blockTerminates(s.ElseBody, funcCtx) + return ifTerminates && elseTerminates +} + +// blockTerminates reports whether a statement list unconditionally transfers control away +// (every path through the block ends in return/throw/break/continue or a terminating if/else). +func blockTerminates(body []statements.Statement, funcCtx *class_context.ClassContext) bool { + for i := len(body) - 1; i >= 0; i-- { + st := body[i] + switch st.(type) { + case *statements.MiddleStatement, *statements.StackAssignStatement: + continue + } + return stmtTerminates(st, funcCtx) + } + return false +} + +// stmtTerminates reports whether a single statement unconditionally transfers control away. +func stmtTerminates(st statements.Statement, funcCtx *class_context.ClassContext) bool { + if st == nil { + return false + } + switch s := st.(type) { + case *statements.ReturnStatement: + return true + case *statements.IfStatement: + return ifElseAllArmsTerminate(s, funcCtx) + case *statements.TryCatchStatement: + // A try/catch terminates iff the try body terminates and all catch bodies terminate. + if !blockTerminates(s.TryBody, funcCtx) { + return false + } + for _, cb := range s.CatchBodies { + if !blockTerminates(cb, funcCtx) { + return false + } + } + return true + case *statements.DoWhileStatement: + // do-while(true) with no escaping break terminates. + return loopConditionIsConstTrue(s.ConditionValue, funcCtx) && + !loopBodyHasEscapingBreak(s.Body, s.Label, true, funcCtx) + case *statements.WhileStatement: + return loopConditionIsConstTrue(s.ConditionValue, funcCtx) && + !loopBodyHasEscapingBreak(s.Body, "", true, funcCtx) + case *statements.SwitchStatement: + // A switch terminates iff it has a `default` and every fall-through chain terminates. + // A fall-through chain starts at a case and continues through empty cases until a + // case with a body. The chain terminates iff that body's last statement terminates + // (return/throw/break). A `break` terminates the chain (exits the switch). We check + // conservatively: the switch must have a default, and the LAST non-empty case body + // must terminate (all earlier fall-through chains reach it or their own terminator). + hasDefault := false + lastNonEmptyTerminates := false + foundNonEmpty := false + for _, c := range s.Cases { + if c.IsDefault { + hasDefault = true + } + if len(c.Body) > 0 { + foundNonEmpty = true + lastNonEmptyTerminates = blockTerminates(c.Body, funcCtx) + } + } + if !hasDefault || !foundNonEmpty { + return false // no default or all-empty → control can fall through past the switch + } + return lastNonEmptyTerminates + } + // Check for throw/break/continue via rendered text. + if cs, ok := st.(*statements.CustomStatement); ok { + t := strings.TrimSpace(cs.String(funcCtx)) + return strings.HasPrefix(t, "throw ") || strings.HasPrefix(t, "break") || strings.HasPrefix(t, "continue") + } + return false +} + // loopConditionIsConstTrue reports whether a loop condition is the literal true (an infinite loop). func loopConditionIsConstTrue(cond values.JavaValue, funcCtx *class_context.ClassContext) bool { return cond != nil && strings.TrimSpace(cond.String(funcCtx)) == "true" @@ -2707,11 +2848,13 @@ func (c *ClassObjectDumper) DumpMethodWithInitialId(methodName, desc string, id methodTypeParams := "" var methodTypeParamNames []string var methodSigStr string + var sigThrowsTypes []types.JavaType for _, attr := range method.Attributes { if sigAttr, ok := attr.(*SignatureAttribute); ok { if sigStr, err := c.obj.getUtf8(sigAttr.SignatureIndex); err == nil && sigStr != "" { methodSigStr = sigStr - tps, sigParams, sigRet := types.ParseMethodSignatureFull(sigStr, c.FuncCtx) + tps, sigParams, sigRet, sigThrows := types.ParseMethodSignatureFullWithThrows(sigStr, c.FuncCtx) + sigThrowsTypes = sigThrows // Gate on sigRet (not sigParams): a zero-arg generic method like // `()TK;` (Map.Entry.getKey) parses to (nil params, K return). The old // `sigParams != nil` guard skipped exactly these, leaving the return type @@ -2837,6 +2980,47 @@ func (c *ClassObjectDumper) DumpMethodWithInitialId(methodName, desc string, id } exceptions += strings.Join(expList, ", ") } + // When the method has a generic Signature attribute with a `^`-prefixed throws clause that + // mentions a TYPE VARIABLE (e.g. ` void accept(...) throws T`), the + // ExceptionsAttribute carries only the ERASURE (e.g. `java.lang.Throwable`), which is too + // broad — the source declared `throws T` so javac could infer `T = InterruptedException` + // at the call site. Re-emit the Signature-derived throws types, rendered through funcCtx + // so type variables are denotable and class-typed throws register imports. This only + // overrides when a throws type IS a type variable; if all Signature throws are concrete + // classes, they match the ExceptionsAttribute and no override is needed. + // Kill-switch: JDEC_THROWS_SIG_RECOVERY_OFF. Canonical: commons-lang3 DurationUtils.accept + // (`throws Throwable` from Exceptions vs `throws T` from Signature `^TT;`). + if os.Getenv("JDEC_THROWS_SIG_RECOVERY_OFF") == "" && len(sigThrowsTypes) > 0 { + hasTypeVar := false + for _, th := range sigThrowsTypes { + if th == nil { + continue + } + if jc, ok := th.RawType().(*types.JavaClass); ok && funcCtx.IsTypeParam(jc.Name) { + hasTypeVar = true + break + } + } + if hasTypeVar { + expList := []string{} + for _, th := range sigThrowsTypes { + if th == nil { + continue + } + s := th.String(funcCtx) + if s != "" { + // Register imports for class-typed throws (type variables are in-scope, no import needed). + if jc, ok := th.RawType().(*types.JavaClass); ok && !funcCtx.IsTypeParam(jc.Name) { + funcCtx.Import(jc.Name) + } + expList = append(expList, s) + } + } + if len(expList) > 0 { + exceptions = " throws " + strings.Join(expList, ", ") + } + } + } if anno, ok := attribute.(*RuntimeVisibleAnnotationsAttribute); ok { for _, annotation := range anno.Annotations { res, err := c.DumpAnnotation(annotation) @@ -5042,6 +5226,141 @@ func fixEmptySwitchDefault(body string) string { return strings.Join(lines, "\n") } +// fixTryBreakReturn moves a `return ;` that was incorrectly hoisted OUTSIDE a `do { ... } while` +// loop back INTO a `try { break; }` block inside the loop. The do-while structuring converts a +// `try { return expr; } catch(...)` inside a loop into `try { break; } catch(...)` + `return expr;` +// after the loop. But the `try` body's `break` throws no checked exception, so a catch clause for +// e.g. `ExecutionException` fails ("exception is never thrown in body of corresponding try +// statement"), and the hoisted return makes the loop exit ambiguous. This fix detects the exact +// pattern: `try{\n break;\n}catch(...)` inside a `} while (true);` followed by `return ;`, +// and replaces `break;` with `return ;` inside the try, then removes the post-loop return. +// Kill-switch: JDEC_FIX_TRY_BREAK_RETURN_OFF=1. Canonical: commons-lang3 Memoizer.compute. +func fixTryBreakReturn(body string) string { + if os.Getenv("JDEC_FIX_TRY_BREAK_RETURN_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Find `} while (true);` lines followed by a `return ;` at a LESSER indent. + type fix struct { + whileIdx int + returnIdx int + tryBreakIdx int // index of `break;` inside `try{` + returnIndent string + returnExpr string + breakIndent string + } + var fixes []fix + for i := 0; i < len(lines)-1; i++ { + ln := strings.TrimRight(lines[i], "\r") + if strings.TrimSpace(ln) != "} while (true);" { + continue + } + whileIndent := leadingTabs(ln) + // Next non-blank line must be `return ;` at lesser or equal indent. + var retLine string + retIdx := -1 + for j := i + 1; j < len(lines); j++ { + rl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(rl) == "" { + continue + } + retLine = rl + retIdx = j + break + } + if retIdx < 0 || !strings.HasPrefix(strings.TrimSpace(retLine), "return ") { + continue + } + retIndent := leadingTabs(retLine) + if len(retIndent) > len(whileIndent) { + continue // return is more indented — inside a nested block, not the loop post-exit + } + // Scan backward from `} while (true);` to find the innermost `try{` ... `break;` ... + // `}catch(` pattern. We scan backward looking for a `break;` line, then verify it's + // inside a try block followed by a catch. The backward scan allows any lines in between + // (catch body statements, nested blocks) — it only stops at a line that clearly belongs + // to a different control structure (e.g. a `do{`, `while(`, `for(`, or method-level `}`). + breakIdx := -1 + breakIndent := "" + tryIndent := "" + for j := i - 1; j >= 0; j-- { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if strings.TrimSpace(jl) == "break;" { + breakIdx = j + breakIndent = leadingTabs(jl) + // Check that the nearest preceding non-blank line is `try{`. + for k := j - 1; k >= 0; k-- { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + if strings.TrimSpace(kl) == "try{" { + tryIndent = leadingTabs(kl) + } + break + } + break + } + // Stop at lines that clearly belong to an outer structure. + trimmed := strings.TrimSpace(jl) + if strings.HasPrefix(trimmed, "do{") || strings.HasPrefix(trimmed, "do {") || + strings.HasPrefix(trimmed, "while(") || strings.HasPrefix(trimmed, "while (") || + strings.HasPrefix(trimmed, "for(") || strings.HasPrefix(trimmed, "for (") || + strings.HasPrefix(trimmed, "switch(") || strings.HasPrefix(trimmed, "switch (") { + break + } + } + if breakIdx < 0 || tryIndent == "" { + continue + } + // `break;` should be one indent level deeper than `try{`. + if len(breakIndent) != len(tryIndent)+1 { + continue + } + // Check that the line after `break;` (skipping blanks) is `}catch(` at the same indent. + hasCatchAfter := false + for j := breakIdx + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if strings.HasPrefix(strings.TrimSpace(jl), "}catch(") && leadingTabs(jl) == tryIndent { + hasCatchAfter = true + } + break + } + if !hasCatchAfter { + continue + } + // Extract the return expression (without `return ` prefix and trailing `;`). + returnExpr := strings.TrimSpace(retLine) + returnExpr = strings.TrimPrefix(returnExpr, "return ") + returnExpr = strings.TrimSuffix(returnExpr, ";") + fixes = append(fixes, fix{ + whileIdx: i, + returnIdx: retIdx, + tryBreakIdx: breakIdx, + returnIndent: retIndent, + returnExpr: returnExpr, + breakIndent: breakIndent, + }) + } + if len(fixes) == 0 { + return body + } + // Apply in descending order: replace `break;` with `return ;`, blank out the post-loop return. + for k := len(fixes) - 1; k >= 0; k-- { + f := fixes[k] + lines[f.tryBreakIdx] = f.breakIndent + "return " + f.returnExpr + ";" + // Remove the post-loop return line (blank it out). + lines[f.returnIdx] = "" + } + 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 @@ -5102,6 +5421,181 @@ func enclosingReturnsReference(lines []string, idx int) bool { return false } +// fixThrowTypeVarCast adds the `(T)` cast to `throw ;` inside a method whose `throws` clause +// declares a single type variable (e.g. `throws T` where `T extends Throwable`), when the thrown +// variable's type is `Throwable` (too broad for `throws T`). The original source had +// `throw (T) throwable;` but the bytecode erased the cast to a no-op checkcast. Without the cast, +// javac rejects "unreported exception Throwable" because `throw varX` (varX: Throwable) is wider +// than the declared `throws T`. Kill-switch: JDEC_FIX_THROW_TYPEVAR_CAST_OFF=1. +// Canonical: commons-lang3 ExceptionUtils.typeErasure ` R typeErasure( +// Throwable throwable) throws T { throw (T) throwable; }`. +func fixThrowTypeVarCast(body string) string { + if os.Getenv("JDEC_FIX_THROW_TYPEVAR_CAST_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Pattern: a method signature line containing `throws ` (a single bare identifier, + // not a class name), followed by a body containing `throw ;` where is a variable + // of type Throwable (not already cast). We detect the method signature by the `throws T` clause + // and scan the method body for `throw ;`. + throwsTypeVarRe := regexp.MustCompile(`\)\s*throws\s+([A-Z]\w*)\s*\{`) + type edits struct { + lineIdx int + oldText string + newText string + } + var pending []edits + for i := 0; i < len(lines); i++ { + ln := strings.TrimRight(lines[i], "\r") + m := throwsTypeVarRe.FindStringSubmatch(ln) + if m == nil { + continue + } + typeVar := m[1] + // Scan the method body for `throw ;` (not already cast). + // The method body starts after the signature line and ends at the matching closing brace. + depth := 1 // we're inside the method body (the `{` on the signature line opened it) + for j := i + 1; j < len(lines) && depth > 0; j++ { + jl := strings.TrimRight(lines[j], "\r") + trimmed := strings.TrimSpace(jl) + if trimmed == "" { + continue + } + // Track brace depth. + for _, ch := range trimmed { + if ch == '{' { + depth++ + } else if ch == '}' { + depth-- + } + } + if depth <= 0 { + break + } + // Match `throw ;` where is a simple identifier (not already cast). + throwRe := regexp.MustCompile(`^(\s*)throw\s+(\w+)\s*;\s*$`) + tm := throwRe.FindStringSubmatch(jl) + if tm == nil { + continue + } + throwIndent := tm[1] + varName := tm[2] + // Only cast if the variable is not the type variable itself (throwing T is fine) + // and the variable is likely a Throwable-typed parameter/local. + if varName == typeVar { + continue + } + // Check the variable's type: scan backward for its declaration or parameter. + // If declared as `Throwable`, the cast is needed. For safety, only apply when the + // method has a `Throwable` parameter (the typical typeErasure pattern). + hasThrowableParam := strings.Contains(ln, "Throwable") + if !hasThrowableParam { + continue + } + pending = append(pending, edits{ + lineIdx: j, + oldText: jl, + newText: throwIndent + "throw (" + typeVar + ") " + varName + ";", + }) + } + } + if len(pending) == 0 { + return body + } + for k := len(pending) - 1; k >= 0; k-- { + e := pending[k] + lines[e.lineIdx] = e.newText + } + return strings.Join(lines, "\n") +} + +// fixUnreachableSwitchDefaultThrow removes a `throw new RuntimeException();` inserted by +// fixEmptySwitchDefault into an empty `default:` when the switch is followed by more code +// at the same or lesser indent — the empty default falls through to that code, and the +// throw makes it unreachable ("unreachable statement"). The throw is only removed when the +// line after the switch-closing `}` is NOT a `}` at the same or lesser indent (i.e. there IS +// post-switch code that the default should fall through to). Kill-switch: +// JDEC_FIX_UNREACHABLE_DEFAULT_THROW_OFF=1. Canonical: commons-lang3 RandomStringUtils. +func fixUnreachableSwitchDefaultThrow(body string) string { + if os.Getenv("JDEC_FIX_UNREACHABLE_DEFAULT_THROW_OFF") == "1" { + return body + } + lines := strings.Split(body, "\n") + // Pattern: + // default: + // throw new RuntimeException(); + // } + // + defaultRe := regexp.MustCompile(`^(\t+)default:\s*$`) + throwRe := regexp.MustCompile(`^\t+throw new RuntimeException\(\);\s*$`) + removes := []int{} // line indices to blank out (descending apply) + for i := 0; i < len(lines)-2; i++ { + dm := defaultRe.FindStringSubmatch(strings.TrimRight(lines[i], "\r")) + if dm == nil { + continue + } + indent := dm[1] + // Next non-blank line must be `throw new RuntimeException();` + throwIdx := -1 + for j := i + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if throwRe.MatchString(jl) { + throwIdx = j + } + break + } + if throwIdx < 0 { + continue + } + // Next non-blank line after throw must be `}` at the default's indent (switch close). + switchCloseIdx := -1 + for j := throwIdx + 1; j < len(lines); j++ { + jl := strings.TrimRight(lines[j], "\r") + if strings.TrimSpace(jl) == "" { + continue + } + if jl == indent+"}" { + switchCloseIdx = j + } + break + } + if switchCloseIdx < 0 { + continue + } + // Check the line after the switch-closing `}`: if it's NOT `}` at the same or lesser + // indent, there is post-switch code → the throw is unreachable → remove it. + for k := switchCloseIdx + 1; k < len(lines); k++ { + kl := strings.TrimRight(lines[k], "\r") + if strings.TrimSpace(kl) == "" { + continue + } + postIndent := leadingTabs(kl) + trimmed := strings.TrimSpace(kl) + if len(postIndent) <= len(indent) && trimmed == "}" { + break // block exit — throw is needed (no post-switch code) + } + if len(postIndent) <= len(indent) { + // Post-switch code exists at the same or lesser indent. + // But only remove the throw if the post-switch code is reachable (not itself + // unreachable). A `return`/`throw`/`continue`/`break` as post-switch code means + // the default falls through to a reachable terminator — the throw is unreachable. + removes = append(removes, throwIdx) + } + break + } + } + if len(removes) == 0 { + return body + } + for k := len(removes) - 1; k >= 0; k-- { + lines[removes[k]] = "" + } + return strings.Join(lines, "\n") +} + // 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. diff --git a/classparser/testdata/regression/CatchMergeSeed.class b/classparser/testdata/regression/CatchMergeSeed.class new file mode 100644 index 0000000000000000000000000000000000000000..67caf2a81ea701f250fc763c13dcd7fac8f04a6b GIT binary patch literal 655 zcmZ`%O;6iE5Pg#b8ypkTgoKZlrqBu{eEHG?5(o()kdP88HGtHkv#h`n*2uMi-@qT? z%n3myxbveBGY*jUP+Ojvot=5_?T(+G?;iks$2SKVWNqYJ48S53kNtDs>-y%<`_Vbp zDkfN~feGR@Lbl%6&tninHXIjuDX*pFNgQ-N6&jU9kv6f{4b{nSugR8>lSLlGDA*Wr zQIxJypX6s^jNj>MMvoFB=p`3rDY6nHtu`uza=&5sC<-s6!^Rk4vcJJdad4_PFO@!v zgU~prVnR+eNf@s;`-SfaD~){{p9qBwKUPOA9Ubbn)(5P4BRpV5BhA3*o#eEmqurjy zq9|Hd!myXBBP{<7{dSs_9^L=%g{cX{?buf*E&uEl-D!tOr1VxG*HQjg)RO4OG(PgU z>W+*QmDs zp!#d!7UPdQz1-sC4L4YDQNd{HYusF6IPsa3W|j$r%;M>}{FZpy70y-RaFvT$Ue{4z V_29oIyJur`o4-L;G?RjSeF4^viYWj9 literal 0 HcmV?d00001 diff --git a/classparser/testdata/regression/ThrowTypeVarCastSeed.class b/classparser/testdata/regression/ThrowTypeVarCastSeed.class new file mode 100644 index 0000000000000000000000000000000000000000..53970bd8c1fdabe93b59a4c1eab698abe3e1b010 GIT binary patch literal 585 zcmaKpJ5K^Z5Xb*>JPwXiQ1F>p*a3xDSP3C91RIGaaG^9=V1q~Cjx2}hx6(>up}il< zID3*n5N&35XXZE0&#$+40B6{@U?5^5Y9od?Au|y7qBIm=mRR835RmXtk0Dqu@piOEz+LLuK+WDNn)8Om~D$@){P zOo-O}9wX_7>&ffss3Vo5g9%psp)1FM>w6OuTZHVIl!RoWE9qmTX%;{+b7WfIrGO z#YBnqu$eb+ciz02{rG%)2XKmg7bSs?e6;a%B43vjDC-m5cScG_Y8ug%9>gZe7GzzN z1)6j9r23IEv;NI=PGKtGs4%1@gX8Y?-&?Q9^a6p}@Fg$Ntn8vH(EhDkImsIfRDC@& zD$Ok8MpsHtPP|@lCx@PZe=g68+LM2{;X3!D?p+kC*_R(@Hl&fx+aDB`a+2E=IK0hF zsuvXKM6=OLW8ze;h7mYglO1Iy%xM*kO-lAbg~ZP5V`In;B9$b>)3*g$g+>Cq#r->g wzy|*sNB)UTI4Hs8tjv4`HAW?DarTYsXsnoR>~M7WO_O7bPmM8mz@Ah20v$Pzwg3PC literal 0 HcmV?d00001 diff --git a/classparser/testdata/regression/ThrowsTypeVarSeed.class b/classparser/testdata/regression/ThrowsTypeVarSeed.class new file mode 100644 index 0000000000000000000000000000000000000000..4ef836b7a8c94bce77ff3a65ac3aedcffd8d8aa2 GIT binary patch literal 1562 zcma)6ZBr6a7(G{BR&W!9(yY`pG@z1R>`ll@(u5)X07KK4W-f5W)n%8KUCn>c-|182 zhkiiQZ)$q3pe#X+vopJQ?|sg>&pG#b?$6&RKLD&CAH)be3cM`5{l~+^DB^ zD*K{pGx#%_q1kH;o_L}h#3%v^f-1(KGK}}a3#L(LP!757h@D-AXgr@N^e?A_7)MA! zSfVEwCVI;`!xnX6F-*pDZdi9dJ%%VQE4ZR!5>pJZ(w=1=wMxfLQRY@rh+1ruYdWv! zVqMFcM$2hPGC!|YMUyn#j=NIn2ulynNO{w^rr^4Y8<;su(6KeW>qd(tTjG{^#sPx*! z63V#1vYOl{bXEtYQhH4${*eZBzE!U4ovLwRyM|Mgg8K}!y~HJpWjRe-)XvqB(xT~D zRk5i_*^>j-ElWIg#=2?RE!*PFcf#H?YppP*LOwi^4fIsSGdw3VkGMwF=8NZZiE^rfk+j2qf+CvJKyD1+q8}u#v1OwU+%K% zf;!1=aigY-R;*x}2Ts#%Y|*x~eyjU{vcufr7hmjb`G2gtO}flkI?RhKfx^BC2h?A%@rZ0=s8!4Fb_`I9^w&wRpQUk+zK9J6fgc5Pg%R-PlQ+(v+sOgaQSc1c@BrmZ}G$f{+48O;m}avstBXopt1{q5LAQ zaG?Su;LQKw3e37uKA;so>^$#0znOWSAHP0-1@H(p7b&C#GI`8lp0IS}z4q#Xr~CDn z-6JIpVSdNgzS$+DE7gM>vTy|4JaWhr$_YI-eo&X8mT?p*ZR$-g2)u5f2pP$cuu`eE z{uXWsEY8$xo5_Kutv!Cm1%nM2~Sa)X}@zAg>US54HreMTjMqeYn7=9CzR@eK$)=IiQYbr zly|(ZOdRR9Qav`O8TMGc(DJo<5f8d5>YTeKWH=bchI0=`s+T$3nD+JDzy@KVZ9IAW z+#8Pc-0^@X0(S|;i38&d!D;(_?Qwft_vwG??o16kD!Y4eFK#=GBdMPFb{$Ip+VH^k zf?K%GduX2&$ZjH^H~6j0kvPuP&Ojd+5H|U=X-jo*lRr%-)E-AGu~GX3LHLe^_pNuo zf407$`0D;gTs_0JZwbMHivklJEb{M8C4#G5)5^LKyx3XJILvWzn{RV0{7j(G6inaZ Siwsx4gL{lp*k)WwQ1uT=z}CqC literal 0 HcmV?d00001 diff --git a/classparser/throw_typevar_cast_test.go b/classparser/throw_typevar_cast_test.go new file mode 100644 index 0000000..1a5cc91 --- /dev/null +++ b/classparser/throw_typevar_cast_test.go @@ -0,0 +1,40 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestThrowTypeVarCastIsLoadBearing pins the fixThrowTypeVarCast post-processing fix. A generic +// method ` R typeErasure(Throwable t) throws T { throw (T) t; }` has +// its `(T)` cast erased by the bytecode (a no-op checkcast). Without the fix, the decompiler +// renders `throw t;` (where t is Throwable), which fails "unreported exception Throwable" +// because the method declares `throws T` (narrower). The fix detects `throw ;` inside a +// method whose `throws` clause is a single type variable and re-emits `throw () ;`. +// Kill-switch: JDEC_FIX_THROW_TYPEVAR_CAST_OFF. Real hit: commons-lang3 ExceptionUtils.typeErasure. +func TestThrowTypeVarCastIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/ThrowTypeVarCastSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + os.Unsetenv("JDEC_FIX_THROW_TYPEVAR_CAST_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "throw (T) var0") { + t.Errorf("fix ON: expected `throw (T) var0;` in decompiled output, got:\n%s", on) + } + + os.Setenv("JDEC_FIX_THROW_TYPEVAR_CAST_OFF", "1") + defer os.Unsetenv("JDEC_FIX_THROW_TYPEVAR_CAST_OFF") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if !strings.Contains(off, "throw var0") || strings.Contains(off, "throw (T) var0") { + t.Errorf("fix OFF: expected bare `throw var0;` (no cast) in decompiled output, got:\n%s", off) + } +} \ No newline at end of file diff --git a/classparser/throws_typevar_test.go b/classparser/throws_typevar_test.go new file mode 100644 index 0000000..430e07c --- /dev/null +++ b/classparser/throws_typevar_test.go @@ -0,0 +1,43 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestThrowsTypeVarRecoveryIsLoadBearing pins the Signature-attribute throws-type-variable +// recovery. A generic method ` void accept(..., T) throws T` whose +// ExceptionsAttribute carries the erasure `java.lang.Throwable` must be rendered as `throws T` +// (from the Signature `^TT;`), not `throws Throwable`. Without this, a call site like +// `accept(obj::wait, ...)` where `obj::wait` throws `InterruptedException` fails with +// "unreported exception Throwable" because javac sees the method throwing `Throwable` +// instead of inferring `T = InterruptedException`. The fix reads the `^`-prefixed throws +// types from the method Signature attribute and overrides the ExceptionsAttribute-derived +// `throws` clause. Kill-switch: JDEC_THROWS_SIG_RECOVERY_OFF. Real hit: commons-lang3 +// DurationUtils.accept -> ObjectUtils.wait. +func TestThrowsTypeVarRecoveryIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/ThrowsTypeVarSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + os.Unsetenv("JDEC_THROWS_SIG_RECOVERY_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + if !strings.Contains(on, "throws T") { + t.Errorf("fix ON: expected `throws T` in decompiled output, got:\n%s", on) + } + + os.Setenv("JDEC_THROWS_SIG_RECOVERY_OFF", "1") + defer os.Unsetenv("JDEC_THROWS_SIG_RECOVERY_OFF") + off, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix OFF) failed: %v", err) + } + if !strings.Contains(off, "throws Throwable") { + t.Errorf("fix OFF: expected `throws Throwable` in decompiled output, got:\n%s", off) + } +} \ No newline at end of file diff --git a/classparser/try_break_return_test.go b/classparser/try_break_return_test.go new file mode 100644 index 0000000..9824c65 --- /dev/null +++ b/classparser/try_break_return_test.go @@ -0,0 +1,52 @@ +package javaclassparser + +import ( + "os" + "strings" + "testing" +) + +// TestTryBreakReturnIsLoadBearing pins the fixTryBreakReturn post-processing fix. A do-while(true) +// loop containing a `try { return expr; } catch(CancellationException) {...} catch(ExecutionException) +// {...}` is structured by the do-while rewriter as `try { break; } catch(...) {...}` + a hoisted +// `return expr;` after the loop. But the `try { break; }` throws no checked exception, so the +// `catch(ExecutionException)` fails "exception is never thrown in body of corresponding try +// statement". The fix moves the hoisted `return ;` back into the `try { break; }` block, +// replacing `break;` with `return ;` and removing the post-loop return. Kill-switch: +// JDEC_FIX_TRY_BREAK_RETURN_OFF. Real hit: commons-lang3 Memoizer.compute. +// +// This seed verifies the ON path: the return must be inside the try block (not after the loop), +// so the catch clauses are valid (the return's expression can throw the caught exceptions). +func TestTryBreakReturnIsLoadBearing(t *testing.T) { + data, err := os.ReadFile("testdata/regression/TryBreakReturnSeed.class") + if err != nil { + t.Fatalf("read seed: %v", err) + } + + os.Unsetenv("JDEC_FIX_TRY_BREAK_RETURN_OFF") + on, err := Decompile(data) + if err != nil { + t.Fatalf("decompile (fix ON) failed: %v", err) + } + // The return should be INSIDE the try block (before } while), making the catch valid. + tryIdx := strings.Index(on, "try{") + if tryIdx < 0 { + t.Fatalf("fix ON: no try block found:\n%s", on) + } + whileIdx := strings.Index(on, "} while (true);") + if whileIdx < 0 { + t.Fatalf("fix ON: no do-while found:\n%s", on) + } + returnInTry := strings.Contains(on[tryIdx:whileIdx], "return ") + if !returnInTry { + t.Errorf("fix ON: return should be INSIDE the try block (before } while), got:\n%s", on) + } + // The output should recompile (no break-in-try + hoisted return pattern). + if strings.Contains(on[whileIdx:], "return ") { + // A return after the while loop means the return was NOT moved into the try. + afterWhile := strings.TrimSpace(on[whileIdx+len("} while (true);"):]) + if strings.HasPrefix(afterWhile, "return ") { + t.Errorf("fix ON: return should not be hoisted after the do-while, got:\n%s", on) + } + } +} \ No newline at end of file From b78b70ab11e49c7809f4c70605822f4237be2e39 Mon Sep 17 00:00:00 2001 From: v1ll4n Date: Wed, 15 Jul 2026 13:05:00 +0800 Subject: [PATCH 3/3] style: gofmt test files --- classparser/catch_merge_test.go | 2 +- classparser/throw_typevar_cast_test.go | 2 +- classparser/throws_typevar_test.go | 2 +- classparser/try_break_return_test.go | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/classparser/catch_merge_test.go b/classparser/catch_merge_test.go index 8d01781..2d5678a 100644 --- a/classparser/catch_merge_test.go +++ b/classparser/catch_merge_test.go @@ -51,4 +51,4 @@ func TestCatchMergeWrappingRethrowIsLoadBearing(t *testing.T) { if catchCountOff != 2 { t.Errorf("fix OFF: expected 2 catch clauses (unmerged), got %d:\n%s", catchCountOff, off) } -} \ No newline at end of file +} diff --git a/classparser/throw_typevar_cast_test.go b/classparser/throw_typevar_cast_test.go index 1a5cc91..f5eab43 100644 --- a/classparser/throw_typevar_cast_test.go +++ b/classparser/throw_typevar_cast_test.go @@ -37,4 +37,4 @@ func TestThrowTypeVarCastIsLoadBearing(t *testing.T) { if !strings.Contains(off, "throw var0") || strings.Contains(off, "throw (T) var0") { t.Errorf("fix OFF: expected bare `throw var0;` (no cast) in decompiled output, got:\n%s", off) } -} \ No newline at end of file +} diff --git a/classparser/throws_typevar_test.go b/classparser/throws_typevar_test.go index 430e07c..43b9a9d 100644 --- a/classparser/throws_typevar_test.go +++ b/classparser/throws_typevar_test.go @@ -40,4 +40,4 @@ func TestThrowsTypeVarRecoveryIsLoadBearing(t *testing.T) { if !strings.Contains(off, "throws Throwable") { t.Errorf("fix OFF: expected `throws Throwable` in decompiled output, got:\n%s", off) } -} \ No newline at end of file +} diff --git a/classparser/try_break_return_test.go b/classparser/try_break_return_test.go index 9824c65..c4fc061 100644 --- a/classparser/try_break_return_test.go +++ b/classparser/try_break_return_test.go @@ -49,4 +49,4 @@ func TestTryBreakReturnIsLoadBearing(t *testing.T) { t.Errorf("fix ON: return should not be hoisted after the do-while, got:\n%s", on) } } -} \ No newline at end of file +}