From 0521bc679ffd5d4865922ab90eff0a2723eba776 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:22:57 +0000 Subject: [PATCH 1/6] Fix unreachable statement info lost in chained method calls - MethodCallHandler and StaticCallHandler overwrote $isAlwaysTerminating instead of OR-ing with the existing value when checking the return type - This caused never-returning arguments in chained calls to not propagate the always-terminating status to the outer call - Added regression test for phpstan/phpstan#14328 Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/MethodCallHandler.php | 2 +- src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- .../DeadCode/UnreachableStatementRuleTest.php | 12 ++++++++++++ tests/PHPStan/Rules/DeadCode/data/bug-14328.php | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/DeadCode/data/bug-14328.php diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 35c5455da5..826d757a20 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -136,7 +136,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $expr) ?? $expr; $returnType = $parametersAcceptor->getReturnType(); - $isAlwaysTerminating = $returnType instanceof NeverType && $returnType->isExplicit(); + $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } $argsResult = $nodeScopeResolver->processArgs( diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 16eceaefbc..2c06152b2c 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -192,7 +192,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $expr) ?? $expr; $returnType = $parametersAcceptor->getReturnType(); - $isAlwaysTerminating = $returnType instanceof NeverType && $returnType->isExplicit(); + $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); $scope = $argsResult->getScope(); diff --git a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php index e79a185935..98ebf8b221 100644 --- a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php @@ -373,4 +373,16 @@ public function testBug13331(): void $this->analyse([__DIR__ . '/data/bug-13331.php'], []); } + #[RequiresPhp('>= 8.2')] + public function testBug14328(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14328.php'], [ + [ + 'Unreachable statement - code above always terminates.', + 16, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php new file mode 100644 index 0000000000..f10c4c29e0 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -0,0 +1,16 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug14328; + +$callback = fn (): never => throw new \Exception(); + +class Foo { + public function returnThis(mixed $value): self { + return $this; + } +} + +$x = new Foo()->returnThis($callback())->returnThis('x'); +$y = 'this will never run'; From 87a004778db64ca8435817a6418550cbbf26fc48 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 20:43:38 +0000 Subject: [PATCH 2/6] Fix lint issue: wrap `new Foo()` in parentheses in test data Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/DeadCode/data/bug-14328.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php index f10c4c29e0..fb5bad86a0 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -12,5 +12,5 @@ public function returnThis(mixed $value): self { } } -$x = new Foo()->returnThis($callback())->returnThis('x'); +$x = (new Foo())->returnThis($callback())->returnThis('x'); $y = 'this will never run'; From 6ab487aae9f3a092150b8b504e5ac9976f46b3bd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 21:19:00 +0000 Subject: [PATCH 3/6] Add test for unreachable statement after static call with never-returning arg Addresses review feedback from staabm: the StaticCallHandler fix was missing a test. Restructured the test data into separate functions so each scenario (method call chain vs static call) is independently tested. Co-Authored-By: Claude Opus 4.6 --- .../DeadCode/UnreachableStatementRuleTest.php | 6 +++++- .../PHPStan/Rules/DeadCode/data/bug-14328.php | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php index 98ebf8b221..6fad7691e1 100644 --- a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php @@ -380,7 +380,11 @@ public function testBug14328(): void $this->analyse([__DIR__ . '/data/bug-14328.php'], [ [ 'Unreachable statement - code above always terminates.', - 16, + 21, + ], + [ + 'Unreachable statement - code above always terminates.', + 27, ], ]); } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php index fb5bad86a0..e976d7cc7e 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -4,13 +4,25 @@ namespace Bug14328; -$callback = fn (): never => throw new \Exception(); - class Foo { public function returnThis(mixed $value): self { return $this; } } -$x = (new Foo())->returnThis($callback())->returnThis('x'); -$y = 'this will never run'; +class Bar { + public static function doSomething(mixed $value): void { + } +} + +function testMethodCall(): void { + $callback = fn (): never => throw new \Exception(); + $x = (new Foo())->returnThis($callback())->returnThis('x'); + $y = 'this will never run'; +} + +function testStaticCall(): void { + $callback = fn (): never => throw new \Exception(); + Bar::doSomething($callback()); + $b = 'this will never run'; +} From 149e50cd3d555df90791bc7819ee186ef76b5baf Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 21:26:40 +0000 Subject: [PATCH 4/6] Add test for chained static call with never-returning argument Add a test case where a static call with a never-returning argument is chained to a method call, testing the interaction between StaticCallHandler and MethodCallHandler isAlwaysTerminating propagation. Co-Authored-By: Claude Opus 4.6 --- .../Rules/DeadCode/UnreachableStatementRuleTest.php | 8 ++++++-- tests/PHPStan/Rules/DeadCode/data/bug-14328.php | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php index 6fad7691e1..b8d14fbc4e 100644 --- a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php @@ -380,11 +380,15 @@ public function testBug14328(): void $this->analyse([__DIR__ . '/data/bug-14328.php'], [ [ 'Unreachable statement - code above always terminates.', - 21, + 25, ], [ 'Unreachable statement - code above always terminates.', - 27, + 31, + ], + [ + 'Unreachable statement - code above always terminates.', + 37, ], ]); } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php index e976d7cc7e..90f1a07fa0 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -8,6 +8,10 @@ class Foo { public function returnThis(mixed $value): self { return $this; } + + public static function staticReturnSelf(mixed $value): self { + return new self(); + } } class Bar { @@ -26,3 +30,9 @@ function testStaticCall(): void { Bar::doSomething($callback()); $b = 'this will never run'; } + +function testStaticCallChained(): void { + $callback = fn (): never => throw new \Exception(); + $a = Foo::staticReturnSelf($callback())->returnThis('x'); + $b = 'this will never run either'; +} From c9bd900bf91b8eb4a637fcb6b977aaf81e00a86d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 21:28:02 +0000 Subject: [PATCH 5/6] Add test for static-to-static chained call (::) unreachable detection The previous tests covered static calls chained with instance method calls (->), but not static calls chained with other static calls (::). This new test exercises the StaticCallHandler fix where the class expression is itself a static call with a never-returning argument. Co-Authored-By: Claude Opus 4.6 --- .../Rules/DeadCode/UnreachableStatementRuleTest.php | 4 ++++ tests/PHPStan/Rules/DeadCode/data/bug-14328.php | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php index b8d14fbc4e..cc1c612050 100644 --- a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php @@ -390,6 +390,10 @@ public function testBug14328(): void 'Unreachable statement - code above always terminates.', 37, ], + [ + 'Unreachable statement - code above always terminates.', + 43, + ], ]); } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php index 90f1a07fa0..f7773a3a3e 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -31,8 +31,14 @@ function testStaticCall(): void { $b = 'this will never run'; } -function testStaticCallChained(): void { +function testStaticCallChainedWithMethodCall(): void { $callback = fn (): never => throw new \Exception(); $a = Foo::staticReturnSelf($callback())->returnThis('x'); $b = 'this will never run either'; } + +function testStaticCallChainedWithStaticCall(): void { + $callback = fn (): never => throw new \Exception(); + $a = Foo::staticReturnSelf($callback())::staticReturnSelf('x'); + $b = 'this will never run either'; +} From 5546ea5eaa64cdadf40af0dffadd4aca07bba747 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 19 Mar 2026 22:51:07 +0100 Subject: [PATCH 6/6] Simplify tests --- .../DeadCode/UnreachableStatementRuleTest.php | 8 ++++---- .../PHPStan/Rules/DeadCode/data/bug-14328.php | 19 +++++++------------ 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php index cc1c612050..964a35da05 100644 --- a/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnreachableStatementRuleTest.php @@ -380,19 +380,19 @@ public function testBug14328(): void $this->analyse([__DIR__ . '/data/bug-14328.php'], [ [ 'Unreachable statement - code above always terminates.', - 25, + 20, ], [ 'Unreachable statement - code above always terminates.', - 31, + 26, ], [ 'Unreachable statement - code above always terminates.', - 37, + 32, ], [ 'Unreachable statement - code above always terminates.', - 43, + 38, ], ]); } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php index f7773a3a3e..c0e707561e 100644 --- a/tests/PHPStan/Rules/DeadCode/data/bug-14328.php +++ b/tests/PHPStan/Rules/DeadCode/data/bug-14328.php @@ -9,36 +9,31 @@ public function returnThis(mixed $value): self { return $this; } - public static function staticReturnSelf(mixed $value): self { + public static function returnSelf(mixed $value): self { return new self(); } } -class Bar { - public static function doSomething(mixed $value): void { - } -} - -function testMethodCall(): void { +function testMethodCallChainedWithMethodCall(): void { $callback = fn (): never => throw new \Exception(); $x = (new Foo())->returnThis($callback())->returnThis('x'); $y = 'this will never run'; } -function testStaticCall(): void { +function testMethodCallChainedWithStaticCall(): void { $callback = fn (): never => throw new \Exception(); - Bar::doSomething($callback()); - $b = 'this will never run'; + $x = (new Foo())->returnThis($callback())::returnSelf('x'); + $y = 'this will never run'; } function testStaticCallChainedWithMethodCall(): void { $callback = fn (): never => throw new \Exception(); - $a = Foo::staticReturnSelf($callback())->returnThis('x'); + $a = Foo::returnSelf($callback())->returnThis('x'); $b = 'this will never run either'; } function testStaticCallChainedWithStaticCall(): void { $callback = fn (): never => throw new \Exception(); - $a = Foo::staticReturnSelf($callback())::staticReturnSelf('x'); + $a = Foo::returnSelf($callback())::returnSelf('x'); $b = 'this will never run either'; }