From 590d44c102490ff13674da95932d9e8d07aff937 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 08:48:44 +0000 Subject: [PATCH] Use `isFromThrowExpr()` instead of the throw point's node shape to decide whether implicit throw points still apply * `TryCatchHandler` decided whether the only matching explicit throw points are `throw`s written in the analysed code by inspecting the throw point's AST node (`Expr\Throw_` / `Stmt\Expression` wrapping one). Throw points relayed through a callable value lose that node - the node becomes the argument expression or the call expression - so a literal `throw` inside a closure or arrow function was mistaken for a documented `@throws`, and all implicit (inferred) throw points of the `try` block were dropped from the catch scope. * The purpose-built `InternalThrowPoint::isFromThrowExpr()` flag is preserved through all of those relays (`ArgumentsHandler`, `FuncCallHandler`, `ClosureTypeResolver`), so the node sniffing is replaced by asking the flag. * `StatementsHandler::getOverridingThrowPoints()` now marks the throw point it creates for a statement-level `/** @throws */` as coming from a throw expression when the annotated statement is a `throw` statement, keeping the previous behaviour of that corner intact. * Probed and found already correct: broad `catch (\Exception)` / `catch (\Error)` / `catch (\Throwable)` with a documented thrower (the reported scenario, fixed by 7d2352b25), union catch types, second catch clauses, documented constructors, `finally` scopes, nested try/catch rethrows, and closure arguments of parameters that are not immediately invoked. --- src/Analyser/StatementsHandler.php | 4 +- src/Analyser/StmtHandler/TryCatchHandler.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-14990.php | 57 ++++++++++++ .../PHPStan/Analyser/nsrt/explicit-throws.php | 92 +++++++++++++++++++ ...rictComparisonOfDifferentTypesRuleTest.php | 5 + 5 files changed, 158 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14990.php diff --git a/src/Analyser/StatementsHandler.php b/src/Analyser/StatementsHandler.php index c306b69bc72..95ce2d528ae 100644 --- a/src/Analyser/StatementsHandler.php +++ b/src/Analyser/StatementsHandler.php @@ -794,7 +794,9 @@ public function getOverridingThrowPoints(Node\Stmt $statement, MutatingScope $sc return []; } - return [InternalThrowPoint::createExplicit($scope, $throwsType, $statement, false)]; + $fromThrowExpr = $statement instanceof Node\Stmt\Expression && $statement->expr instanceof Expr\Throw_; + + return [InternalThrowPoint::createExplicit($scope, $throwsType, $statement, false, $fromThrowExpr)]; } } diff --git a/src/Analyser/StmtHandler/TryCatchHandler.php b/src/Analyser/StmtHandler/TryCatchHandler.php index aedccaccac8..38215176829 100644 --- a/src/Analyser/StmtHandler/TryCatchHandler.php +++ b/src/Analyser/StmtHandler/TryCatchHandler.php @@ -142,11 +142,7 @@ public function processStmt( if (!$throwPoint->isExplicit()) { continue; } - $throwNode = $throwPoint->getNode(); - if ( - !$throwNode instanceof Expr\Throw_ - && !($throwNode instanceof Node\Stmt\Expression && $throwNode->expr instanceof Expr\Throw_) - ) { + if (!$throwPoint->isFromThrowExpr()) { $onlyExplicitIsThrow = false; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14990.php b/tests/PHPStan/Analyser/nsrt/bug-14990.php new file mode 100644 index 00000000000..ae541f0c8fe --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14990.php @@ -0,0 +1,57 @@ + throw new \InvalidArgumentException(), [1, 2]); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doThrowInClosureArg(): void + { + try { + doFoo(); + $a = 1; + array_map(static function (int $i): int { + throw new \InvalidArgumentException(); + }, [1, 2]); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doThrowInCalledClosure(): void + { + $callback = static function (): void { + throw new \InvalidArgumentException(); + }; + try { + doFoo(); + $a = 1; + $callback(); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doThrowInImmediatelyInvokedClosure(): void + { + try { + doFoo(); + $a = 1; + (static function (): void { + throw new \InvalidArgumentException(); + })(); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doThrowInClosureVariablePassedAsCallable(): void + { + $callback = static function (int $i): int { + throw new \InvalidArgumentException(); + }; + try { + doFoo(); + $a = 1; + array_map($callback, [1, 2]); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doAnnotatedThrowStatement(): void + { + try { + doFoo(); + $a = 1; + /** @throws \InvalidArgumentException */ + throw new \InvalidArgumentException(); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createMaybe(), $a); + } + } + + public function doDocumentedThrowInClosureArg(): void + { + try { + doFoo(); + $a = 1; + array_map(function (int $i): int { + $this->throwInvalidArgument(); + + return $i; + }, [1, 2]); + } catch (\InvalidArgumentException $e) { + assertVariableCertainty(TrinaryLogic::createYes(), $a); + } + } + /** * @throws \InvalidArgumentException */ diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index debc27ee4e3..e9be1f1aaed 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1264,6 +1264,11 @@ public function testBug14966(): void $this->analyse([__DIR__ . '/data/bug-14966.php'], []); } + public function testBug14990(): void + { + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14990.php'], []); + } + public function testBug14847(): void { $this->analyse([__DIR__ . '/data/bug-14847.php'], [