From 82c173c7e6b86fe4e3fea3402820f3c21d2fa117 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 11 Sep 2026 16:13:22 +0200 Subject: [PATCH 1/2] Build a loop's back edge from its continue statements when the body end is unreachable filterOutLoopExitPoints() marks a loop body containing break or continue as not always terminating, and the loop handlers then treated the end of the body as reachable. When the body ends in return or throw, that dead scope was merged into the next iteration and into the scope after the loop, so a variable assigned on the only path that loops back came out as possibly undefined. InternalStatementResult now keeps whether the end of the statements is reachable, and getLoopBackEdgeScope() returns the scope the next iteration starts from: the reachable end merged with the continue statements, or null when no iteration follows. While, do-while, for and foreach loops, including unrolled constant-array iterations, build their convergence passes and post-loop scopes from it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC --- src/Analyser/InternalStatementResult.php | 32 ++- src/Analyser/StmtHandler/DoWhileHandler.php | 32 +-- src/Analyser/StmtHandler/ForHandler.php | 20 +- src/Analyser/StmtHandler/ForeachHandler.php | 62 +++-- src/Analyser/StmtHandler/WhileHandler.php | 29 +- tests/PHPStan/Analyser/nsrt/bug-14418.php | 254 ++++++++++++++++++ .../PHPStan/Analyser/nsrt/for-loop-i-type.php | 2 +- .../Variables/DefinedVariableRuleTest.php | 9 + .../Rules/Variables/data/bug-14418.php | 18 ++ 9 files changed, 393 insertions(+), 65 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14418.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-14418.php diff --git a/src/Analyser/InternalStatementResult.php b/src/Analyser/InternalStatementResult.php index 13583801155..d4d03c24057 100644 --- a/src/Analyser/InternalStatementResult.php +++ b/src/Analyser/InternalStatementResult.php @@ -9,6 +9,8 @@ final class InternalStatementResult { + private bool $endReachable; + /** * @param InternalStatementExitPoint[] $exitPoints * @param InternalThrowPoint[] $throwPoints @@ -24,8 +26,10 @@ public function __construct( private array $impurePoints, private array $endStatements = [], private ?VariableFlow $variableFlow = null, + ?bool $endReachable = null, ) { + $this->endReachable = $endReachable ?? !$isAlwaysTerminating; foreach ($exitPoints as $exitPoint) { $this->scope = $this->scope->addTemplateArgumentConstraints($exitPoint->getScope()->getTemplateArgumentConstraints()); } @@ -39,6 +43,30 @@ public function getVariableFlow(): ?VariableFlow return $this->variableFlow; } + /** + * Whether execution can reach the end of the statements. Unlike isAlwaysTerminating(), + * filterOutLoopExitPoints() does not reset it: a loop body left only through + * break or continue still cannot reach its end. + */ + public function isEndReachable(): bool + { + return $this->endReachable; + } + + /** + * The scope the next iteration of a loop starts from: the end of the body when + * it is reachable, merged with the body's continue statements. Null when the body + * never reaches the next iteration. + */ + public function getLoopBackEdgeScope(): ?MutatingScope + { + $backEdge = $this->endReachable ? $this->scope : null; + foreach ($this->getExitPointsByType(Stmt\Continue_::class) as $continueExitPoint) { + $backEdge = $backEdge === null ? $continueExitPoint->getScope() : $backEdge->mergeWith($continueExitPoint->getScope()); + } + return $backEdge; + } + public function toPublic(): StatementResult { return new StatementResult( @@ -81,14 +109,14 @@ public function filterOutLoopExitPoints(): self $num = $statement->num; if (!$num instanceof Int_) { - return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints, variableFlow: $this->variableFlow); + return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints, variableFlow: $this->variableFlow, endReachable: false); } if ($num->value !== 1) { continue; } - return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints, variableFlow: $this->variableFlow); + return new self($this->scope, $this->hasYield, false, $this->exitPoints, $this->throwPoints, $this->impurePoints, variableFlow: $this->variableFlow, endReachable: false); } return $this; diff --git a/src/Analyser/StmtHandler/DoWhileHandler.php b/src/Analyser/StmtHandler/DoWhileHandler.php index b70ec50cc6d..4124c17bcbb 100644 --- a/src/Analyser/StmtHandler/DoWhileHandler.php +++ b/src/Analyser/StmtHandler/DoWhileHandler.php @@ -4,7 +4,6 @@ use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Break_; -use PhpParser\Node\Stmt\Continue_; use PhpParser\Node\Stmt\Do_; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; @@ -72,12 +71,8 @@ public function processStmt( $scope->pushExpressionResultStorage($storage); try { $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->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); + $backEdgeScope = $bodyScopeResult->getLoopBackEdgeScope(); + $finalScope = $backEdgeScope === null ? $finalScope : $backEdgeScope->mergeWith($finalScope); foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); } @@ -88,10 +83,16 @@ public function processStmt( $replayPassStorage = $storage; $replayPassResult = $bodyScopeResult; } - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getTruthyScope(); + if ($backEdgeScope !== null) { + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $backEdgeScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getTruthyScope(); + } } finally { $scope->popExpressionResultStorage(); } + if ($backEdgeScope === null) { + $bodyScope = $prevScope; + break; + } if ($bodyScope->equals($prevScope)) { break; } @@ -120,10 +121,9 @@ public function processStmt( } else { $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); } - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + $backEdgeScope = $bodyScopeResult->getLoopBackEdgeScope(); + $backEdgeDead = $backEdgeScope === null; + $bodyScope = $backEdgeScope ?? $bodyScopeResult->getScope(); // the condition is processed once on the post-body scope; its result // answers both the always-iterates check below and the falsey post-loop @@ -138,16 +138,16 @@ public function processStmt( $alwaysIterates = $condBooleanType->isTrue()->yes(); } - if ($alwaysIterates) { + if ($alwaysIterates || $backEdgeDead) { $alwaysTerminating = count($bodyScopeResult->getExitPointsByType(Break_::class)) === 0; } else { $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + $finalScope = $alwaysTerminating || $backEdgeDead ? $finalScope : $bodyScope->mergeWith($finalScope); if ($finalScope === null) { $finalScope = $scope; } - if (!$alwaysTerminating) { + if (!$alwaysTerminating && !$backEdgeDead) { $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -162,7 +162,7 @@ public function processStmt( $breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class); if (count($breakExitPoints) > 0) { - $breakScope = $alwaysIterates ? null : $finalScope; + $breakScope = $alwaysIterates || $backEdgeDead ? null : $finalScope; foreach ($breakExitPoints as $breakExitPoint) { $breakScope = $breakScope === null ? $breakExitPoint->getScope() : $breakScope->mergeWith($breakExitPoint->getScope()); } diff --git a/src/Analyser/StmtHandler/ForHandler.php b/src/Analyser/StmtHandler/ForHandler.php index 7c55a9c0a96..45bdb70b06b 100644 --- a/src/Analyser/StmtHandler/ForHandler.php +++ b/src/Analyser/StmtHandler/ForHandler.php @@ -12,7 +12,6 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Break_; -use PhpParser\Node\Stmt\Continue_; use PhpParser\Node\Stmt\For_; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; @@ -200,10 +199,12 @@ public function processStmt( $bodyScope = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep(resolveTemplateArguments: false))->getTruthyScope(); } $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $backEdgeScope = $bodyScopeResult->getLoopBackEdgeScope(); + if ($backEdgeScope === null) { + $bodyScope = $prevScope; + break; } + $bodyScope = $backEdgeScope; foreach ($stmt->loop as $loopExpr) { $exprResult = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel(resolveTemplateArguments: false)); @@ -244,10 +245,9 @@ public function processStmt( } $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope(); - foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $finalScope = $continueExitPoint->getScope()->mergeWith($finalScope); - } + $backEdgeScope = $finalScopeResult->getLoopBackEdgeScope(); + $backEdgeDead = $backEdgeScope === null; + $finalScope = $backEdgeScope ?? $finalScopeResult->getScope(); $loopScope = $finalScope; foreach ($stmt->loop as $loopExpr) { @@ -271,14 +271,14 @@ public function processStmt( $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); if (count($breakExitPoints) > 0) { - $breakScope = $alwaysIterates->yes() ? null : $finalScope; + $breakScope = $alwaysIterates->yes() || $backEdgeDead ? null : $finalScope; foreach ($breakExitPoints as $breakExitPoint) { $breakScope = $breakScope === null ? $breakExitPoint->getScope() : $breakScope->mergeWith($breakExitPoint->getScope()); } $finalScope = $breakScope; } - if ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) { + if ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating() || ($backEdgeDead && count($breakExitPoints) === 0)) { if ($nodeScopeResolver->shouldPolluteScopeWithLoopInitialAssignments()) { $finalScope = $initScope; } else { diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index 44b7efd0597..6271e4cd4e6 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -232,10 +232,12 @@ static function () use ($condResult, $emptyArrayType): Type { try { $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $backEdgeScope = $bodyScopeResult->getLoopBackEdgeScope(); + if ($backEdgeScope === null) { + $bodyScope = $prevScope; + break; } + $bodyScope = $backEdgeScope; } finally { $scope->popExpressionResultStorage(); } @@ -278,7 +280,7 @@ static function () use ($condResult, $emptyArrayType): Type { $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); } - $finalScope = $finalScopeResult->getScope(); + $finalScope = $finalScopeResult->isEndReachable() ? $finalScopeResult->getScope() : null; $scopesWithIterableValueType = []; $keyVarExpr = null; @@ -299,7 +301,7 @@ static function () use ($condResult, $emptyArrayType): Type { $trackingExpr = $originalKeyVarExpr ?? $originalValueExpr; $continueExitPointHasUnoriginalKeyType = false; - if ($trackingExpr !== null) { + if ($trackingExpr !== null && $finalScope !== null) { if ($finalScope->hasExpressionType($trackingExpr)->yes()) { $scopesWithIterableValueType[] = $finalScope; } else { @@ -309,7 +311,7 @@ static function () use ($condResult, $emptyArrayType): Type { foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { $continueScope = $continueExitPoint->getScope(); - $finalScope = $continueScope->mergeWith($finalScope); + $finalScope = $finalScope === null ? $continueScope : $continueScope->mergeWith($finalScope); if ($trackingExpr === null || !$continueScope->hasExpressionType($trackingExpr)->yes()) { $continueExitPointHasUnoriginalKeyType = true; continue; @@ -318,8 +320,9 @@ static function () use ($condResult, $emptyArrayType): Type { } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); foreach ($breakExitPoints as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + $finalScope = $finalScope === null ? $breakExitPoint->getScope() : $breakExitPoint->getScope()->mergeWith($finalScope); } + $finalScope ??= $finalScopeResult->getScope(); if ($unrolledEndScope !== null) { $finalScope = $unrolledEndScope; @@ -628,7 +631,7 @@ private function enterForeach(NodeScopeResolver $nodeScopeResolver, MutatingScop } /** - * @return array{bodyScope: MutatingScope, endScope: MutatingScope, totalKeys: int}|null + * @return array{bodyScope: MutatingScope, endScope: MutatingScope|null, totalKeys: int}|null */ private function tryProcessUnrolledConstantArrayForeach( NodeScopeResolver $nodeScopeResolver, @@ -755,14 +758,22 @@ private function tryProcessUnrolledConstantArrayForeach( $bodyContext, )->filterOutLoopExitPoints(); - $iterEndScope = $bodyResult->getScope(); - foreach ($bodyResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $iterEndScope = $iterEndScope->mergeWith($continueExitPoint->getScope()); - } + $iterEndScope = $bodyResult->getLoopBackEdgeScope(); foreach ($bodyResult->getExitPointsByType(Break_::class) as $breakExitPoint) { $allBreakScopes[] = $breakExitPoint->getScope(); } + if ($iterEndScope === null) { + if ($isOptional) { + // the key may be missing, the next iteration then starts from the previous one + continue; + } + + // no later iteration runs, the loop is left only through its break statements + $chainScope = null; + break; + } + if ($isOptional) { $chainScope = $iterEndScope->mergeWith($chainScope); } else { @@ -774,11 +785,15 @@ private function tryProcessUnrolledConstantArrayForeach( for ($i = 1, $c = count($entryScopes); $i < $c; $i++) { $arrayBodyScope = $arrayBodyScope->mergeWith($entryScopes[$i]); } - if (count($entryScopes) === 1) { + if (count($entryScopes) === 1 && $chainScope !== null) { $arrayBodyScope = $arrayBodyScope->mergeWith($chainScope); } $allBodyScopes[] = $arrayBodyScope; + if ($chainScope === null) { + continue; + } + $allChainScopes[] = $chainScope; } @@ -791,13 +806,14 @@ private function tryProcessUnrolledConstantArrayForeach( $bodyScope = $bodyScope->mergeWith($allBodyScopes[$i]); } - $endScope = $allChainScopes[0]; - for ($i = 1, $c = count($allChainScopes); $i < $c; $i++) { - $endScope = $endScope->mergeWith($allChainScopes[$i]); + $chainEndScope = null; + foreach ($allChainScopes as $chainScope) { + $chainEndScope = $chainEndScope === null ? $chainScope : $chainEndScope->mergeWith($chainScope); } + $endScope = $chainEndScope; foreach ($allBreakScopes as $breakScope) { - $endScope = $endScope->mergeWith($breakScope); + $endScope = $endScope === null ? $breakScope : $endScope->mergeWith($breakScope); } // Unsealed shapes describe zero-or-more additional entries beyond the @@ -805,7 +821,7 @@ private function tryProcessUnrolledConstantArrayForeach( // unrolled explicit iterations so body-scope variables (e.g. counters) // account for the extra iterations while keeping the lower bound // established by the non-optional explicit keys. - if ($hasUnsealed) { + if ($hasUnsealed && $chainEndScope !== null && $endScope !== null) { $loopScope = $endScope; $count = 0; do { @@ -814,13 +830,15 @@ private function tryProcessUnrolledConstantArrayForeach( $iterBodyScope = $loopScope->mergeWith($endScope); $iterBodyScope = $this->enterForeach($nodeScopeResolver, $iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); $iterBodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); - $loopScope = $iterBodyScopeResult->getScope(); - foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); - } + $backEdgeScope = $iterBodyScopeResult->getLoopBackEdgeScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { $endScope = $endScope->mergeWith($breakExitPoint->getScope()); } + if ($backEdgeScope === null) { + $loopScope = $prevLoopScope; + break; + } + $loopScope = $backEdgeScope; $bodyScope = $bodyScope->mergeWith($loopScope); if ($loopScope->equals($prevLoopScope)) { break; diff --git a/src/Analyser/StmtHandler/WhileHandler.php b/src/Analyser/StmtHandler/WhileHandler.php index 0dd656935e1..469b2c833f8 100644 --- a/src/Analyser/StmtHandler/WhileHandler.php +++ b/src/Analyser/StmtHandler/WhileHandler.php @@ -4,7 +4,6 @@ use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Break_; -use PhpParser\Node\Stmt\Continue_; use PhpParser\Node\Stmt\While_; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; @@ -100,13 +99,15 @@ public function processStmt( $passCondResult = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep(resolveTemplateArguments: false)); $bodyScope = $passCondResult->getTruthyScope(); $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep()->withoutTemplateArgumentResolution())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + $backEdgeScope = $bodyScopeResult->getLoopBackEdgeScope(); } finally { $scope->popExpressionResultStorage(); } + if ($backEdgeScope === null) { + $bodyScope = $prevScope; + break; + } + $bodyScope = $backEdgeScope; // the candidate to replace the final walk when this pass's // entry turns out to be the fixpoint if ($condRecording instanceof RecordingNodeCallback && $bodyRecording instanceof RecordingNodeCallback) { @@ -155,7 +156,9 @@ public function processStmt( $bodyScope = $bodyCondResult->getTruthyScope(); $finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); } - $finalScope = $finalScopeResult->getScope(); + $backEdgeScope = $finalScopeResult->getLoopBackEdgeScope(); + $backEdgeDead = $backEdgeScope === null; + $finalScope = $backEdgeScope ?? $finalScopeResult->getScope(); // the loop condition narrows the post-loop scope to its falsey branch; // $finalScope (after the body ran) is a different scope than the condition's // own, so reprocess the condition there rather than re-running its result. @@ -170,15 +173,9 @@ public function processStmt( $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } - if (!$alwaysIterates) { - foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $finalScope = $finalScope->mergeWith($continueExitPoint->getScope()); - } - } - $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); if (count($breakExitPoints) > 0) { - $breakScope = $alwaysIterates ? null : $finalScope; + $breakScope = $alwaysIterates || $backEdgeDead ? null : $finalScope; foreach ($breakExitPoints as $breakExitPoint) { $breakScope = $breakScope === null ? $breakExitPoint->getScope() : $breakScope->mergeWith($breakExitPoint->getScope()); } @@ -195,12 +192,16 @@ public function processStmt( } else { $isAlwaysTerminating = false; } + if ($backEdgeDead && count($breakExitPoints) === 0) { + $finalScope = null; + } if (!$isIterableAtLeastOnce) { if (!$nodeScopeResolver->shouldPolluteScopeWithLoopInitialAssignments()) { $condScope = $condScope->mergeWith($scope); } - $finalScope = $finalScope->mergeWith($condScope); + $finalScope = $finalScope === null ? $condScope : $finalScope->mergeWith($condScope); } + $finalScope ??= $finalScopeResult->getScope(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14418.php b/tests/PHPStan/Analyser/nsrt/bug-14418.php new file mode 100644 index 00000000000..f6455ae0110 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14418.php @@ -0,0 +1,254 @@ +attempt(); + } catch (\Exception $e) { + continue; + } + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + throw $e; +} + +function forContinue(): int +{ + for ($try = 0; $try <= 3; $try++) { + if (rand(0, 1)) { + $e = 1; + continue; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function whileContinue(): int +{ + $i = 0; + while ($i < 3) { + $i++; + if (rand(0, 1)) { + $e = 1; + continue; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function doWhileContinue(): int +{ + do { + if (rand(0, 1)) { + $e = 1; + continue; + } + return 2; + } while (rand(0, 1)); + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +/** + * @param list $xs + */ +function foreachContinueMayNotIterate(array $xs): int +{ + foreach ($xs as $x) { + if (rand(0, 1)) { + $e = $x; + continue; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $e); + return 1; +} + +function unrolledForeachContinue(): int +{ + foreach ([1, 2] as $x) { + if (rand(0, 1)) { + $e = $x; + continue; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function unrolledForeachContinueOuterLoop(): int +{ + foreach ([1, 2, 3] as $a) { + while (rand(0, 1)) { + if (rand(0, 1)) { + $e = $a; + continue 2; + } + return 1; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function forBreak(): int +{ + for ($i = 0; $i < 3; $i++) { + if (rand(0, 1)) { + $e = 1; + break; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function whileBreakMayNotIterate(int $n): int +{ + while ($n < 3) { + if (rand(0, 1)) { + $e = 1; + break; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $e); + return 1; +} + +function whileTrueBreak(): int +{ + while (true) { + if (rand(0, 1)) { + $e = 1; + break; + } + return 2; + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function doWhileBreak(): int +{ + do { + if (rand(0, 1)) { + $e = 1; + break; + } + return 2; + } while (rand(0, 1)); + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function continueOuterLoop(): int +{ + for ($i = 0; $i < 3; $i++) { + while (true) { + if (rand(0, 1)) { + $e = 1; + continue 2; + } + return 2; + } + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function continueFromSwitch(): int +{ + for ($i = 0; $i < 3; $i++) { + switch (rand(0, 1)) { + case 0: + $e = 1; + continue 2; + default: + return 2; + } + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + return $e; +} + +function catchContinueWithFinally(): int +{ + for ($i = 0; $i < 3; $i++) { + try { + if (rand(0, 1)) { + return 1; + } + throw new \Exception(); + } catch (\Exception $e) { + continue; + } finally { + echo 'x'; + } + } + assertVariableCertainty(TrinaryLogic::createYes(), $e); + throw $e; +} + +function endReachable(): void +{ + for ($i = 0; $i < 3; $i++) { + if (rand(0, 1)) { + $e = 1; + continue; + } + } + assertVariableCertainty(TrinaryLogic::createMaybe(), $e); +} + +function continueTypeAfterLoop(): void +{ + $v = 'str'; + for ($try = 0; $try <= 3; $try++) { + if (rand(0, 1)) { + $v = 5; + continue; + } + return; + } + assertType('5', $v); +} + +function loopHeadFromContinue(): void +{ + $v = 'init'; + for ($i = 0; $i < 3; $i++) { + assertType("1|'init'", $v); + if (rand(0, 1)) { + $v = 1; + continue; + } + return; + } + assertType('1', $v); +} diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-i-type.php b/tests/PHPStan/Analyser/nsrt/for-loop-i-type.php index 1317b3695cc..b642339dfaf 100644 --- a/tests/PHPStan/Analyser/nsrt/for-loop-i-type.php +++ b/tests/PHPStan/Analyser/nsrt/for-loop-i-type.php @@ -59,7 +59,7 @@ public function doLOrem() { break; } - assertType('int<1, max>', $i); + assertType('1', $i); } } diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 3f79e8c5dd1..015037275e7 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1759,4 +1759,13 @@ public function testBug8360(): void $this->analyse([__DIR__ . '/data/bug-8360.php'], []); } + public function testBug14418(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-14418.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-14418.php b/tests/PHPStan/Rules/Variables/data/bug-14418.php new file mode 100644 index 00000000000..c89556eb9a0 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-14418.php @@ -0,0 +1,18 @@ + Date: Fri, 11 Sep 2026 16:24:54 +0200 Subject: [PATCH 2/2] Add regression tests for #1946, #11919 and #13959 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC --- tests/PHPStan/Analyser/nsrt/bug-13959.php | 34 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-1946.php | 23 +++++++++++++ .../CallToFunctionParametersRuleTest.php | 16 +++++++++ .../Rules/Functions/data/bug-11919.php | 24 +++++++++++++ .../PHPStan/Rules/Functions/data/bug-1946.php | 19 +++++++++++ 5 files changed, 116 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13959.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-1946.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11919.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-1946.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13959.php b/tests/PHPStan/Analyser/nsrt/bug-13959.php new file mode 100644 index 00000000000..7ca03249473 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13959.php @@ -0,0 +1,34 @@ + $value + */ + public function sayHello(array $value): void + { + assertType('list', $value); + + foreach ($value as $item) { + if ($item instanceof GlobalTagId) { + continue; + } + + if (is_string($item)) { + continue; + } + + throw new InvalidArgumentException('Invalid type'); + } + + assertType('list', $value); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-1946.php b/tests/PHPStan/Analyser/nsrt/bug-1946.php new file mode 100644 index 00000000000..b3a0d52aa62 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-1946.php @@ -0,0 +1,23 @@ +analyse([__DIR__ . '/data/bug-11041.php'], []); } + public function testBug1946(): void + { + $this->analyse([__DIR__ . '/data/bug-1946.php'], [ + [ + 'Parameter #1 $string of function strlen expects string, null given.', + 19, + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testBug11919(): void + { + $this->analyse([__DIR__ . '/data/bug-11919.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11919.php b/tests/PHPStan/Rules/Functions/data/bug-11919.php new file mode 100644 index 00000000000..f68e87a36c9 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11919.php @@ -0,0 +1,24 @@ +