Drop receivers only when they have no observable effect - #8444
Draft
martinfrancois wants to merge 3 commits into
Draft
Drop receivers only when they have no observable effect#8444martinfrancois wants to merge 3 commits into
martinfrancois wants to merge 3 commits into
Conversation
…fect The recipe replaced an invocation's receiver with the target type name unconditionally. Java still evaluates that expression, before the arguments, and only then discards its value, so `receiver().value()` became `B.value()` and the call to `receiver()` was gone: evaluation, ordering and any exception it raised were all observable, and the recipe dropped them. A member reference is worse still, because it evaluates and null checks its qualifier when the reference is created and `B::value` does neither. A receiver is now dropped only when its evaluation cannot be observed: a type name, `this` (possibly qualified), a literal, or a non-volatile variable read. An instantiation is still dropped, since `new A().staticMethod()` is the shape this recipe exists to rewrite, but only when its own arguments are discardable as well. A member reference is retargeted only when its qualifier already names a type. Every other invocation is left alone, receiver evaluation intact. Chains the recipe rewrites itself still collapse: a qualifier that is itself a matching invocation with a discardable receiver is accepted recursively, so `legacy.value().value()` still becomes `B.value()` rather than `B.value().value()`, which does not compile once `value()` is static on the target type. The deliberate cost is that a bound member reference on a variable, `a::value`, is no longer retargeted, as the null check it performs at creation has no equivalent in the static form. No existing test expectation changed.
…mentary A matched qualifier was collapsed without checking its own arguments, so `A.of(argument()).reverse()` silently dropped `argument()`. Rebuilding from the unvisited node also discarded argument rewrites, costing a second cycle, and a field read qualified by a type name or `this` was needlessly skipped.
This was referenced Aug 11, 2026
martinfrancois
marked this pull request as draft
August 16, 2026 01:10
martinfrancois
force-pushed
the
fix/change-method-target-to-static-receiver-evaluation
branch
from
August 16, 2026 20:17
0c73efc to
0cc77d3
Compare
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: 28 of 52 (Score: 3.5)
Review first: openrewrite/rewrite-static-analysis#975
What's changed?
ChangeMethodTargetToStaticnow rewrites a call only when the receiver can be deleted without the running program noticing. On main every matched receiver is replaced by the target type name, whatever it does.Java requires the receiver to be evaluated: per JLS 15.12.4.1 the qualifier of
Primary.method(...)is evaluated and its value discarded even when the invoked method is static, and per JLS 15.13.3 the qualifier of a bound method reference is evaluated and null checked when the reference is created. Main's output still compiles and still returns the same value, so only what the program does while it runs changes.A receiver is still deleted when it is a type name,
this(includingOuter.this), a literal, a non-volatile variable read, a non-volatile field read qualified by a type name orthis, or anew A(...)with no anonymous body, no enclosing instance and arguments that are themselves droppable. Every other form now stops the rewrite: an unmatched invocation, a field access on another expression such asother.field, an array access, a cast.For a member reference the rule is stricter: the qualifier is replaced only when it is a type name,
this, or a call this recipe rewrites away itself, since creating the reference already evaluates and null checks it.Chains still collapse - with
a.A value()retargeted tob.B,legacy.value().value()becomesB.value()- but only when the collapsed qualifier's own receiver and arguments are droppable.legacy.combine(argument()).value()is now left alone rather than losingargument().What's your motivation?
Recipe:
org.openrewrite.java.ChangeMethodTargetToStatic.Before
Actual after the recipe
Expected after the recipe
(unchanged)
Anything in particular you'd like reviewers to focus on?
No existing test expectation changed, so the existing tests show nothing of the rewrites the recipe no longer performs. Those are a real loss of coverage: a bound method reference on a variable,
a::value, is no longer retargeted toB::value, because creatinga::valuenull checksawhileB::valuedoes not.Three recipes call this one, and all three keep working.
MockUtilsToStaticin rewrite-testing-frameworks rewritesnew MockUtil().isMock(x)and aMockUtilheld in a local or a field - an instantiation and a variable read, both still droppable.RemovedToolProviderConstructorandRemovedModifierAndConstantBootstrapsConstructors, insideUpgradeToJava17in rewrite-migrate-java, targetjavax.tools.ToolProvider,java.lang.reflect.Modifierandjava.lang.invoke.ConstantBootstraps, whose usages take the same two shapes. The one shape they lose is the receiver that is itself a call,modifier().isPublic(1), which is the defect above.Two receivers are still deleted although deleting them can be observed. Main deletes them too, so neither is a regression:
new A().staticMethod()still loses the instantiation, though a constructor can run arbitrary code and throw. This is deliberate: it is the shape the recipe exists to rewrite, so treating an instantiation as undeletable would switch the recipe off for its main use.INSTANCE.stat(), is a variable read and is still deleted, losing the class initialization JLS 12.4.1 requires on first use of a non-constant static field.Have you considered any alternatives or workarounds?
One alternative is to keep the receiver and emit it as a separate
receiver();statement before the rewritten call. That is a much larger change: it has to decide whether the call sits somewhere a statement can be inserted at all - a field initializer or a ternary branch offers nowhere to put one - and for a member reference it has to generate an explicit null check, because the implicit one is lost once the qualifier becomes a type name. That work can follow this change; say so if you prefer it.Any additional context
Adds 15 tests to
ChangeMethodTargetToStaticTest, taking it from 12 to 27. Nine fail without the code change; the rest cover rewrites the recipe must still perform, showing the change does not narrow it too far.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