Recover never-collapsed variables from the loop-condition falsey scope for side-effecting while/for conditions#5923
Conversation
staabm
left a comment
There was a problem hiding this comment.
needs a rule test which covers https://phpstan.org/r/3b00642c-dac9-45f1-bf79-effcc05c4cc6 and shows the errors no longer appear
|
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
Verification
This complements the existing |
…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>
3acf2f5 to
a31bae7
Compare
staabm
left a comment
There was a problem hiding this comment.
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>
|
I've addressed staabm's review. Here's a summary of what I found and fixed. The problemstaabm's failing test showed the rule still reported the false positive on line 19 of while (mt_rand(0, 10) < 10 && --$x > 0) { ... }
if ($x === 0) { ... } // still: int<1, 5> === 0 always falseThe original fix only recovered variables that The fixInstead 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
Verification
Pushed as commit |
Summary
PHPStan inferred
*NEVER*for a variable after awhileloop whose condition mutates that variable, e.g.:Because
$xwas inferred as*NEVER*after the loop, any later comparison against it ($x === 0) was reported asidentical.alwaysFalse. The fix makes the after-loop type sound (int<min, 0>, which contains0), so the false positive is gone.Changes
src/Analyser/MutatingScope.php: addrestoreNeverTypesFrom(self $other). It walks the scope's expression types, and for any expression that isNeverTypetakes the corresponding type (and native type) from$otherwhen that one is notNeverType. All other expressions are left untouched, so precision is preserved.src/Analyser/NodeScopeResolver.php:whileloop exit: keep computing the precise after-loop scope viafilterByFalseyValue($cond), but when the condition contains a side effect, recover the never-collapsed variables fromgetFalseyScope()of the loop-condition evaluation.forloop exit: same treatment for the last condition expression.exprContainsSideEffect()which detectsPreInc/PreDec/PostInc/PostDec/Assign/AssignOp/AssignRefanywhere in the condition.tests/PHPStan/Analyser/nsrt/bug-10109.php: regression test.Root cause
while/forloops compute the after-loop scope from the end-of-body scope narrowed byMutatingScope::filterByFalseyValue($cond).filterByFalseyValue()only narrows types — it does not re-apply the side effects of the condition.For
while (--$x > 0), the type specifier (viaBinaryOpHandler/createRangeTypes) builds the specification assuming$xalready holds its post-decrement value (it asserts$xis notint<1, max>in the falsey branch). But the end-of-body scope still holds the pre-decrement valueint<1, 4>. Subtractingint<1, max>fromint<1, 4>yields*NEVER*.Re-evaluating the condition at the exit is not an option: the
while/forcondition 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 ofint<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-whilewas never affected: it evaluates the condition once at the bottom viaprocessExprNode(...)->getFalseyScope(), so the side effect is applied exactly once and there is no contradiction.Test
tests/PHPStan/Analyser/nsrt/bug-10109.phpasserts 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>while ($x > 0) { $x = $x - 1; }(no side effect in condition) still infers the precise0.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; notablytests/PHPStan/Analyser/nsrt/while-loop-variables.php, which exercises a side-effectingwhilecondition (($val = fetch()) && $i++ < 10), keeps its precise expectations because those variables never collapse tonever.Fixes phpstan/phpstan#10109