Do not delete expressions that may have side effects - #959
Merged
Conversation
Regression tests for openrewrite/rewrite#8333, which stopped SemanticallyEqual treating a null fieldType on both sides as proof of equality. The Groovy case needs no harness setup, since the Groovy parser passes null for fieldType at essentially every reference site; the Java case strips attribution to simulate an LST where it did not complete. See #953
Four recipes gated deletion on a comparison that ignores what the deleted code
does, so removing it changed behaviour:
- RemoveUnconditionalValueOverwrite dropped the receiver, key and value the
overwritten call would have evaluated, so `m.put(k, register())` lost the
register() call.
- AllBranchesIdentical discarded the condition when collapsing a chain, so
`if (it.next() != null) p("x"); else p("x");` stopped advancing the iterator.
- RemoveDuplicateConditions treated syntactically identical conditions as
unreachable, dropping a branch that a non-idempotent condition would reach.
- AvoidBoxedBooleanExpressions rewrote `!b` outside any control position, where
visitExpression already guards on isControlExpression but visitUnary did not,
turning a would-be NullPointerException into false.
The purity check SimplifyRedundantLogicalExpression already carried moves to
SideEffects so all of them share it.
Fixes #953
steve-aom-elliott
approved these changes
Aug 5, 2026
steve-aom-elliott
left a comment
Contributor
There was a problem hiding this comment.
The changes make sense. I am wondering if we already have a recipe (or could make one) for being able to mark these edge scenarios that have side effects to encourage manual remediation, since code using side-effect laden conditionals or repeated assignments feels like a smell that someone should clear up.
martinfrancois
added a commit
to martinfrancois/rewrite-static-analysis
that referenced
this pull request
Aug 10, 2026
…shared SideEffects helper Rework the repeat-safety guard to the shape openrewrite#959 standardised: gate on `SideEffects.mayHaveSideEffects`, exactly as `RemoveRedundantNullCheckBeforeInstanceof` does for the identical pattern, instead of the bespoke `isRepeatSafe`/`isClassName` predicate the previous commit introduced. The previous javadoc argued the shared helper "cannot gate this removal". That was wrong for every case the guard has to block: repeated method invocations (direct and chained), `values[i++]` and `(s = t)` are all reported by the helper. The one thing it does not model is a read of a volatile field, which has no side effect but is a synchronization action; that stays as a separate check on the field attribution rather than being folded into a bespoke predicate. The bespoke guard was also much broader than the defect it fixed, and its javadoc justified the extra breadth with a claim that does not hold: for `values[0]` and `(String) o` the surviving evaluation throws the same exception and yields the same value as the removed one, so those removals were always behaviour-preserving. With the helper in place they simplify again, as do Groovy sources, which the attribution requirement had made the recipe entirely inert on. Test changes: - `doNotChangeWhenNullCheckedExpressionIsNotASimpleReference` bundled one case that must stay rejected with two that never needed rejecting; it is now `doNotChangeWhenNullCheckedExpressionIsAssignment` and `doNotChangeWhenArrayIndexHasSideEffect` plus the positive `removeRedundantNullCheckWithArrayAccess` and `removeRedundantNullCheckWithCast`. - The Groovy test pinned total inertness as if it were the fix; it now pins that a parameter simplifies and a repeated invocation does not. Known limit, unchanged from both `origin/main` and the instanceof sibling: a Groovy implicit property read (`value` backed by `getValue()`) parses as an identifier, so a shape-based side-effect check cannot see the getter dispatch and the null check is still removed there. See openrewrite#953 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 10, 2026
Open
mergify Bot
added a commit
to robfrank/linklift
that referenced
this pull request
Aug 20, 2026
… 2.40.0 to 2.41.0 [skip ci] Bumps [org.openrewrite.recipe:rewrite-static-analysis](https://github.com/openrewrite/rewrite-static-analysis) from 2.40.0 to 2.41.0. Release notes *Sourced from [org.openrewrite.recipe:rewrite-static-analysis's releases](https://github.com/openrewrite/rewrite-static-analysis/releases).* > 2.41.0 > ------ > > What's Changed > -------------- > > * Pre-install the JavaScript RPC npm package before running tests by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#956](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/956) > * UnnecessaryExplicitTypeArguments: retain witness for a return-only ty… by [`@neil-mushell`](https://github.com/neil-mushell) in [openrewrite/rewrite-static-analysis#958](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/958) > * Do not delete expressions that may have side effects by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#959](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/959) > * OpenRewrite recipe best practices by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#960](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/960) > * UseLambdaForFunctionalInterface: only convert when the anonymous class implements the SAM by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#962](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/962) > * ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977) > * RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/971) > * Make sure ModifierOrder doesn't alter Python's def quasi-modifier by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/981) > * NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE by [`@martinfrancois`](https://github.com/martinfrancois) in [openrewrite/rewrite-static-analysis#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/968) > * `FallThrough` to work only for Java files by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/982) > * Retry a failed npx warm, and survive a machine without Node by [`@timtebeek`](https://github.com/timtebeek) in [openrewrite/rewrite-static-analysis#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/984) > * Make `DefaultComesLast` apply only to Java by [`@greg-at-moderne`](https://github.com/greg-at-moderne) in [openrewrite/rewrite-static-analysis#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/983) > > New Contributors > ---------------- > > * [`@martinfrancois`](https://github.com/martinfrancois) made their first contribution in [openrewrite/rewrite-static-analysis#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/pull/977) > > **Full Changelog**: <openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0> Commits * [`43b51de`](openrewrite/rewrite-static-analysis@43b51de) Make DefaultComesLast apply only to Java ([#983](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/983)) * [`5d08edd`](openrewrite/rewrite-static-analysis@5d08edd) Retry a failed npx warm, and survive a machine without Node ([#984](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/984)) * [`49ddf78`](openrewrite/rewrite-static-analysis@49ddf78) FallThrough to work only for Java files ([#982](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/982)) * [`9699a7e`](openrewrite/rewrite-static-analysis@9699a7e) NullableOnMethodReturnType: only move annotations applicable to TYPE\_USE ([#968](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/968)) * [`0c6f970`](openrewrite/rewrite-static-analysis@0c6f970) Make sure ModifierOrder doesn't alter Python's def quasi-modifier ([#981](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/981)) * [`6ef24c5`](openrewrite/rewrite-static-analysis@6ef24c5) RemoveMethodsOnlyCallSuper: keep synchronized and deprecated overrides ([#971](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/971)) * [`0d8f7d7`](openrewrite/rewrite-static-analysis@0d8f7d7) ReplaceStringBuilderWithString: wrap `char[]` appends in `String.valueOf` ([#977](https://redirect.github.com/openrewrite/rewrite-static-analysis/issues/977)) * [`9a15a92`](openrewrite/rewrite-static-analysis@9a15a92) OpenRewrite recipe best practices * [`a394f54`](openrewrite/rewrite-static-analysis@a394f54) UnnecessaryExplicitTypeArguments: regression test for witness on varargs meth... * [`0ecb6d8`](openrewrite/rewrite-static-analysis@0ecb6d8) Update Gradle wrapper to 9.7.0 * Additional commits viewable in [compare view](openrewrite/rewrite-static-analysis@v2.40.0...v2.41.0) [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- Dependabot commands and options You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
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.
The problem
RemoveUnconditionalValueOverwritem.put("k", register()); m.put("k", 2);m.put("k", 2);register()is never calledAllBranchesIdenticalif (it.next() != null) p("x"); else p("x");p("x");RemoveDuplicateConditionsif (advance()) p("a"); else if (advance()) p("b");if (advance()) p("a");bAvoidBoxedBooleanExpressionsboolean x = !b;boolean x = Boolean.FALSE.equals(b);NullPointerExceptionbecomesfalseThe fix
SimplifyRedundantLogicalExpressionalready carried exactly the purity check the other three need, as a private method. This moves it toSideEffects.mayHaveSideEffectsand shares it:RemoveUnconditionalValueOverwrite— the overwrittenputreally is dead, but the receiver, key and value it evaluates on the way are not, so only those are checked, not the call itself. Otherwise the guard would block every case the recipe exists for.AllBranchesIdenticalandRemoveDuplicateConditions— require every condition in the chain to be pure. ForRemoveDuplicateConditionsit is not enough to check the duplicated condition alone: a side effect in an intervening condition can change what the later one evaluates to, which is what makes the branch reachable.AvoidBoxedBooleanExpressions—visitExpressionalready gated its rewrite onisControlExpression;visitUnarynow does too. In anifor ternary the substitution preserves meaning, which is the recipe's purpose; in an assignment it does not.Each recipe gains a
doNotChange…test built from the reproduction above, plus one for a side-effecting key inRemoveUnconditionalValueOverwrite. Full suite green (2205 tests).Also included
RemoveSelfAssignmentregression tests for Do not treat missing type attribution as proof of semantic equality rewrite#8333, the upstream fix for the original half of Deletion-gating recipes lack purity guards, and untyped identifier comparisons remain unsound #953. The Groovy one needs no harness setup at all — the Groovy parser passesnullforfieldTypeat essentially every reference site, sothis.readOnly = readOnlyin a constructor reproduced the deletion directly. That is worth having as a permanent guard, since it does not depend on stripping attribution by hand. Happy to split it into its own PR if you would rather land it separately — it is independent of the purity work.Open question
mayHaveSideEffectstreats any method invocation as impure, because purity cannot be decided from the LST alone. That is the safe direction for recipes that delete code, but it does narrowRemoveDuplicateConditionsin particular:if (isFoo()) … else if (isFoo()) …is a common shape for that rule and is now skipped. Every existing test still passes, so nothing currently covered regresses — but if you would rather keep firing on repeated calls and accept the risk, or gate on something narrower, say so and I will adjust.Two smaller notes:
Expression#getSideEffects()looked like the natural built-in, but it reports only the side effects of the expression's own node type.J.Ternaryhas no override, soc ? f() : g()reports as pure. The conservative subtree walk is used instead; the reasoning is in the javadoc onSideEffects.CommonStaticAnalysisor any other collection, so exposure today is direct invocation only.