From f2e4370243efdc7ceae60510a8c0055f339e9771 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:40:22 +0000 Subject: [PATCH] Keep the terminated iterations' scope after an unrolled constant-array `foreach` when no iteration leaves the loop - ForeachHandler::tryProcessUnrolledConstantArrayForeach() collects the end scope of every non-optional iteration whose body never reaches the next iteration. When no chain scope and no break scope remain (the code after the loop is unreachable), these scopes become the end scope. Previously the end scope stayed null, and the caller fell back to the generic loop pass. That pass sees the loop variable as the union of all values, so the subject lost its narrowing to `never`. This was a regression from 003d60144. - Ported the same change to the turbo-ext mirror (ForeachHandler.cpp). - Analogous constructs: while/for/do-while loops are not unrolled, and the generic foreach path already falls back to the body result scope (`$finalScope ??= $finalScopeResult->getScope()`), so only the unrolled path was affected. --- src/Analyser/StmtHandler/ForeachHandler.php | 10 ++++ tests/PHPStan/Analyser/nsrt/bug-15390.php | 61 +++++++++++++++++++++ turbo-ext/src/ForeachHandler.cpp | 15 +++++ 3 files changed, 86 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15390.php diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index 2ba5323bf7b..46ad6ac2b05 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -724,6 +724,7 @@ private function tryProcessUnrolledConstantArrayForeach( $allBodyScopes = []; $allChainScopes = []; $allBreakScopes = []; + $allTerminatedScopes = []; $bodyContext = $context->enterUnrolledForeach($totalKeys); @@ -807,6 +808,7 @@ private function tryProcessUnrolledConstantArrayForeach( } // no later iteration runs, the loop is left only through its break statements + $allTerminatedScopes[] = $bodyResult->getScope(); $chainScope = null; break; } @@ -853,6 +855,14 @@ private function tryProcessUnrolledConstantArrayForeach( $endScope = $endScope === null ? $breakScope : $endScope->mergeWith($breakScope); } + // the code after the loop is unreachable - keep what the terminated + // iterations narrowed instead of falling back to the generic loop pass + if ($endScope === null) { + foreach ($allTerminatedScopes as $terminatedScope) { + $endScope = $endScope === null ? $terminatedScope : $endScope->mergeWith($terminatedScope); + } + } + // Unsealed shapes describe zero-or-more additional entries beyond the // explicit keys. Run the scope-generalizing loop on top of the // unrolled explicit iterations so body-scope variables (e.g. counters) diff --git a/tests/PHPStan/Analyser/nsrt/bug-15390.php b/tests/PHPStan/Analyser/nsrt/bug-15390.php new file mode 100644 index 00000000000..2aa0b69fb59 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15390.php @@ -0,0 +1,61 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15390; + +use function PHPStan\Testing\assertType; + +enum Status +{ + case A; + case B; + case C; +} + +function f(Status $status): void +{ + foreach ([Status::A, Status::B, Status::C] as $s) { + if ($status === $s) { + return; + } + } + + assertType('*NEVER*', $status); +} + +function g(Status $status): int +{ + foreach ([1 => [Status::A, Status::B], 2 => [Status::C]] as $value => $statuses) { + if (in_array($status, $statuses, true)) { + return $value; + } + } + + assertType('*NEVER*', $status); + + return 0; +} + +function h(Status $status): void +{ + foreach ([Status::A, Status::B] as $s) { + if ($status === $s) { + return; + } + } + + assertType('Bug15390\Status::C', $status); +} + +/** @param 1|2|3 $i */ +function i(int $i): void +{ + foreach ([1, 2, 3] as $v) { + if ($i === $v) { + throw new \Exception(); + } + } + + assertType('*NEVER*', $i); +} diff --git a/turbo-ext/src/ForeachHandler.cpp b/turbo-ext/src/ForeachHandler.cpp index 93d6fd6990c..68847004228 100644 --- a/turbo-ext/src/ForeachHandler.cpp +++ b/turbo-ext/src/ForeachHandler.cpp @@ -1503,6 +1503,7 @@ class ForeachHandler zv::Arr allBodyScopes = zv::Arr::empty(); zv::Arr allChainScopes = zv::Arr::empty(); zv::Arr allBreakScopes = zv::Arr::empty(); + zv::Arr allTerminatedScopes = zv::Arr::empty(); zv::Val bodyContext = pt_statement_context_enter_unrolled_foreach(context, totalKeys); if (UNEXPECTED(bodyContext.isUndef())) return false; @@ -1635,6 +1636,12 @@ class ForeachHandler } // no later iteration runs, the loop is left only through its break statements + { + zv::Val hold; + zval *terminatedScope = pt_internal_statement_result_scope(bodyResult.raw(), hold); + if (UNEXPECTED(terminatedScope == NULL)) return false; + allTerminatedScopes.push(zv::Ref(terminatedScope)); + } chainScope = zv::Val::null(); break; } @@ -1692,6 +1699,14 @@ class ForeachHandler if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false; } + // the code after the loop is unreachable - keep what the terminated + // iterations narrowed instead of falling back to the generic loop pass + if (endScope.isNull()) { + for (auto entry : zv::ArrRef(allTerminatedScopes.raw())) { + if (UNEXPECTED(!ptlh::mergeOrTake(endScope, entry.value().deref().raw()))) return false; + } + } + // Unsealed shapes describe zero-or-more additional entries beyond the // explicit keys. Run the scope-generalizing loop on top of the // unrolled explicit iterations so body-scope variables (e.g. counters)