From ffb20b66d25dccc9f3ebdaf9a291dc0e590bc54d Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Fri, 22 May 2026 13:34:15 +0000 Subject: [PATCH 1/2] Prevent nested foreach unrolling to fix exponential blowup - Add `insideUnrolledForeach` flag to StatementContext to track whether we are already inside an unrolled foreach body - Skip unrolling when already inside an unrolled foreach, avoiding multiplicative 3^k work for k nesting levels - Remove the FOREACH_UNROLL_NESTED_LIMIT constant and foreachUnrollFactor field, which were an insufficient fix - Propagate the flag through the final convergence pass so nested foreaches inside unrolled bodies also skip unrolling - Update test expectations for bug-7978, bug-9332, bug-14489 to reflect slightly less precise (but still correct) types when nested foreach unrolling is skipped --- src/Analyser/NodeScopeResolver.php | 8 +-- src/Analyser/StatementContext.php | 12 ++-- tests/PHPStan/Analyser/nsrt/bug-14489.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7978.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9332.php | 2 +- tests/bench/data/bug-14674.php | 83 +++++++++++++++++++++++ 6 files changed, 96 insertions(+), 13 deletions(-) create mode 100644 tests/bench/data/bug-14674.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a52b1c94f2d..a42b6ee1286 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -193,7 +193,6 @@ class NodeScopeResolver private const LOOP_SCOPE_ITERATIONS = 3; private const GENERALIZE_AFTER_ITERATION = 1; private const FOREACH_UNROLL_LIMIT = 16; - private const FOREACH_UNROLL_NESTED_LIMIT = 16; /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -1486,7 +1485,8 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); - $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); + $finalPassContext = $unrolledEndScope !== null ? $context->enterUnrolledForeach() : $context; + $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); $scopesWithIterableValueType = []; @@ -4114,7 +4114,7 @@ private function tryProcessUnrolledConstantArrayForeach( if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; } - if ($context->getForeachUnrollFactor() * $totalKeys > self::FOREACH_UNROLL_NESTED_LIMIT) { + if ($context->isInsideUnrolledForeach()) { return null; } @@ -4129,7 +4129,7 @@ private function tryProcessUnrolledConstantArrayForeach( $allChainScopes = []; $allBreakScopes = []; - $bodyContext = $context->enterUnrolledForeach($totalKeys); + $bodyContext = $context->enterUnrolledForeach(); foreach ($constantArrays as $arrayIndex => $constantArray) { $keyTypes = $constantArray->getKeyTypes(); diff --git a/src/Analyser/StatementContext.php b/src/Analyser/StatementContext.php index fb2893b584e..0adcfddc1f4 100644 --- a/src/Analyser/StatementContext.php +++ b/src/Analyser/StatementContext.php @@ -15,7 +15,7 @@ final class StatementContext private function __construct( private bool $isTopLevel, - private int $foreachUnrollFactor = 1, + private bool $insideUnrolledForeach = false, ) { } @@ -41,23 +41,23 @@ public function isTopLevel(): bool return $this->isTopLevel; } - public function getForeachUnrollFactor(): int + public function isInsideUnrolledForeach(): bool { - return $this->foreachUnrollFactor; + return $this->insideUnrolledForeach; } public function enterDeep(): self { if ($this->isTopLevel) { - return new self(false, $this->foreachUnrollFactor); + return new self(false, $this->insideUnrolledForeach); } return $this; } - public function enterUnrolledForeach(int $totalKeys): self + public function enterUnrolledForeach(): self { - return new self($this->isTopLevel, $this->foreachUnrollFactor * $totalKeys); + return new self($this->isTopLevel, true); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14489.php b/tests/PHPStan/Analyser/nsrt/bug-14489.php index f1471e754a7..070445cf857 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14489.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14489.php @@ -23,7 +23,7 @@ function () { } $values = array_values($cData); - assertType('array{array{1}, array{4}}', $values); + assertType('array{0: non-empty-array<0|1, 1|4>, 1?: non-empty-array<0|1, 1|4>}', $values); }; function () { diff --git a/tests/PHPStan/Analyser/nsrt/bug-7978.php b/tests/PHPStan/Analyser/nsrt/bug-7978.php index 5a47433c4db..885a1d66931 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7978.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7978.php @@ -31,7 +31,7 @@ public function doSomething(): void foreach ($fields as $field) { $credentials[$field] = 'fake'; } - assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials); + assertType("non-empty-array{password?: 'fake', username?: 'fake', app_id?: 'fake', app_key?: 'fake'}", $credentials); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9332.php b/tests/PHPStan/Analyser/nsrt/bug-9332.php index d774abc48c8..44e60113b2d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9332.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9332.php @@ -15,7 +15,7 @@ public function sayHello(): void } } - assertType("array{a: 'asdfghi', bd: int<1, 1000>, be: int<1, 1000>, cd: int<1, 1000>, ce: int<1, 1000>}", $data); + assertType("array{a: 'asdfghi', bd?: int<1, 1000>, be?: int<1, 1000>, cd?: int<1, 1000>, ce?: int<1, 1000>}", $data); $this->doSomething($data); } diff --git a/tests/bench/data/bug-14674.php b/tests/bench/data/bug-14674.php new file mode 100644 index 00000000000..8d42eb62957 --- /dev/null +++ b/tests/bench/data/bug-14674.php @@ -0,0 +1,83 @@ +assertTrue($closure()); + } + + /** + * @return iterable + */ + public static function performanceProvider(): iterable { + foreach(['0', '1'] as $level_1) { + $keys = [$level_1]; + + foreach(['0', '1'] as $level_2) { + $keys[] = $level_2; + + foreach(['0', '1'] as $level_3) { + $keys[] = $level_3; + + foreach(['0', '1'] as $level_4) { + $keys[] = $level_4; + + foreach(['0', '1'] as $level_5) { + $keys[] = $level_5; + + foreach(['0', '1'] as $level_6) { + $keys[] = $level_6; + + foreach(['0', '1'] as $level_7) { + $keys[] = $level_7; + + foreach(['0', '1'] as $level_8) { + $keys[] = $level_8; + + foreach(['0', '1'] as $level_9) { + $keys[] = $level_9; + + foreach(['0', '1'] as $level_10) { + $keys[] = $level_10; + + foreach(['0', '1'] as $level_11) { + $keys[] = $level_11; + + foreach(['0', '1'] as $level_12) { + $keys[] = $level_12; + + foreach(['0', '1'] as $level_13) { + $keys[] = $level_13; + + $case = [ + 'closure' => function () use ($level_1, $level_3, $level_5, $level_13) { + return $level_1 === '1' && $level_3 === '1' && $level_5 === '1' && $level_13 === '1'; + }, + ]; + + yield implode('-', $keys) => $case; + } + } + } + } + } + } + } + } + } + } + } + } + } + } + +} From c82b6f097a23b3ba6d227e86eac87afe240f6a17 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 23 May 2026 06:49:24 +0000 Subject: [PATCH 2/2] Use factor-based nested foreach unroll limit instead of boolean flag Instead of completely disabling nested foreach unrolling, restore the factor-based approach with a reduced limit (8, down from the original 16). This preserves type precision for small nested foreaches (2 levels of 2-key arrays) while still preventing exponential blowup for deeply nested cases. Key changes: - FOREACH_UNROLL_NESTED_LIMIT = 8 (only checked when factor > 1, so top-level foreaches are unaffected by the nested limit) - Factor is properly propagated through the final convergence pass via enterUnrolledForeach(totalKeys), fixing a bug where the original code reset the factor in the final pass - Restored precise type expectations in bug-7978, bug-9332, bug-14489 Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 14 +++++++++----- src/Analyser/StatementContext.php | 12 ++++++------ tests/PHPStan/Analyser/nsrt/bug-14489.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-7978.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9332.php | 2 +- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a42b6ee1286..4072b789182 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -193,6 +193,7 @@ class NodeScopeResolver private const LOOP_SCOPE_ITERATIONS = 3; private const GENERALIZE_AFTER_ITERATION = 1; private const FOREACH_UNROLL_LIMIT = 16; + private const FOREACH_UNROLL_NESTED_LIMIT = 8; /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; @@ -1449,6 +1450,7 @@ public function processStmtNode( $originalStorage = $storage; $unrolledEndScope = null; + $unrolledTotalKeys = null; if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); @@ -1457,6 +1459,7 @@ public function processStmtNode( if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; $unrolledEndScope = $unrolledResult['endScope']; + $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $nodeCallback); $count = 0; @@ -1485,7 +1488,7 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); - $finalPassContext = $unrolledEndScope !== null ? $context->enterUnrolledForeach() : $context; + $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); $scopesWithIterableValueType = []; @@ -4079,7 +4082,7 @@ public function processVarAnnotation(MutatingScope $scope, array $variableNames, } /** - * @return array{bodyScope: MutatingScope, endScope: MutatingScope}|null + * @return array{bodyScope: MutatingScope, endScope: MutatingScope, totalKeys: int}|null */ private function tryProcessUnrolledConstantArrayForeach( Foreach_ $stmt, @@ -4114,7 +4117,8 @@ private function tryProcessUnrolledConstantArrayForeach( if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; } - if ($context->isInsideUnrolledForeach()) { + $foreachUnrollFactor = $context->getForeachUnrollFactor(); + if ($foreachUnrollFactor > 1 && $foreachUnrollFactor * $totalKeys > self::FOREACH_UNROLL_NESTED_LIMIT) { return null; } @@ -4129,7 +4133,7 @@ private function tryProcessUnrolledConstantArrayForeach( $allChainScopes = []; $allBreakScopes = []; - $bodyContext = $context->enterUnrolledForeach(); + $bodyContext = $context->enterUnrolledForeach($totalKeys); foreach ($constantArrays as $arrayIndex => $constantArray) { $keyTypes = $constantArray->getKeyTypes(); @@ -4242,7 +4246,7 @@ private function tryProcessUnrolledConstantArrayForeach( $endScope = $endScope->mergeWith($breakScope); } - return ['bodyScope' => $bodyScope, 'endScope' => $endScope]; + return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys]; } private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint diff --git a/src/Analyser/StatementContext.php b/src/Analyser/StatementContext.php index 0adcfddc1f4..fb2893b584e 100644 --- a/src/Analyser/StatementContext.php +++ b/src/Analyser/StatementContext.php @@ -15,7 +15,7 @@ final class StatementContext private function __construct( private bool $isTopLevel, - private bool $insideUnrolledForeach = false, + private int $foreachUnrollFactor = 1, ) { } @@ -41,23 +41,23 @@ public function isTopLevel(): bool return $this->isTopLevel; } - public function isInsideUnrolledForeach(): bool + public function getForeachUnrollFactor(): int { - return $this->insideUnrolledForeach; + return $this->foreachUnrollFactor; } public function enterDeep(): self { if ($this->isTopLevel) { - return new self(false, $this->insideUnrolledForeach); + return new self(false, $this->foreachUnrollFactor); } return $this; } - public function enterUnrolledForeach(): self + public function enterUnrolledForeach(int $totalKeys): self { - return new self($this->isTopLevel, true); + return new self($this->isTopLevel, $this->foreachUnrollFactor * $totalKeys); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14489.php b/tests/PHPStan/Analyser/nsrt/bug-14489.php index 070445cf857..f1471e754a7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14489.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14489.php @@ -23,7 +23,7 @@ function () { } $values = array_values($cData); - assertType('array{0: non-empty-array<0|1, 1|4>, 1?: non-empty-array<0|1, 1|4>}', $values); + assertType('array{array{1}, array{4}}', $values); }; function () { diff --git a/tests/PHPStan/Analyser/nsrt/bug-7978.php b/tests/PHPStan/Analyser/nsrt/bug-7978.php index 885a1d66931..5a47433c4db 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-7978.php +++ b/tests/PHPStan/Analyser/nsrt/bug-7978.php @@ -31,7 +31,7 @@ public function doSomething(): void foreach ($fields as $field) { $credentials[$field] = 'fake'; } - assertType("non-empty-array{password?: 'fake', username?: 'fake', app_id?: 'fake', app_key?: 'fake'}", $credentials); + assertType("array{app_id: 'fake', app_key: 'fake'}|array{username: 'fake', password: 'fake'}", $credentials); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9332.php b/tests/PHPStan/Analyser/nsrt/bug-9332.php index 44e60113b2d..d774abc48c8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9332.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9332.php @@ -15,7 +15,7 @@ public function sayHello(): void } } - assertType("array{a: 'asdfghi', bd?: int<1, 1000>, be?: int<1, 1000>, cd?: int<1, 1000>, ce?: int<1, 1000>}", $data); + assertType("array{a: 'asdfghi', bd: int<1, 1000>, be: int<1, 1000>, cd: int<1, 1000>, ce: int<1, 1000>}", $data); $this->doSomething($data); }