Keep ArrayStoreException catches when adding TypeNotPresentException - #1191
Draft
martinfrancois wants to merge 7 commits into
Draft
Conversation
…the old The recipe replaced the `ArrayStoreException` catch with `TypeNotPresentException`, so a protected region that could still throw an array store, such as an assignment into an array, lost its handler. It also fired on any `Class.getAnnotation` call anywhere under the try, including one in a catch body, a finally block or a deferred lambda, none of which the catch protects, and it ignored the other catches, so a try that already caught `TypeNotPresentException` was rewritten into two catches of the same type and stopped compiling. `TypeNotPresentException` is now added as a multi-catch alternative and the `ArrayStoreException` catch is kept. The call has to sit in the protected region, the resources or the body, with deferred bodies left out, and a try is skipped when its own catches, or those of an enclosing try that protects it, already concern `TypeNotPresentException`. Per JLS 14.20 a multi-catch parameter is implicitly final and typed as the least upper bound of its alternatives, here `RuntimeException`, so every reference to it in the handler has to survive that widening. An allow-list of contexts decides; anything unrecognized leaves the catch untouched, so the recipe now declines some catches it used to rewrite. The recipe became a `ScanningRecipe` so it can emit the fully qualified name where a source declares its own `TypeNotPresentException`, and it visits Java sources only, multi-catch being Java-only syntax. That scanned state is keyed by the `JavaProject` marker, so a declaration in one module no longer qualifies the name in every other module of a multi-module build. The existing `@DocumentExample` test expects the multi-catch now, and the recipe description, `examples.yml` and `recipes.csv` follow it.
4 tasks
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
ArrayStoreException catches when adding TypeNotPresentException
…ative allow list The catch-parameter check now recognizes only the common handler shapes: calls declared by Throwable except getClass, concatenation and comparison, instanceof, throw, and arguments, initializers and assignments whose declared type accepts every RuntimeException. Sources where the TypeNotPresentException simple name could be shadowed are skipped instead of receiving a fully qualified name. Both changes only cost migrations that are not applied.
…er handler shapes One fixture each for calls outside the protected region, for tries that already handle TypeNotPresentException, and for parameter uses beyond the allow list, plus two positive handler shapes mirroring WildFly's BusinessViewAnnotationProcessor and Grails' DomainClassArtefactHandler.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Suggested review order: 34 of 52 (Score: 2.5)
Review first: openrewrite/rewrite-static-analysis#967
What's changed?
ArrayStoreExceptionToTypeNotPresentExceptionnow addsTypeNotPresentExceptionas an alternative on the existing catch instead of replacingArrayStoreException, so both exceptions reach the same handler.The recipe changes a
catch (ArrayStoreException e)clause only when all three of the following hold.First, a call to
Class.getAnnotation(Class)occurs in the region the catch clauses of thistryprotect, its resources or its body. A call in acatchor afinallyblock does not count, nor one in the body of a lambda, an anonymous class or a local class declared inside thetry, since such a body can run long after thetryhas finished. That also skips a lambda invoked on the spot and an anonymous class's instance initializer, whose bodies do run inside the protected region, which costs a migration but never performs a wrong one.Second, no catch clause on this
try, and none on an enclosingtrywhose protected region contains thistry, already concerns itself withTypeNotPresentException: a clause catchingRuntimeException,ExceptionorThrowablealready handles it, and a clause catchingTypeNotPresentExceptionitself or a subclass of it would become unreachable.Third, every use of the catch parameter sits on a deliberately short allow list of contexts that still compile, and keep their meaning, after the parameter's type widens. Per JLS 14.20 the type of a multi-catch parameter is the least upper bound of its alternatives, here
RuntimeException, so javac reads the rewritten parameter as aRuntimeException. The allow list covers the common handler shapes: calls on the parameter whose resolved method is declared by a supertype ofRuntimeException, exceptgetClass, whose result type follows the receiver's static type; string concatenation and==/!=;instanceof;throw; and arguments, initializers and assignments whose declared target type accepts everyRuntimeException. Any other use leaves the clause untouched, which costs a migration but never performs a wrong one.Where a class declared in the sources could shadow the simple name
TypeNotPresentException, the recipe leaves the file unchanged. Finding those classes needs aScanningRecipe, whose accumulator is keyed by theJavaProjectmarker of the file declaring the class, so such a class in one module never blocks another module's files; files carrying no marker share one entry.HasNoJakartaAnnotationshere and MavenAddDependencyinopenrewrite/rewritescope their accumulators the same way.What's your motivation?
Recipe:
org.openrewrite.java.migrate.ArrayStoreExceptionToTypeNotPresentException.Before
Actual after the recipe
Expected after the recipe
The premise of this recipe, as part of
Java8toJava11, is thatClass.getAnnotationreports a missing annotation type asTypeNotPresentExceptionfrom Java 11 onwards, so a handler written forArrayStoreExceptionalone stops catching that failure. It does not follow thatArrayStoreExceptioncan no longer occur: the protected code can still throw it for reasons unrelated to annotations, as thevalues[0] = value;assignment does.After the recipe on main runs, nothing catches
ArrayStoreException, even when thetrybody can still throw it. I ran the example on Java 21 with a printingrecover: the original printsHANDLED java.lang.ArrayStoreException, while main's output lets that exception escape uncaught. Where thetryalready catchesTypeNotPresentException, main's output does not compile:error: exception TypeNotPresentException has already been caught. Reproduced on 3.41.0 and current main.Main also looks for
Class.getAnnotationanywhere inside thetrystatement, including infinally, in siblingcatchblocks and in lambdas thetrydoes not protect, so it rewrites catch clauses that an exception from that call cannot reach.Affected code in real projects
wildfly/wildflyBusinessViewAnnotationProcessor: catchesArrayStoreExceptionat three sites aroundgetAnnotation(Remote.class),getAnnotation(Local.class)andiface.getAnnotation(annotation), citing JDK-7183985, to raise WildFly's "missing class in annotation" deployment error. On Java 11+ the rawTypeNotPresentExceptionbypasses those handlers today; the multi-catch this recipe now emits routes it back to them.wildfly/wildflyServletContainerInitializerDeploymentProcessor: the same guard aroundgetAnnotation(HandlesTypes.class)while wiringServletContainerInitializerservices during deployment.apache/grails-coreDomainClassArtefactHandler: deliberately swallows the failure aroundgetAnnotation(Artefact.class)with an empty catch; the multi-catch restores that swallowing on Java 11+.oracle/graalNodeUtil: supporting evidence for keepingArrayStoreExceptioncaught rather than replacing it, this Truffle code catchesArrayStoreExceptionaround a genuine array store in a file that also callsgetAnnotation.All three affected files are eligible under this implementation, and their two handler shapes, a throw that never references the parameter and an empty swallowing catch, are the
requireAnnotationandisDomainClassscenarios inwidenCatchWhenHandlerAcceptsRuntimeException.Anything in particular you'd like reviewers to focus on?
One existing test changed: the
@DocumentExampletest, renamed fromreplaceCaughtExceptiontoalsoCatchTypeNotPresentException. Its input source is unchanged; in its expected output the two lines that read} catch (TypeNotPresentException e) {now read} catch (ArrayStoreException | TypeNotPresentException e) {. The generatedexamples.ymlcarries the same rename and the same two lines, and the generatedrecipes.csvrow carries the recipe's new description.Five limits:
TypeNotPresentExceptionleaves the wholetrystatement alone.e.getClass(), pass it to a generic method such asObjects.requireNonNull, or use it in a ternary keep their original catch. Each of those could be shown safe individually, but the analysis outgrew the migrations it enabled, so the recipe skips them.TypeNotPresentException, becauseJavaProjectmarkers carry no dependency edges: a class in another module is treated like one from a compiled dependency, even when that module is on the compile classpath and its class really does shadow the name there. Where it does shadow, for instance by sitting in the same package as the file being rewritten, the emitted simple name resolves to that class, so the output can fail to compile, or compile and catch the wrong type. A single accumulator entry for the whole repository would avoid that, but only by skipping every module as soon as any module declares such a class.ArrayStoreExceptionafter the rewrite, while parsing the printed output afresh attributes it withRuntimeException, the least upper bound named in the third condition above. I found no case where that difference matters.Have you considered any alternatives or workarounds?
A broad clause beside the narrow one on the same
tryneed not stop the migration: Java accepts a narrow catch clause that gains an alternative while a broader clause follows it. I skip the wholetrystatement anyway, because the broad clause already catchesTypeNotPresentException, so migrating the narrow clause beside it gains nothing. Reversing that means dropping the three entries of theHANDLES_TYPE_NOT_PRESENT_EXCEPTIONset this change introduces,java.lang.RuntimeException,java.lang.Exceptionandjava.lang.Throwable, from the check on thistrywhile keeping them in the check on an enclosingtry, where widening would intercept exceptions the enclosing handler sees today, and updating the one scenario that covers this shape,existingBroaderCatchinretainWhenTypeNotPresentExceptionIsAlreadyHandled.The multi-catch is built directly rather than with
JavaTemplate: a catch parameter is not a template insertion point, sinceJ.Try.CatchandJ.MultiCatchexpose no coordinates, and regenerating the parameter would drop the type expression's formatting and the parameter's modifiers and annotations. Splicing one name into the existing type expression followsCombineSemanticallyEqualCatchBlocksin rewrite-static-analysis.An earlier revision of this branch proved widening safety through generic method type inference and
Class<? extends T>result flows, and emitted the qualifiedjava.lang.TypeNotPresentExceptionwhere sources shadow the simple name instead of skipping the file. That version migrated more inputs but tripled the implementation. A missed migration is visible and recoverable by hand, while the proof machinery would need maintaining indefinitely, so this revision trades those rare migrations for the allow list and the skip.Any additional context
Pre-existing tests changed:
ArrayStoreExceptionToTypeNotPresentExceptionTest.java.replaceCaughtException(removed).The focused
ArrayStoreExceptionToTypeNotPresentExceptionTestclass now reports 21 executions, up from 3 on main. One existing test was renamed, and the branch adds 18 net test methods. Without the code change in this pull request, 18 executions fail. Related scenarios share one source fixture with one method per scenario rather than one test per scenario:widenCatchWhenHandlerAcceptsRuntimeExceptionholds 5 widening-safe handler shapes (including the WildFly and Grails shapes above),retainCatchWhoseHandlerUsesTheExceptionBeyondTheAllowListholds 14 conservatively skipped parameter uses,retainWhenGetAnnotationIsOutsideTheProtectedRegionholds 8 lookup placements the catches do not protect, andretainWhenTypeNotPresentExceptionIsAlreadyHandledholds 4 tries whose handling already covers it. The tests cover six shapes:TypeNotPresentExceptionand keepsArrayStoreException, for examplealsoCatchTypeNotPresentException;tryis skipped where it, or an enclosingtry, already handlesTypeNotPresentException, for example theexistingCatchscenario ofretainWhenTypeNotPresentExceptionIsAlreadyHandled, whose output on main names that exception in two clauses and does not compile;Class.getAnnotationcall sits outside the region its catch clauses protect, for example theinFinallyscenario ofretainWhenGetAnnotationIsOutsideTheProtectedRegion;RuntimeException, for example thepassToNarrowerParameterscenario ofretainCatchWhoseHandlerUsesTheExceptionBeyondTheAllowList; the uses that do tolerate the wider type are migrated, and covered too;retainWhenSamePackageClassShadowsSimpleName;The full list is the test file in this change.
One of those 18 failing executions,
alsoCatchTypeNotPresentException, is the pre-existing@DocumentExampletest under its new name, so this change does alter an expectation that used to hold, as described above.retainArrayStoreExceptionWhenLookupIsNotTypeAttributedpasses either way, as do the two pre-existing tests left untouched,retainOtherCaughtExceptionsandretainArrayStoreExceptionWithoutClassGetAnnotation.This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.
Checklist
./gradlew buildlocally, and committed any resulting changes torecipes.csv