From 88be42ed1d89ba99f8803bee83e95fbc55478d5a Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 19:35:29 +0000 Subject: [PATCH 1/4] Add implicit throw point for dynamic instantiation of non-final classes without constructors - In `NewHandler::processExpr`, when a dynamic class name (`new $class()`) resolves to a single non-final class without a constructor, add an implicit throw point and an uncertain impure point. This is because the actual runtime class could be a subclass with a constructor that throws. - The fix is guarded by `implicitThrows` to remain consistent with how constructors are handled when that setting is disabled. - Fixes the false positive "Dead catch" for `class-string`, `class-string`, and `class-string` where the referenced type has no constructor. - Also fixes the false positive "Function returns void but does not have any side effects" for the same scenarios. - Updated `bug-4806` test which was asserting the buggy behavior for a non-final class without a constructor. --- src/Analyser/ExprHandler/NewHandler.php | 11 +++ .../CatchWithUnthrownExceptionRuleTest.php | 14 ++- .../Rules/Exceptions/data/bug-6574.php | 85 +++++++++++++++++++ .../Rules/Pure/PureFunctionRuleTest.php | 5 ++ tests/PHPStan/Rules/Pure/data/bug-6574.php | 23 +++++ 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-6574.php create mode 100644 tests/PHPStan/Rules/Pure/data/bug-6574.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 2e57336cb9e..70edad9decf 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -184,6 +184,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } + if ($classReflection !== null && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $classReflection->getDisplayName()), + false, + ); + } + if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; } diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 52be895f99b..61d516b3f4c 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -273,10 +273,6 @@ public function testBug4806(): void 'Dead catch - ArgumentCountError is never thrown in the try block.', 65, ], - [ - 'Dead catch - Throwable is never thrown in the try block.', - 119, - ], ]); } @@ -798,4 +794,14 @@ public function testBug14569(): void $this->analyse([__DIR__ . '/data/bug-14569.php'], []); } + public function testBug6574(): void + { + $this->analyse([__DIR__ . '/data/bug-6574.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 83, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php new file mode 100644 index 00000000000..eab5098f508 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -0,0 +1,85 @@ + $class */ +function interfaceWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function interfaceWithConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function abstractClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function abstractClassWithConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function nonFinalClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function finalClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - final class with no constructor + } +} diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index d4fd0d07518..cb9afa117a6 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -225,4 +225,9 @@ public function testBug14557(): void $this->analyse([__DIR__ . '/data/bug-14557-function.php'], []); } + public function testBug6574(): void + { + $this->analyse([__DIR__ . '/data/bug-6574.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/bug-6574.php b/tests/PHPStan/Rules/Pure/data/bug-6574.php new file mode 100644 index 00000000000..f025719a252 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-6574.php @@ -0,0 +1,23 @@ + $class */ +function interfaceWithoutConstructor(string $class): void +{ + new $class(); +} + +/** @param class-string $class */ +function abstractClassWithoutConstructor(string $class): void +{ + new $class(); +} From 07884912eac2372996de76184d5d2bc169cdfee4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 20:02:21 +0000 Subject: [PATCH 2/4] Add test cases for @throws void constructors on interfaces and abstract classes Co-Authored-By: Claude Opus 4.6 --- .../CatchWithUnthrownExceptionRuleTest.php | 10 +++++- .../Rules/Exceptions/data/bug-6574.php | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 61d516b3f4c..4cb3f7e0183 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -799,7 +799,15 @@ public function testBug6574(): void $this->analyse([__DIR__ . '/data/bug-6574.php'], [ [ 'Dead catch - Exception is never thrown in the try block.', - 83, + 97, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 106, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 115, ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index eab5098f508..76df06b8926 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -30,6 +30,20 @@ final class FinalClass { } +interface ThrowsVoidInterface +{ + /** @throws void */ + public function __construct(); +} + +abstract class AbstractThrowsVoid +{ + /** @throws void */ + public function __construct() + { + } +} + /** @param class-string $class */ function interfaceWithoutConstructor(string $class): void { @@ -83,3 +97,21 @@ function finalClassWithoutConstructor(string $class): void } catch (\Exception $e) { // dead catch - final class with no constructor } } + +/** @param class-string $class */ +function interfaceWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + +/** @param class-string $class */ +function abstractClassWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} From dff24fdd5ba1e629f2919a9d4f4f355cc8449655 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 20:06:11 +0000 Subject: [PATCH 3/4] Address review: make bug-4806 HasNoConstructor final, add @throws void test cases - Make HasNoConstructor class final in bug-4806.php so the dead catch assertion is preserved (instead of removing it) - Add test cases for interface and abstract class with @throws void constructors, which should correctly report dead catch Co-Authored-By: Claude Opus 4.6 --- .../CatchWithUnthrownExceptionRuleTest.php | 4 +++ .../Rules/Exceptions/data/bug-4806.php | 2 +- .../Rules/Exceptions/data/bug-6574.php | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 4cb3f7e0183..e7e10cb9e93 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -273,6 +273,10 @@ public function testBug4806(): void 'Dead catch - ArgumentCountError is never thrown in the try block.', 65, ], + [ + 'Dead catch - Throwable is never thrown in the try block.', + 119, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-4806.php b/tests/PHPStan/Rules/Exceptions/data/bug-4806.php index 78d1ae5d389..d9f4b7fa39b 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-4806.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-4806.php @@ -12,7 +12,7 @@ final public function __construct() } } -class HasNoConstructor +final class HasNoConstructor { } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index 76df06b8926..be558445e06 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -26,6 +26,20 @@ class NonFinalClass { } +interface ThrowsVoidInterface +{ + /** @throws void */ + public function __construct(); +} + +abstract class AbstractThrowsVoid +{ + /** @throws void */ + public function __construct() + { + } +} + final class FinalClass { } @@ -89,6 +103,24 @@ function nonFinalClassWithoutConstructor(string $class): void } } +/** @param class-string $class */ +function interfaceWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + +/** @param class-string $class */ +function abstractClassWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + /** @param class-string $class */ function finalClassWithoutConstructor(string $class): void { From 3913e4ecc6e0cf4a73e63a3895a596bf46eb5f94 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 16 May 2026 22:20:18 +0200 Subject: [PATCH 4/4] Fix duplicate --- .../Rules/Exceptions/data/bug-6574.php | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index be558445e06..d366912b8bc 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -44,20 +44,6 @@ final class FinalClass { } -interface ThrowsVoidInterface -{ - /** @throws void */ - public function __construct(); -} - -abstract class AbstractThrowsVoid -{ - /** @throws void */ - public function __construct() - { - } -} - /** @param class-string $class */ function interfaceWithoutConstructor(string $class): void { @@ -129,21 +115,3 @@ function finalClassWithoutConstructor(string $class): void } catch (\Exception $e) { // dead catch - final class with no constructor } } - -/** @param class-string $class */ -function interfaceWithThrowsVoidConstructor(string $class): void -{ - try { - new $class(); - } catch (\Exception $e) { // dead catch - constructor is @throws void - } -} - -/** @param class-string $class */ -function abstractClassWithThrowsVoidConstructor(string $class): void -{ - try { - new $class(); - } catch (\Exception $e) { // dead catch - constructor is @throws void - } -}