From 4a5087717961f27026c677c15ba56cc22be476d9 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 20:11:37 +0000 Subject: [PATCH] Use condResult->getFalseyScope() for loop exit when condition has direct inc/dec When a while or for loop condition is a comparison with a direct PreInc/PreDec/PostInc/PostDec operand (e.g. `while(++$counter < 100)`), the old approach of applying filterByFalseyValue on the body output scope produced NEVER for the counter variable. This happened because the body scope inherited the truthy-narrowed type from condition evaluation, and the falsey filter then contradicted it by subtracting the same range. The fix uses condResult->getFalseyScope() in this case, which correctly captures both the side effect (increment/decrement) and the falsey narrowing on the pre-body merged scope. For conditions without direct inc/dec operators, the old filterByFalseyValue approach is preserved to maintain body-output precision for other variables. Closes https://github.com/phpstan/phpstan/issues/7230 --- src/Analyser/NodeScopeResolver.php | 36 +++++++++-- tests/PHPStan/Analyser/nsrt/bug-7230.php | 82 ++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7230.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1f0b7a4cb0..caac975a05 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1502,9 +1502,10 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $condResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + $finalScope = $this->getWhileLoopExitScope($stmt->cond, $condResult, $finalScopeResult->getScope()); $alwaysIterates = false; $neverIterates = false; @@ -1727,9 +1728,11 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($initScope); $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); + $lastCondResult = null; if ($lastCondExpr !== null) { $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $lastCondResult = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $lastCondResult->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); } @@ -1746,7 +1749,7 @@ public function processStmtNode( $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { - $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + $finalScope = $this->getWhileLoopExitScope($lastCondExpr, $lastCondResult, $finalScope); } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); @@ -4819,6 +4822,31 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): return $stmts; } + private function conditionHasDirectIncDec(Expr $cond): bool + { + if (!$cond instanceof BinaryOp\Smaller && !$cond instanceof BinaryOp\SmallerOrEqual && !$cond instanceof BinaryOp\Greater && !$cond instanceof BinaryOp\GreaterOrEqual) { + return false; + } + + return $cond->left instanceof Expr\PreInc + || $cond->left instanceof Expr\PreDec + || $cond->left instanceof Expr\PostInc + || $cond->left instanceof Expr\PostDec + || $cond->right instanceof Expr\PreInc + || $cond->right instanceof Expr\PreDec + || $cond->right instanceof Expr\PostInc + || $cond->right instanceof Expr\PostDec; + } + + private function getWhileLoopExitScope(Expr $cond, ExpressionResult $condResult, MutatingScope $bodyOutputScope): MutatingScope + { + if ($this->conditionHasDirectIncDec($cond)) { + return $condResult->getFalseyScope(); + } + + return $bodyOutputScope->filterByFalseyValue($cond); + } + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope { // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7230.php b/tests/PHPStan/Analyser/nsrt/bug-7230.php new file mode 100644 index 0000000000..01c80c6ad0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7230.php @@ -0,0 +1,82 @@ +', $counter); + } + assertType('int<100, max>', $counter); + + if ($counter === 100) { + assertType('100', $counter); + } +} + +function preIncWhileWithBody(): void +{ + $tmp = ''; + $counter = 0; + while (++$counter < 100) { + $tmp = 'tmp_' . (string) $counter; + } + assertType('int<100, max>', $counter); + + if ($counter === 100) { + $tmp = 'tmp_hardcoded'; + } + + assertType("''|(literal-string&lowercase-string&non-falsy-string)", $tmp); +} + +function postIncWhile(): void +{ + $counter = 0; + while ($counter++ < 100) { + assertType('int<1, 100>', $counter); + } + assertType('int<101, max>', $counter); + + if ($counter === 101) { + assertType('101', $counter); + } +} + +function preDecWhile(): void +{ + $counter = 100; + while (--$counter > 0) { + assertType('int<1, 99>', $counter); + } + assertType('int', $counter); +} + +function preIncFor(): void +{ + $counter = 0; + for (; ++$counter < 5; ) { + } + assertType('int<5, max>', $counter); +} + +function postIncFor(): void +{ + $counter = 0; + for (; $counter++ < 5; ) { + } + assertType('int<6, max>', $counter); +} + +function preDecFor(): void +{ + $counter = 10; + for (; --$counter > 0; ) { + } + assertType('int', $counter); +}