Skip to content

Do not delete expressions that may have side effects - #959

Merged
timtebeek merged 2 commits into
mainfrom
tim/953-purity-guards
Aug 5, 2026
Merged

Do not delete expressions that may have side effects#959
timtebeek merged 2 commits into
mainfrom
tim/953-purity-guards

Conversation

@timtebeek

@timtebeek timtebeek commented Aug 5, 2026

Copy link
Copy Markdown
Member

The problem

Recipe Before After Effect
RemoveUnconditionalValueOverwrite m.put("k", register()); m.put("k", 2); m.put("k", 2); register() is never called
AllBranchesIdentical if (it.next() != null) p("x"); else p("x"); p("x"); the iterator never advances
RemoveDuplicateConditions if (advance()) p("a"); else if (advance()) p("b"); if (advance()) p("a"); prints nothing where the original printed b
AvoidBoxedBooleanExpressions boolean x = !b; boolean x = Boolean.FALSE.equals(b); a would-be NullPointerException becomes false

The fix

SimplifyRedundantLogicalExpression already carried exactly the purity check the other three need, as a private method. This moves it to SideEffects.mayHaveSideEffects and shares it:

  • RemoveUnconditionalValueOverwrite — the overwritten put really 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.
  • AllBranchesIdentical and RemoveDuplicateConditions — require every condition in the chain to be pure. For RemoveDuplicateConditions it 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.
  • AvoidBoxedBooleanExpressionsvisitExpression already gated its rewrite on isControlExpression; visitUnary now does too. In an if or 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 in RemoveUnconditionalValueOverwrite. Full suite green (2205 tests).

Also included

Open question

mayHaveSideEffects treats 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 narrow RemoveDuplicateConditions in 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.Ternary has no override, so c ? f() : g() reports as pure. The conservative subtree walk is used instead; the reasoning is in the javadoc on SideEffects.
  • None of these four recipes is wired into CommonStaticAnalysis or any other collection, so exposure today is direct invocation only.

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
@github-project-automation github-project-automation Bot moved this to In Progress in OpenRewrite Aug 5, 2026
@timtebeek
timtebeek marked this pull request as ready for review August 5, 2026 12:32

@steve-aom-elliott steve-aom-elliott left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-project-automation github-project-automation Bot moved this from In Progress to Ready to Review in OpenRewrite Aug 5, 2026
@timtebeek
timtebeek merged commit 70da659 into main Aug 5, 2026
1 check passed
@timtebeek
timtebeek deleted the tim/953-purity-guards branch August 5, 2026 18:22
@github-project-automation github-project-automation Bot moved this from Ready to Review to Done in OpenRewrite Aug 5, 2026
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>
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)
  
[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility\_score?dependency-name=org.openrewrite.recipe:rewrite-static-analysis&package-manager=maven&previous-version=2.40.0&new-version=2.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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

2 participants