Skip to content

Recover never-collapsed variables from the loop-condition falsey scope for side-effecting while/for conditions#5923

Open
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7a1yw80
Open

Recover never-collapsed variables from the loop-condition falsey scope for side-effecting while/for conditions#5923
phpstan-bot wants to merge 5 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7a1yw80

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan inferred *NEVER* for a variable after a while loop whose condition mutates that variable, e.g.:

$x = 5;
while (--$x > 0) {
}
if ($x === 0) { // wrongly reported as identical.alwaysFalse
}

Because $x was inferred as *NEVER* after the loop, any later comparison against it ($x === 0) was reported as identical.alwaysFalse. The fix makes the after-loop type sound (int<min, 0>, which contains 0), so the false positive is gone.

Changes

  • src/Analyser/MutatingScope.php: add restoreNeverTypesFrom(self $other). It walks the scope's expression types, and for any expression that is NeverType takes the corresponding type (and native type) from $other when that one is not NeverType. All other expressions are left untouched, so precision is preserved.
  • src/Analyser/NodeScopeResolver.php:
    • while loop exit: keep computing the precise after-loop scope via filterByFalseyValue($cond), but when the condition contains a side effect, recover the never-collapsed variables from getFalseyScope() of the loop-condition evaluation.
    • for loop exit: same treatment for the last condition expression.
    • add exprContainsSideEffect() which detects PreInc/PreDec/PostInc/PostDec/Assign/AssignOp/AssignRef anywhere in the condition.
  • tests/PHPStan/Analyser/nsrt/bug-10109.php: regression test.

Root cause

while/for loops compute the after-loop scope from the end-of-body scope narrowed by MutatingScope::filterByFalseyValue($cond). filterByFalseyValue() only narrows types — it does not re-apply the side effects of the condition.

For while (--$x > 0), the type specifier (via BinaryOpHandler/createRangeTypes) builds the specification assuming $x already holds its post-decrement value (it asserts $x is not int<1, max> in the falsey branch). But the end-of-body scope still holds the pre-decrement value int<1, 4>. Subtracting int<1, max> from int<1, 4> yields *NEVER*.

Re-evaluating the condition at the exit is not an option: the while/for condition is evaluated once at the loop top (feeding the body via the truthy branch), so re-processing it would apply the decrement a second time and shift the result by one (int<min, -1> instead of int<min, 0>). The correct exit scope for the mutated variable is the falsey branch of that same single top-of-loop evaluation, which applied the side effect exactly once. The fix grafts only the never-collapsed variables from that falsey scope onto the otherwise-precise end-of-body scope.

This is why do-while was never affected: it evaluates the condition once at the bottom via processExprNode(...)->getFalseyScope(), so the side effect is applied exactly once and there is no contradiction.

Test

tests/PHPStan/Analyser/nsrt/bug-10109.php asserts the after-loop type for the whole family of side-effecting loop conditions, all of which previously inferred *NEVER*:

  • while (--$x > 0)int<min, 0> (the reported case, with and without a body)
  • while (++$x < 10)int<10, max>
  • while (($x = $x - 1) > 0)int<min, 0> (assignment in condition)
  • while ($x-- > 0)int<min, -1> (post-decrement)
  • for ($x = 5; --$x > 0;)int<min, 0>
  • a control case while ($x > 0) { $x = $x - 1; } (no side effect in condition) still infers the precise 0.

The whole test fails on master (each side-effecting assertion reports *NEVER*) and passes with the fix. The full test suite (make tests), self-analysis (make phpstan) and coding standard (make cs-fix) are green; notably tests/PHPStan/Analyser/nsrt/while-loop-variables.php, which exercises a side-effecting while condition (($val = fetch()) && $i++ < 10), keeps its precise expectations because those variables never collapse to never.

Fixes phpstan/phpstan#10109

@staabm staabm 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.

needs a rule test which covers https://phpstan.org/r/3b00642c-dac9-45f1-bf79-effcc05c4cc6 and shows the errors no longer appear

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed staabm's review request by adding a rule test that exercises the playground scenario through the actual error-reporting rule.

What I added

tests/PHPStan/Rules/Comparison/data/bug-10109.php — a StrictComparisonOfDifferentTypesRule data file reproducing the reported code: a while (--$x > 0) loop followed by if ($x === 0), in both the minimal form and a "closer to real code" variant (with a loop body and a parameterized bound), matching the two versions described in the issue.

StrictComparisonOfDifferentTypesRuleTest::testBug10109() — asserts the rule reports no errors for that file.

Verification

  • Fails on master (source reverted to the pre-fix commit): the rule reports Strict comparison using === between *NEVER* and 0 will always evaluate to false. at line 11 — exactly the identical.alwaysFalse false positive from the issue.
  • Passes with the fix: no errors.
  • Full StrictComparisonOfDifferentTypesRuleTest class: 87/87 green.
  • make phpstan: no errors.

This complements the existing nsrt/bug-10109.php (which asserts the inferred type) by demonstrating the user-visible error is gone, which is what the reviewer asked for. Committed separately and pushed.

staabm and others added 4 commits June 27, 2026 14:07
…e for side-effecting `while`/`for` conditions

- `while`/`for` loops compute their after-loop scope by narrowing the end-of-body
  scope with `filterByFalseyValue($cond)`. That only narrows types, it never
  re-applies the condition's side effects, so a condition such as `while (--$x > 0)`
  narrows the not-yet-decremented `$x` against the spec computed for its decremented
  value and collapses `$x` to `*NEVER*`, producing a spurious `identical.alwaysFalse`.
- Add `MutatingScope::restoreNeverTypesFrom()`: for every expression that became
  `NeverType` it takes the corrected type from another scope (where the value is not
  never), leaving everything else untouched.
- In the `while` and `for` exit handling, when the condition contains a side effect
  (`++`/`--`/`=`/`+=`/`=&`), recover the never-collapsed variables from the falsey
  branch of the loop-condition evaluation, where the side effects were applied exactly
  once. Conditions without side effects keep the existing precise behaviour.
- Covers pre/post increment and decrement as well as assignments inside the condition,
  for both `while` and `for`. `do-while` already evaluated the condition once at the
  bottom and was unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-7a1yw80 branch from 3acf2f5 to a31bae7 Compare June 27, 2026 12:07

@staabm staabm 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.

fix the implementation. the test expectations are correct

1) PHPStan\Rules\Comparison\StrictComparisonOfDifferentTypesRuleTest::testBug10109
Failed asserting that two strings are identical.
--- Expected
+++ Actual
@@ @@
-'
+'19: Strict comparison using === between int<1, 5> and 0 will always evaluate to false.
 '

…ion falsey scope

The previous recovery only restored variables that filterByFalseyValue()
collapsed to never. When the side effect sits behind a short-circuiting
operator (e.g. `while (mt_rand(...) < 10 && --$x > 0)`) the variable is not
collapsed to never but left at its in-loop value, which can exclude a reachable
exit value (here `int<1, 5>`, missing `0`), reintroducing the
`identical.alwaysFalse` false positive.

Graft the mutated variables from the loop-condition falsey scope by unioning
their corrected type into the after-loop scope. Pre-increments/decrements and
assignments compare the same value the variable ends up with, so their falsey
type is always grafted; post-increments/decrements compare the pre-mutation
value, so they are only grafted to rescue a never-collapse, preserving the
precise bound of counter idioms like `$i++ < 10`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed staabm's review. Here's a summary of what I found and fixed.

The problem

staabm's failing test showed the rule still reported the false positive on line 19 of bug-10109.php — the "closer to real code" variant where the side effect sits behind a short-circuit operator:

while (mt_rand(0, 10) < 10 && --$x > 0) { ... }
if ($x === 0) { ... } // still: int<1, 5> === 0 always false

The original fix only recovered variables that filterByFalseyValue() had collapsed to never. That collapse only happens for standalone side-effecting conditions. Behind a &&, short-circuit means filterByFalseyValue() can't narrow the variable at all, so $x was left at its in-loop value int<1, 5> — which excludes the reachable 0 — and the never-only recovery never fired.

The fix

Instead of keying recovery on "did it collapse to never", I now collect the variables actually mutated by the condition's side effects and graft their corrected type from the loop-condition falsey scope (unioning it in), where the side effect was applied exactly once.

The subtlety I had to get right (and verified empirically with \PHPStan\dumpType): grafting everything regressed the established while (($val = fetch()) && $i++ < 10) counter, widening $i from int<0, 10> to int<0, max>. So the graft is conditioned on the mutation kind:

  • Pre-inc/dec and assignments (--$x, $x = ...): the condition compares the same value the variable ends up with → falsey scope is the correct exit type → always grafted.
  • Post-inc/dec ($i++): the condition compares the pre-mutation value → only grafted to rescue a never-collapse, preserving the precise counter bound.

Verification

  • The flagged rule test testBug10109 now passes (confirmed it fails for the exact int<1, 5> reason when stashed).
  • while-loop-variables.php keeps int<0, 10>; all 6 original bug-10109b.php assertions hold; added two new assertions covering both short-circuit families.
  • Full make tests (17490 tests), make phpstan, and make cs are green.

Pushed as commit c9a191295.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHPStan confused by prefix decrement in while condition

2 participants