From 2991d477cbce6f9d4572d936e102e5ee28925395 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 28 Feb 2026 14:23:39 +0000 Subject: [PATCH] Fix false positive dead catch when overriding non-abstract trait method with @throws When a class uses a trait with a non-abstract method that has a @throws tag, and the class overrides that method, the @throws tag was not inherited. This caused false positive "Dead catch" errors when implicitThrows was false, because PHPStan did not recognize that the original trait method could throw the exception. The existing trait PHPDoc inheritance in PhpDocInheritanceResolver only processed abstract trait methods (to avoid inheriting @return/@param from non-abstract trait methods). This fix adds a targeted second pass that inherits only @throws tags from non-abstract trait methods when the overriding method has no @throws of its own. Fixes https://github.com/phpstan/phpstan/issues/10315 --- src/PhpDoc/PhpDocInheritanceResolver.php | 40 +++++++++++- src/PhpDoc/ResolvedPhpDocBlock.php | 8 +++ ...atchWithUnthrownExceptionRuleStubsTest.php | 5 ++ .../Rules/Exceptions/data/bug-10315.php | 62 +++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-10315.php diff --git a/src/PhpDoc/PhpDocInheritanceResolver.php b/src/PhpDoc/PhpDocInheritanceResolver.php index 628b3e62df..26c68b89ba 100644 --- a/src/PhpDoc/PhpDocInheritanceResolver.php +++ b/src/PhpDoc/PhpDocInheritanceResolver.php @@ -142,7 +142,45 @@ public function resolvePhpDocForMethod( return $this->resolveMethodPhpDocFromParentClass($traitMethod, $resolvedPhpDocBlock, $declaringClass, $trait, $currentResolvedPhpDoc, $currentPositionalParameterNames); } - return $currentResolvedPhpDoc; + $result = $currentResolvedPhpDoc; + + // Inherit @throws from non-abstract trait methods + if ($result === null || $result->getThrowsTag() === null) { + foreach ($declaringClass->getTraits() as $trait) { + if (!$trait->hasNativeMethod($methodName)) { + continue; + } + + $traitMethod = $trait->getNativeMethod($methodName); + if ($traitMethod->getDocComment() === null) { + continue; + } + if ($declaringClass->getFileName() === null) { + continue; + } + + $traitResolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $declaringClass->getFileName(), + $declaringClass->getName(), + $trait->getName(), + $methodName, + $traitMethod->getDocComment(), + ); + + $throwsTag = $traitResolvedPhpDoc->getThrowsTag(); + if ($throwsTag === null) { + continue; + } + + if ($result === null) { + $result = ResolvedPhpDocBlock::createEmpty(); + } + $result = $result->withThrowsTag($throwsTag); + break; + } + } + + return $result; } /** diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index be6da4fc46..1b87457e7c 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -621,6 +621,14 @@ public function getThrowsTag(): ?ThrowsTag return $this->throwsTag; } + public function withThrowsTag(ThrowsTag $throwsTag): self + { + $result = clone $this; + $result->throwsTag = $throwsTag; + + return $result; + } + /** * @return array */ diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleStubsTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleStubsTest.php index 421fdb719d..83bb8ed534 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleStubsTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleStubsTest.php @@ -36,6 +36,11 @@ public function testRule(): void ]); } + public function testBug10315(): void + { + $this->analyse([__DIR__ . '/data/bug-10315.php'], []); + } + public static function getAdditionalConfigFiles(): array { return [ diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-10315.php b/tests/PHPStan/Rules/Exceptions/data/bug-10315.php new file mode 100644 index 0000000000..12252abd60 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-10315.php @@ -0,0 +1,62 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10315; + +class MyException extends \RuntimeException +{ +} + +trait DriverPoolAbstractTrait +{ + /** + * @throws MyException + */ + protected function driverReadMultiple(): array + { + throw new MyException(); + } +} + +trait CacheItemPoolTrait +{ + public function getItems(): array + { + try { + $result = $this->driverReadMultiple(); + } catch (MyException) { + $result = []; + } + + return $result; + } +} + +// Scenario A: base uses DriverPoolAbstractTrait, child uses CacheItemPoolTrait and overrides +abstract class AbstractPoolA +{ + use DriverPoolAbstractTrait; +} + +class RedisDriverA extends AbstractPoolA +{ + use CacheItemPoolTrait; + + protected function driverReadMultiple(): array + { + return ['key' => 'value']; + } +} + +// Scenario B: class uses both traits and overrides the method directly +class DirectDriverB +{ + use DriverPoolAbstractTrait; + use CacheItemPoolTrait; + + protected function driverReadMultiple(): array + { + return ['key' => 'value']; + } +}