From 301e8d49840f242f09e89e9a8fee1035e32aa26b Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 25 May 2026 21:48:51 +0000 Subject: [PATCH 1/3] Run loop convergence at all nesting depths in `NodeScopeResolver` - Remove `if ($context->isTopLevel())` guard from convergence blocks in all four loop types (foreach, while, do-while, for) so that nested loops converge even when inside an outer loop's convergence - Keep `$context->enterDeep()` in convergence body processing so that other top-level behaviors (alwaysIterates/neverIterates detection, early exit for always-false conditions) remain suppressed for nested loops - Update two test expectations that reflected the old imprecise analysis: - generalize-scope-recursive.php: more precise recursive array type - bug-10438.php: list instead of array{}|array{string} Closes https://github.com/phpstan/phpstan/issues/10666 --- src/Analyser/NodeScopeResolver.php | 204 +++++++++--------- tests/PHPStan/Analyser/nsrt/bug-10438.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10666.php | 37 ++++ .../nsrt/generalize-scope-recursive.php | 2 +- .../PHPStan/Rules/Variables/EmptyRuleTest.php | 7 + .../Rules/Variables/data/bug-10666.php | 60 ++++++ 6 files changed, 204 insertions(+), 108 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10666.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-10666.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 93bb2b8b5d3..d608641ff63 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1450,37 +1450,35 @@ public function processStmtNode( $originalStorage = $storage; $unrolledEndScope = null; - if ($context->isTopLevel()) { - $storage = $originalStorage->duplicate(); + $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); - if ($unrolledResult !== null) { - $bodyScope = $unrolledResult['bodyScope']; - $unrolledEndScope = $unrolledResult['endScope']; - } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); - $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - if ($bodyScope->equals($prevScope)) { - break; - } + $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; + $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + if ($unrolledResult !== null) { + $bodyScope = $unrolledResult['bodyScope']; + $unrolledEndScope = $unrolledResult['endScope']; + } else { + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $storage = $originalStorage->duplicate(); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); } $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); @@ -1673,28 +1671,26 @@ public function processStmtNode( } $bodyScope = $condResult->getTruthyScope(); - if ($context->isTopLevel()) { - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $storage = $originalStorage->duplicate(); - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - if ($bodyScope->equals($prevScope)) { - break; - } + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $storage = $originalStorage->duplicate(); + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; @@ -1766,34 +1762,32 @@ public function processStmtNode( $impurePoints = []; $originalStorage = $storage; - if ($context->isTopLevel()) { - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $storage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); - } - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - if ($bodyScope->equals($prevScope)) { - break; - } + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $storage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); - $bodyScope = $bodyScope->mergeWith($scope); - } + $bodyScope = $bodyScope->mergeWith($scope); $storage = $originalStorage; $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -1886,39 +1880,37 @@ public function processStmtNode( } } - if ($context->isTopLevel()) { - $count = 0; - do { - $prevScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $bodyScope = $bodyScope->mergeWith($initScope); - if ($lastCondExpr !== null) { - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - } - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + $count = 0; + do { + $prevScope = $bodyScope; + $storage = $originalStorage->duplicate(); + $bodyScope = $bodyScope->mergeWith($initScope); + if ($lastCondExpr !== null) { + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } - foreach ($stmt->loop as $loopExpr) { - $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); - $bodyScope = $exprResult->getScope(); - $hasYield = $hasYield || $exprResult->hasYield(); - $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); - } + foreach ($stmt->loop as $loopExpr) { + $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } - if ($bodyScope->equals($prevScope)) { - break; - } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); - } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); $storage = $originalStorage; $bodyScope = $bodyScope->mergeWith($initScope); diff --git a/tests/PHPStan/Analyser/nsrt/bug-10438.php b/tests/PHPStan/Analyser/nsrt/bug-10438.php index 89fbd0762c9..19ee2249366 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10438.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10438.php @@ -21,7 +21,7 @@ public function extract(SimpleXMLElement $data, string $type = 'Meta'): array $meta[$key] = (string)$tag->{$valueName}; continue; } - assertType('array', $meta); + assertType('array|string>', $meta); $meta[$key] = []; assertType('array{}', $meta[$key]); foreach ($tag->{$valueName} as $value) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10666.php b/tests/PHPStan/Analyser/nsrt/bug-10666.php new file mode 100644 index 00000000000..805dcfc8579 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10666.php @@ -0,0 +1,37 @@ +", $text); + array_pop($text); + assertType("list<'x'>", $text); + } + assertType("non-empty-list<'x'>", $text); + return implode('', $text); +} + +function shiftInNestedLoop(int $n): string +{ + $text = []; + while (empty($text)) { + $i = 0; + while ($i < $n) { + $text[] = 'x'; + $i++; + } + array_shift($text); + } + assertType("non-empty-list<'x'>", $text); + return implode('', $text); +} diff --git a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index d4a82c8dcb4..0b03bacddb7 100644 --- a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php @@ -16,7 +16,7 @@ public function doFoo(array $array, array $values) } } - assertType('array{}|array{foo?: array}', $data); + assertType('array{}|array{foo: array}', $data); } /** diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 582fdeb1076..a3ca451895e 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -223,6 +223,13 @@ public function testBug7806(): void $this->analyse([__DIR__ . '/data/bug-7806.php'], []); } + public function testBug10666(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/bug-10666.php'], []); + } + public function testBug14393(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Variables/data/bug-10666.php b/tests/PHPStan/Rules/Variables/data/bug-10666.php new file mode 100644 index 00000000000..dd1ed8eb8f3 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-10666.php @@ -0,0 +1,60 @@ + Date: Tue, 26 May 2026 09:09:42 +0000 Subject: [PATCH 2/3] Bound loop convergence depth to prevent slowdown in deeply nested loops Instead of removing the `isTopLevel()` guard entirely (which would cause O(3^N) convergence iterations for N-deep nested loops), replace it with a depth-limited `shouldRunLoopConvergence()` check. StatementContext now tracks nesting depth instead of a boolean. Convergence runs at depth 0 (top-level) and depth 1 (one level of nesting), bounded by LOOP_CONVERGENCE_DEPTH_LIMIT = 2. This fixes the bug (which involves exactly one level of nesting) while limiting worst-case work to 3^2 = 9 convergence iterations instead of unbounded 3^N. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 204 +++++++++++++++-------------- src/Analyser/StatementContext.php | 23 ++-- 2 files changed, 121 insertions(+), 106 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d608641ff63..3e97c12bd89 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1450,35 +1450,37 @@ public function processStmtNode( $originalStorage = $storage; $unrolledEndScope = null; - $storage = $originalStorage->duplicate(); + if ($context->shouldRunLoopConvergence()) { + $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); - if ($unrolledResult !== null) { - $bodyScope = $unrolledResult['bodyScope']; - $unrolledEndScope = $unrolledResult['endScope']; - } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); - $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - if ($bodyScope->equals($prevScope)) { - break; - } + $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; + $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + if ($unrolledResult !== null) { + $bodyScope = $unrolledResult['bodyScope']; + $unrolledEndScope = $unrolledResult['endScope']; + } else { + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $storage = $originalStorage->duplicate(); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } } $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); @@ -1671,26 +1673,28 @@ public function processStmtNode( } $bodyScope = $condResult->getTruthyScope(); - $count = 0; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $storage = $originalStorage->duplicate(); - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - if ($bodyScope->equals($prevScope)) { - break; - } + if ($context->shouldRunLoopConvergence()) { + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $storage = $originalStorage->duplicate(); + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; @@ -1762,32 +1766,34 @@ public function processStmtNode( $impurePoints = []; $originalStorage = $storage; - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - $storage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); - } - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - if ($bodyScope->equals($prevScope)) { - break; - } + if ($context->shouldRunLoopConvergence()) { + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $storage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); - $bodyScope = $bodyScope->mergeWith($scope); + $bodyScope = $bodyScope->mergeWith($scope); + } $storage = $originalStorage; $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -1880,37 +1886,39 @@ public function processStmtNode( } } - $count = 0; - do { - $prevScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $bodyScope = $bodyScope->mergeWith($initScope); - if ($lastCondExpr !== null) { - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - } - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + if ($context->shouldRunLoopConvergence()) { + $count = 0; + do { + $prevScope = $bodyScope; + $storage = $originalStorage->duplicate(); + $bodyScope = $bodyScope->mergeWith($initScope); + if ($lastCondExpr !== null) { + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } - foreach ($stmt->loop as $loopExpr) { - $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); - $bodyScope = $exprResult->getScope(); - $hasYield = $hasYield || $exprResult->hasYield(); - $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); - } + foreach ($stmt->loop as $loopExpr) { + $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } - if ($bodyScope->equals($prevScope)) { - break; - } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } $storage = $originalStorage; $bodyScope = $bodyScope->mergeWith($initScope); diff --git a/src/Analyser/StatementContext.php b/src/Analyser/StatementContext.php index fb2893b584e..27495eec644 100644 --- a/src/Analyser/StatementContext.php +++ b/src/Analyser/StatementContext.php @@ -13,8 +13,10 @@ final class StatementContext { + private const LOOP_CONVERGENCE_DEPTH_LIMIT = 2; + private function __construct( - private bool $isTopLevel, + private int $depth, private int $foreachUnrollFactor = 1, ) { @@ -25,7 +27,7 @@ private function __construct( */ public static function createTopLevel(): self { - return new self(true); + return new self(0); } /** @@ -33,12 +35,17 @@ public static function createTopLevel(): self */ public static function createDeep(): self { - return new self(false); + return new self(self::LOOP_CONVERGENCE_DEPTH_LIMIT); } public function isTopLevel(): bool { - return $this->isTopLevel; + return $this->depth === 0; + } + + public function shouldRunLoopConvergence(): bool + { + return $this->depth < self::LOOP_CONVERGENCE_DEPTH_LIMIT; } public function getForeachUnrollFactor(): int @@ -48,16 +55,16 @@ public function getForeachUnrollFactor(): int public function enterDeep(): self { - if ($this->isTopLevel) { - return new self(false, $this->foreachUnrollFactor); + if ($this->depth >= self::LOOP_CONVERGENCE_DEPTH_LIMIT) { + return $this; } - return $this; + return new self($this->depth + 1, $this->foreachUnrollFactor); } public function enterUnrolledForeach(int $totalKeys): self { - return new self($this->isTopLevel, $this->foreachUnrollFactor * $totalKeys); + return new self($this->depth, $this->foreachUnrollFactor * $totalKeys); } } From a3c227d4443a0cac6e2fc3d6bb0fe5a0c8194fc3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 09:30:44 +0000 Subject: [PATCH 3/3] Add phpbench benchmark for deeply nested loops Adds a benchmark data file with 5-level deep nested loops (while, for, foreach, and mixed loop types) to verify that the loop convergence depth limit prevents performance degradation for deeply nested loop structures. Co-Authored-By: Claude Opus 4.6 --- tests/bench/data/deeply-nested-loops.php | 108 +++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 tests/bench/data/deeply-nested-loops.php diff --git a/tests/bench/data/deeply-nested-loops.php b/tests/bench/data/deeply-nested-loops.php new file mode 100644 index 00000000000..8fd039ae542 --- /dev/null +++ b/tests/bench/data/deeply-nested-loops.php @@ -0,0 +1,108 @@ + $items + */ + public function fiveLevelWhile(array $items): int + { + $count = 0; + while ($items !== []) { + $item = array_shift($items); + $parts = explode('.', $item); + while ($parts !== []) { + $part = array_shift($parts); + $chars = str_split($part); + while ($chars !== []) { + $char = array_shift($chars); + $codes = [ord($char)]; + while ($codes !== []) { + $code = array_shift($codes); + $bits = []; + while ($code > 0) { + $bits[] = $code & 1; + $code >>= 1; + } + $count += count($bits); + } + } + } + } + return $count; + } + + /** + * @param list $items + */ + public function fiveLevelFor(array $items): int + { + $count = 0; + for ($a = 0; $a < count($items); $a++) { + $parts = explode('.', $items[$a]); + for ($b = 0; $b < count($parts); $b++) { + $chars = str_split($parts[$b]); + for ($c = 0; $c < count($chars); $c++) { + $codes = [ord($chars[$c])]; + for ($d = 0; $d < count($codes); $d++) { + $code = $codes[$d]; + for ($e = 0; $e < 8; $e++) { + $count += ($code >> $e) & 1; + } + } + } + } + } + return $count; + } + + /** + * @param list $items + */ + public function fiveLevelForeach(array $items): int + { + $count = 0; + foreach ($items as $item) { + $parts = explode('.', $item); + foreach ($parts as $part) { + $chars = str_split($part); + foreach ($chars as $char) { + $codes = [ord($char)]; + foreach ($codes as $code) { + $bits = []; + foreach (range(0, 7) as $i) { + $bits[] = ($code >> $i) & 1; + } + $count += array_sum($bits); + } + } + } + } + return $count; + } + + /** + * @param array> $data + */ + public function mixedLoopTypes(array $data): int + { + $total = 0; + foreach ($data as $key => $values) { + $i = 0; + while ($i < count($values)) { + $n = $values[$i]; + for ($j = 0; $j < $n; $j++) { + $k = $j; + do { + $total += $k; + $k--; + } while ($k > 0); + } + $i++; + } + } + return $total; + } +}