From 4f5293e91a8d68974256ea7aa700bcd5d6f969cd Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:25:36 +0000 Subject: [PATCH] Fix false positive return.missing for loops with match default throw - Exclude always-terminating (NeverType) match arm scopes from post-match scope merge in MatchHandler, so variables are correctly narrowed after a match with `default => throw` - Re-evaluate for loop condition against the real processing's loop scope to detect when body narrowing makes the condition always true - New regression test in tests/PHPStan/Rules/Missing/data/bug-9444.php Closes https://github.com/phpstan/phpstan/issues/9444 --- src/Analyser/ExprHandler/MatchHandler.php | 13 ++++-- src/Analyser/NodeScopeResolver.php | 10 +++++ .../Rules/Missing/MissingReturnRuleTest.php | 7 ++++ tests/PHPStan/Rules/Missing/data/bug-9444.php | 41 +++++++++++++++++++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Missing/data/bug-9444.php diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 98e672e945..99aad1d33e 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -335,7 +335,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ExpressionContext::createTopLevel(), ); $armScope = $armResult->getScope(); - $armBodyScopes[] = $armScope; + if (!$matchArmBodyScope->getType($arm->body) instanceof NeverType) { + $armBodyScopes[] = $armScope; + } $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); @@ -367,12 +369,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasDefaultCond = true; $matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); + $armBodyType = $matchScope->getType($arm->body); $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); $matchScope = $armResult->getScope(); $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); - $armBodyScopes[] = $matchScope; + if (!$armBodyType instanceof NeverType) { + $armBodyScopes[] = $matchScope; + } continue; } @@ -423,7 +428,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ExpressionContext::createTopLevel(), ); $armScope = $armResult->getScope(); - $armBodyScopes[] = $armScope; + if (!$bodyScope->getType($arm->body) instanceof NeverType) { + $armBodyScopes[] = $armScope; + } $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 2adef28f93..416c046a48 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1691,6 +1691,16 @@ public function processStmtNode( foreach ($stmt->loop as $loopExpr) { $loopScope = $this->processExprNode($stmt, $loopExpr, $loopScope, $storage, $nodeCallback, ExpressionContext::createTopLevel())->getScope(); } + + if ( + !$alwaysIterates->yes() + && $context->isTopLevel() + && $lastCondExpr !== null + && ($this->treatPhpDocTypesAsCertain ? $loopScope->getType($lastCondExpr) : $loopScope->getNativeType($lastCondExpr))->toBoolean()->isTrue()->yes() + ) { + $alwaysIterates = TrinaryLogic::createYes(); + } + $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { diff --git a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php index b848a761d7..109bb23dd8 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -373,4 +373,11 @@ public function testBug12722(): void $this->analyse([__DIR__ . '/data/bug-12722.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug9444(): void + { + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/bug-9444.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/bug-9444.php b/tests/PHPStan/Rules/Missing/data/bug-9444.php new file mode 100644 index 0000000000..65fe4463a0 --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/bug-9444.php @@ -0,0 +1,41 @@ += 8.0 + +namespace Bug9444; + +class One +{ + private static int $i = 0; + + public static function run(): string + { + self::$i++; + if (self::$i <= 3) { + throw new \Exception('One:run'); + } + + return 'Ok'; + } +} + +class Main +{ + public function process(): string + { + for ($i = 0; $i <= 5; $i++) { + try { + return One::run(); + } catch (\Throwable $e) { + $sleep = match($i) { + 0 => 0.5, + 1 => 1, + 2 => 3, + 3 => 6, + 4 => 9, + default => throw $e, + }; + + echo $sleep . PHP_EOL; + } + } + } +}