From 9b3472b6ea4895affae2c21035db5228de297433 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 28 Jun 2026 15:20:01 +0200 Subject: [PATCH] Resolve early-terminating calls to an explicit never type in the call handlers A MethodCall/StaticCall/FuncCall configured via earlyTerminatingMethodCalls/earlyTerminatingFunctionCalls now resolves to an explicit NeverType in its handler, through a shared EarlyTerminatingCallHelper. findEarlyTerminatingExpr()'s duplicate list-matching goes away - its existing explicit-never check covers the configured calls the same way it already covers exit/die/throw and signature-never calls - and the lists move off NodeScopeResolver's constructor onto the helper as DI parameters. The test-case overrides are replaced by a nodeScopeResolverEarlyTerminating.neon parameter file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019wqGgaD7iqL44t1KgpJS7b --- src/Analyser/ExprHandler/FuncCallHandler.php | 9 +++ .../Helper/EarlyTerminatingCallHelper.php | 80 +++++++++++++++++++ .../ExprHandler/MethodCallHandler.php | 9 +++ .../ExprHandler/StaticCallHandler.php | 11 +++ src/Analyser/NodeScopeResolver.php | 53 ------------ src/Testing/RuleTestCase.php | 2 - src/Testing/TypeInferenceTestCase.php | 14 ---- tests/PHPStan/Analyser/AnalyserTest.php | 2 - .../Fiber/FiberNodeScopeResolverRuleTest.php | 2 - .../Fiber/FiberNodeScopeResolverTest.php | 2 - .../Analyser/LegacyNodeScopeResolverTest.php | 16 +--- .../Analyser/NodeScopeResolverTest.php | 16 +--- .../nodeScopeResolverEarlyTerminating.neon | 7 ++ 13 files changed, 118 insertions(+), 105 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php create mode 100644 tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index ca8f153f13f..5f649ef3da5 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -19,6 +19,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -95,6 +96,7 @@ final class FuncCallHandler implements ExprHandler * @param ExtensionsCollection $dynamicFunctionThrowTypeExtensions */ public function __construct( + private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private ReflectionProvider $reflectionProvider, #[AutowiredExtensions(of: DynamicFunctionThrowTypeExtension::class)] private ExtensionsCollection $dynamicFunctionThrowTypeExtensions, @@ -811,6 +813,13 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra public function resolveType(MutatingScope $scope, Expr $expr): Type { + if ( + $expr->name instanceof Name + && $this->earlyTerminatingCallHelper->isEarlyTerminatingFunctionCall($expr->name->toString()) + ) { + return new NeverType(true); + } + if ($expr->name instanceof Expr) { $calledOnType = $scope->getType($expr->name); if ($calledOnType->isCallable()->no()) { diff --git a/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php new file mode 100644 index 00000000000..d44df0587f3 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php @@ -0,0 +1,80 @@ + */ + private array $earlyTerminatingMethodNames; + + /** + * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) + * @param array $earlyTerminatingFunctionCalls + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter] + private array $earlyTerminatingMethodCalls, + #[AutowiredParameter] + private array $earlyTerminatingFunctionCalls, + ) + { + $earlyTerminatingMethodNames = []; + foreach ($this->earlyTerminatingMethodCalls as $methodNames) { + foreach ($methodNames as $methodName) { + $earlyTerminatingMethodNames[strtolower($methodName)] = true; + } + } + $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + } + + public function isEarlyTerminatingMethodCall(string $methodName, Type $calledOnType): bool + { + if (!array_key_exists(strtolower($methodName), $this->earlyTerminatingMethodNames)) { + return false; + } + + foreach ($calledOnType->getObjectClassNames() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($referencedClass); + foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { + if (!isset($this->earlyTerminatingMethodCalls[$className])) { + continue; + } + + if (in_array($methodName, $this->earlyTerminatingMethodCalls[$className], true)) { + return true; + } + } + } + + return false; + } + + public function isEarlyTerminatingFunctionCall(string $functionName): bool + { + return in_array($functionName, $this->earlyTerminatingFunctionCalls, true); + } + +} diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 4bc06266eda..329ba771c26 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -56,6 +57,7 @@ final class MethodCallHandler implements ExprHandler { public function __construct( + private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, @@ -246,6 +248,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex public function resolveType(MutatingScope $scope, Expr $expr): Type { + if ( + $expr->name instanceof Identifier + && $this->earlyTerminatingCallHelper->isEarlyTerminatingMethodCall($expr->name->name, $scope->getType($expr->var)) + ) { + return new NeverType(true); + } + if ($expr->name instanceof Identifier) { if ($scope->nativeTypesPromoted) { $methodReflection = $scope->getMethodReflection( diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index ed018fbd17f..280adc40833 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; @@ -64,6 +65,7 @@ final class StaticCallHandler implements ExprHandler { public function __construct( + private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, @@ -302,6 +304,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex public function resolveType(MutatingScope $scope, Expr $expr): Type { + if ($expr->name instanceof Identifier) { + $earlyTerminatingClassType = $expr->class instanceof Name + ? $scope->resolveTypeByName($expr->class) + : $scope->getType($expr->class); + if ($this->earlyTerminatingCallHelper->isEarlyTerminatingMethodCall($expr->name->name, $earlyTerminatingClassType)) { + return new NeverType(true); + } + } + if ($expr->name instanceof Identifier) { if ($scope->nativeTypesPromoted) { if ($expr->class instanceof Name) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 6e1ef776098..8b4964a53a0 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -205,9 +205,6 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; - /** @var array */ - private array $earlyTerminatingMethodNames; - /** @var array */ private array $calledMethodStack = []; @@ -215,8 +212,6 @@ class NodeScopeResolver private array $calledMethodResults = []; /** - * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) - * @param array $earlyTerminatingFunctionCalls * @param ExtensionsCollection $functionParameterOutTypeExtensions * @param ExtensionsCollection $methodParameterOutTypeExtensions * @param ExtensionsCollection $staticMethodParameterOutTypeExtensions @@ -268,10 +263,6 @@ public function __construct( private readonly bool $polluteScopeWithAlwaysIterableForeach, #[AutowiredParameter] private readonly bool $polluteScopeWithBlock, - #[AutowiredParameter] - private readonly array $earlyTerminatingMethodCalls, - #[AutowiredParameter] - private readonly array $earlyTerminatingFunctionCalls, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private readonly bool $implicitThrows, #[AutowiredParameter] @@ -280,13 +271,6 @@ public function __construct( protected readonly ExpressionResultFactory $expressionResultFactory, ) { - $earlyTerminatingMethodNames = []; - foreach ($this->earlyTerminatingMethodCalls as $methodNames) { - foreach ($methodNames as $methodName) { - $earlyTerminatingMethodNames[strtolower($methodName)] = true; - } - } - $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; } /** @@ -2758,43 +2742,6 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr { - if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { - if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { - if ($expr instanceof MethodCall) { - $methodCalledOnType = $scope->getType($expr->var); - } else { - if ($expr->class instanceof Name) { - $methodCalledOnType = $scope->resolveTypeByName($expr->class); - } else { - $methodCalledOnType = $scope->getType($expr->class); - } - } - - foreach ($methodCalledOnType->getObjectClassNames() as $referencedClass) { - if (!$this->reflectionProvider->hasClass($referencedClass)) { - continue; - } - - $classReflection = $this->reflectionProvider->getClass($referencedClass); - foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { - if (!isset($this->earlyTerminatingMethodCalls[$className])) { - continue; - } - - if (in_array((string) $expr->name, $this->earlyTerminatingMethodCalls[$className], true)) { - return $expr; - } - } - } - } - } - - if ($expr instanceof FuncCall && $expr->name instanceof Name) { - if (in_array((string) $expr->name, $this->earlyTerminatingFunctionCalls, true)) { - return $expr; - } - } - if ($expr instanceof Expr\Exit_ || $expr instanceof Expr\Throw_) { return $expr; } diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 7d9484dc27d..04f550f51ba 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -126,8 +126,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 01677335bb0..13e9a907dab 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -101,8 +101,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), @@ -500,16 +498,4 @@ protected static function getAdditionalAnalysedFiles(): array return []; } - /** @return string[][] */ - protected static function getEarlyTerminatingMethodCalls(): array - { - return []; - } - - /** @return string[] */ - protected static function getEarlyTerminatingFunctionCalls(): array - { - return []; - } - } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 5b268f01201..04417b7ab9f 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -842,8 +842,6 @@ private function createAnalyser(): Analyser false, true, true, - [], - [], true, $this->shouldTreatPhpDocTypesAsCertain(), $container->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 17644daeb02..c9732b873d2 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -146,8 +146,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 6124b0cf24b..00038bbc18e 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -79,8 +79,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index fc474ac68db..cdd9a94467f 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -46,6 +46,7 @@ public static function getAdditionalConfigFiles(): array return [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ]; } @@ -92,19 +93,4 @@ public function testEarlyTermination(): void }); } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index ed2e6f788ac..24f55aad643 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -355,6 +355,7 @@ public static function getAdditionalConfigFiles(): array [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ], ); } @@ -369,19 +370,4 @@ protected static function getAdditionalAnalysedFiles(): array ]; } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon new file mode 100644 index 00000000000..90badb85150 --- /dev/null +++ b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon @@ -0,0 +1,7 @@ +parameters: + earlyTerminatingMethodCalls: + EarlyTermination\Foo: + - doFoo + - doBar + earlyTerminatingFunctionCalls: + - baz