From b6073f07bf43ede9999e2063d5b09496dfb76468 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:11:46 +0000 Subject: [PATCH 1/3] Fix throw points not properly matched to catch clauses - When a method has @throws with a supertype of the caught exception (e.g. @throws RuntimeException with catch PDOException), implicit throw points from other method calls were incorrectly excluded from the catch scope - Phase 3 (implicit throw point matching) was skipped when explicit @throws matched even as "maybe", now it only skips when there's a definitive "yes" match - Added regression test in tests/PHPStan/Rules/Variables/data/bug-9349.php Closes https://github.com/phpstan/phpstan/issues/9349 --- src/Analyser/NodeScopeResolver.php | 6 +- .../Variables/DefinedVariableRuleTest.php | 15 +++++ .../PHPStan/Rules/Variables/data/bug-9349.php | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Variables/data/bug-9349.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 79b05cd0f8..ce853bd980 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1878,6 +1878,7 @@ public function processStmtNode( // explicit only $onlyExplicitIsThrow = true; + $hasDirectExplicitNonThrowMatch = false; if (count($matchingThrowPoints) === 0) { foreach ($throwPoints as $throwPointIndex => $throwPoint) { foreach ($catchTypes as $catchTypeIndex => $catchTypeItem) { @@ -1895,6 +1896,9 @@ public function processStmtNode( && !($throwNode instanceof Node\Stmt\Expression && $throwNode->expr instanceof Expr\Throw_) ) { $onlyExplicitIsThrow = false; + if ($catchTypeItem->isSuperTypeOf($throwPoint->getType())->yes()) { + $hasDirectExplicitNonThrowMatch = true; + } } $matchingThrowPoints[$throwPointIndex] = $throwPoint; } @@ -1902,7 +1906,7 @@ public function processStmtNode( } // implicit only - if (count($matchingThrowPoints) === 0 || $onlyExplicitIsThrow) { + if (count($matchingThrowPoints) === 0 || $onlyExplicitIsThrow || !$hasDirectExplicitNonThrowMatch) { foreach ($throwPoints as $throwPointIndex => $throwPoint) { if ($throwPoint->isExplicit()) { continue; diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index add6c66beb..066817e7c5 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1403,6 +1403,21 @@ public function testBug14019(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14019.php'], []); } + public function testBug9349(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + + $this->analyse([__DIR__ . '/data/bug-9349.php'], [ + [ + 'Variable $sql might not be defined.', + 19, + ], + ]); + } + #[RequiresPhp('>= 8.0')] public function testBug14274(): void { diff --git a/tests/PHPStan/Rules/Variables/data/bug-9349.php b/tests/PHPStan/Rules/Variables/data/bug-9349.php new file mode 100644 index 0000000000..42c2fae5fa --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-9349.php @@ -0,0 +1,58 @@ +maybeThrows(); + $sql = "SELECT * FROM foo"; + $rs = $pdo->query($sql); + if ($result = $rs->fetch(\PDO::FETCH_ASSOC)) { + // do something + } + } catch (\PDOException $e) { + var_dump($sql); + } + } + + /** + * @throws \RuntimeException + */ + public function maybeThrows(): void + { + if (random_int(0, 1) === 1) { + throw new \RuntimeException(); + } + } + + public function test2(): void + { + global $pdo; + + try { + $this->maybeThrows2(); + $sql = "SELECT * FROM foo"; + $rs = $pdo->query($sql); + if ($result = $rs->fetch(\PDO::FETCH_ASSOC)) { + // do something + } + } catch (\PDOException $e) { + var_dump($sql); + } + } + + /** + * @throws \LogicException + */ + public function maybeThrows2(): void + { + if (random_int(0, 1) === 1) { + throw new \LogicException(); + } + } +} From 6539a4a01c88c1c25c0c6807740b5a171f92f610 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 17 Mar 2026 16:33:21 +0000 Subject: [PATCH 2/3] Separate second test case into its own class in bug-9349 test Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Variables/data/bug-9349.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Rules/Variables/data/bug-9349.php b/tests/PHPStan/Rules/Variables/data/bug-9349.php index 42c2fae5fa..777d938ac1 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-9349.php +++ b/tests/PHPStan/Rules/Variables/data/bug-9349.php @@ -30,6 +30,10 @@ public function maybeThrows(): void } } +} + +class HelloWorld2 +{ public function test2(): void { global $pdo; From c459510e2644bc76f5210d7661215ae0075d0337 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 17 Mar 2026 16:38:53 +0000 Subject: [PATCH 3/3] Add test case with union @throws annotation in bug-9349 test Co-Authored-By: Claude Opus 4.6 --- .../Variables/DefinedVariableRuleTest.php | 4 +++ .../PHPStan/Rules/Variables/data/bug-9349.php | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 066817e7c5..0bf1408671 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1415,6 +1415,10 @@ public function testBug9349(): void 'Variable $sql might not be defined.', 19, ], + [ + 'Variable $sql might not be defined.', + 78, + ], ]); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-9349.php b/tests/PHPStan/Rules/Variables/data/bug-9349.php index 777d938ac1..44ed020211 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-9349.php +++ b/tests/PHPStan/Rules/Variables/data/bug-9349.php @@ -60,3 +60,35 @@ public function maybeThrows2(): void } } } + +class HelloWorld3 +{ + public function test3(): void + { + global $pdo; + + try { + $this->maybeThrows3(); + $sql = "SELECT * FROM foo"; + $rs = $pdo->query($sql); + if ($result = $rs->fetch(\PDO::FETCH_ASSOC)) { + // do something + } + } catch (\PDOException $e) { + var_dump($sql); + } + } + + /** + * @throws \LogicException|\RuntimeException + */ + public function maybeThrows3(): void + { + if (random_int(0, 1) === 1) { + throw new \RuntimeException(); + } + if (random_int(0, 1) === 1) { + throw new \LogicException(); + } + } +}