From db5d9cc59fa3fbb551ad67b52803461387bcef6f Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:45:04 +0000 Subject: [PATCH] Mark `for`/`while` loops as never exiting when every fall-through iteration keeps the condition satisfied - In NodeScopeResolver's `For_` handling, after computing the body's fall-through scope and applying the loop expressions, check whether the loop condition is still always true. If so, the loop can only ever leave via `break`/`return`/`throw`, so it is treated as always-iterating and the code after it becomes unreachable (no false `return.missing`). - Apply the same reasoning to `While_`, using the body-end scope to re-evaluate the condition. - Both checks are guarded to the top-level loop pass, require the loop to iterate at least once, and only fire when the body is not already always-terminating, so genuine missing-return cases (e.g. a catch that merely logs and continues) keep being reported. - `do-while` already handled this correctly and needed no change; `foreach` has no value-narrowable condition and is unaffected. --- src/Analyser/NodeScopeResolver.php | 26 +++++++ .../Rules/Missing/MissingReturnRuleTest.php | 7 ++ tests/PHPStan/Rules/Missing/data/bug-9444.php | 72 +++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 tests/PHPStan/Rules/Missing/data/bug-9444.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03f..dd9e0e72812 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1726,6 +1726,18 @@ public function processStmtNode( $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); + + // When every non-terminating path through the body keeps the loop condition + // satisfied (e.g. the boundary iteration always returns or throws), the loop can + // never exit normally, so the code after it is unreachable. + if ( + !$alwaysIterates + && $beforeCondBooleanType->isTrue()->yes() + && !$finalScopeResult->isAlwaysTerminating() + && $finalScopeResult->getScope()->getType($stmt->cond)->toBoolean()->isTrue()->yes() + ) { + $alwaysIterates = true; + } } if (!$alwaysIterates) { foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -1957,6 +1969,20 @@ public function processStmtNode( foreach ($stmt->loop as $loopExpr) { $loopScope = $this->processExprNode($stmt, $loopExpr, $loopScope, $storage, $nodeCallback, ExpressionContext::createTopLevel())->getScope(); } + + // When every non-terminating path through the body keeps the loop condition + // satisfied (e.g. the boundary iteration always returns or throws), the loop can + // never exit normally, so the code after it is unreachable. + if ( + $context->isTopLevel() + && $lastCondExpr !== null + && $isIterableAtLeastOnce->yes() + && !$finalScopeResult->isAlwaysTerminating() + && $loopScope->getType($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 ee565a39fa6..ee7b5b0bc34 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -380,4 +380,11 @@ public function testBug14638(): void $this->analyse([__DIR__ . '/data/bug-14638.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 00000000000..dd8f34e1e57 --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/bug-9444.php @@ -0,0 +1,72 @@ += 8.0 + +declare(strict_types = 1); + +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; + } + } + } + + public function processWithIf(): string + { + for ($i = 0; $i <= 5; $i++) { + try { + return One::run(); + } catch (\Throwable $e) { + if ($i >= 5) { + throw $e; + } + echo $i . PHP_EOL; + } + } + } + + public function processWhile(): string + { + $i = 0; + while ($i <= 5) { + try { + return One::run(); + } catch (\Throwable $e) { + if ($i >= 5) { + throw $e; + } + $i++; + } + } + } +}