From 47f7778f89b521600c67dc20b9575959615e3cb7 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 14 Jun 2026 18:59:10 +0000 Subject: [PATCH 1/4] Resolve conditional `@throws` types like `($x is 0 ? Exception : void)` at call sites and throw points - Add `ConditionalThrowTypeResolver` that resolves `ConditionalTypeForParameter` inside a `@throws` type, either against the arguments passed at a call site (`resolveForCall`) or against the parameter variables narrowed in a throw point's scope (`resolveForScope`). - Resolve conditional throws types when computing throw points for function calls (`FuncCallHandler`), method/static calls (`MethodThrowPointHelper`) and constructor calls (`NewHandler`), so callers only see the exception when the arguments actually trigger the throwing branch. - Resolve conditional throws types per throw point in `MissingCheckedExceptionInThrowsCheck` (used by the function, method and property-hook rules) so a function body that throws only in a branch is matched against its own conditional `@throws` declaration. - Accept conditional throws types in `InvalidThrowsPhpDocValueRule` as long as every branch is a valid throws type (a `Throwable` subtype or `void`); plain `void` is still only allowed standalone or inside a conditional branch, so `Throwable|void` remains invalid. --- src/Analyser/ConditionalThrowTypeResolver.php | 153 ++++++++++++++++++ src/Analyser/ExprHandler/FuncCallHandler.php | 4 + .../Helper/MethodThrowPointHelper.php | 4 + src/Analyser/ExprHandler/NewHandler.php | 3 +- .../MissingCheckedExceptionInThrowsCheck.php | 7 +- .../PhpDoc/InvalidThrowsPhpDocValueRule.php | 28 +++- ...eckedExceptionInFunctionThrowsRuleTest.php | 15 ++ ...CheckedExceptionInMethodThrowsRuleTest.php | 18 +++ .../data/conditional-throws-function.php | 45 ++++++ .../data/conditional-throws-method.php | 88 ++++++++++ .../InvalidThrowsPhpDocValueRuleTest.php | 4 + .../Rules/PhpDoc/data/incompatible-throws.php | 24 +++ 12 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 src/Analyser/ConditionalThrowTypeResolver.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php diff --git a/src/Analyser/ConditionalThrowTypeResolver.php b/src/Analyser/ConditionalThrowTypeResolver.php new file mode 100644 index 00000000000..b3317e89444 --- /dev/null +++ b/src/Analyser/ConditionalThrowTypeResolver.php @@ -0,0 +1,153 @@ +hasTemplateOrLateResolvableType()) { + return $throwType; + } + + return self::resolve($throwType, self::getPassedArgs($parametersAcceptor, $args, $scope)); + } + + public static function resolveForScope(Type $throwType, Scope $scope): Type + { + if (!$throwType->hasTemplateOrLateResolvableType()) { + return $throwType; + } + + $passedArgs = []; + foreach (self::collectParameterNames($throwType) as $parameterName) { + $variableName = substr($parameterName, 1); + if (!$scope->hasVariableType($variableName)->yes()) { + continue; + } + + $passedArgs[$parameterName] = $scope->getType(new Variable($variableName)); + } + + return self::resolve($throwType, $passedArgs); + } + + /** + * @param array $passedArgs + */ + private static function resolve(Type $throwType, array $passedArgs): Type + { + if ($passedArgs === []) { + return $throwType; + } + + $resolved = TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use ($passedArgs): Type { + if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $passedArgs)) { + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + return $type->toConditional($passedArgs[$type->getParameterName()]); + } + + return $type; + } + + return $traverse($type); + }); + + return TypeUtils::resolveLateResolvableTypes($resolved, false); + } + + /** + * @return list + */ + private static function collectParameterNames(Type $throwType): array + { + $names = []; + TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use (&$names): Type { + if ($type instanceof ConditionalTypeForParameter) { + $names[] = $type->getParameterName(); + } + + return $traverse($type); + }); + + return $names; + } + + /** + * @param Arg[] $args + * @return array + */ + private static function getPassedArgs(ParametersAcceptor $parametersAcceptor, array $args, Scope $scope): array + { + $parameters = $parametersAcceptor->getParameters(); + + $namedArgTypes = []; + $i = 0; + foreach ($args as $arg) { + if ($arg->unpack) { + // unpacked arguments cannot be reliably mapped to a single parameter + $i++; + continue; + } + + if ($arg->name !== null) { + $namedArgTypes[$arg->name->toString()] = $scope->getType($arg->value); + continue; + } + + if (isset($parameters[$i])) { + $namedArgTypes[$parameters[$i]->getName()] = $scope->getType($arg->value); + } elseif (count($parameters) > 0) { + $lastParameter = array_last($parameters); + if ($lastParameter->isVariadic()) { + $namedArgTypes[$lastParameter->getName()] = $scope->getType($arg->value); + } + } + + $i++; + } + + $passedArgs = []; + foreach ($parameters as $parameter) { + if (array_key_exists($parameter->getName(), $namedArgTypes)) { + $passedArgs['$' . $parameter->getName()] = $namedArgTypes[$parameter->getName()]; + } elseif ($parameter->getDefaultValue() !== null) { + $passedArgs['$' . $parameter->getName()] = $parameter->getDefaultValue(); + } + } + + return $passedArgs; + } + +} diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index d28a3225dee..11fa7098c9a 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -14,6 +14,7 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ConditionalThrowTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; @@ -604,6 +605,9 @@ private function getFunctionThrowPoint( } $throwType = $functionReflection->getThrowType(); + if ($throwType !== null && $parametersAcceptor !== null) { + $throwType = ConditionalThrowTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedFuncCall->getArgs(), $scope); + } if ($throwType === null) { $returnType = $scope->getType($normalizedFuncCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 08ff8735587..838aaefe556 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -4,6 +4,7 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +use PHPStan\Analyser\ConditionalThrowTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -76,6 +77,9 @@ public function getThrowPoint( } $throwType = $methodReflection->getThrowType(); + if ($throwType !== null) { + $throwType = ConditionalThrowTypeResolver::resolveForCall($throwType, $parametersAcceptor, $normalizedMethodCall->getArgs(), $scope); + } if ($throwType === null) { $returnType = $scope->getType($normalizedMethodCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 2360ccd8076..eed726bd2bf 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -9,6 +9,7 @@ use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ConditionalThrowTypeResolver; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; @@ -302,7 +303,7 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio } if ($constructorReflection->getThrowType() !== null) { - $throwType = $constructorReflection->getThrowType(); + $throwType = ConditionalThrowTypeResolver::resolveForCall($constructorReflection->getThrowType(), $parametersAcceptor, $args, $scope); if (!$throwType->isVoid()->yes()) { return InternalThrowPoint::createExplicit($scope, $throwType, $new, true); } diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php index 62de7f9e2ad..9f3717d5f99 100644 --- a/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php +++ b/src/Rules/Exceptions/MissingCheckedExceptionInThrowsCheck.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Exceptions; use PhpParser\Node; +use PHPStan\Analyser\ConditionalThrowTypeResolver; use PHPStan\Analyser\ThrowPoint; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -41,11 +42,15 @@ public function check(?Type $throwType, array $throwPoints): array continue; } + // Conditional @throws types like ($x is 0 ? Exception : void) are resolved + // against the parameter variables narrowed in the scope of the throw point. + $resolvedThrowType = ConditionalThrowTypeResolver::resolveForScope($throwType, $throwPoint->getScope()); + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { if ($throwPointType->isSuperTypeOf(new ObjectType(Throwable::class))->yes()) { continue; } - if ($throwType->isSuperTypeOf($throwPointType)->yes()) { + if ($resolvedThrowType->isSuperTypeOf($throwPointType)->yes()) { continue; } diff --git a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php index 9a89cfce380..4835ea56ed8 100644 --- a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php +++ b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php @@ -10,6 +10,8 @@ use PHPStan\Node\InPropertyHookNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\ConditionalType; +use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -73,7 +75,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - if ($this->isThrowsValid($phpDocThrowsType)) { + if ($this->isThrowsValid($phpDocThrowsType, false)) { return []; } @@ -85,12 +87,32 @@ public function processNode(Node $node, Scope $scope): array ]; } - private function isThrowsValid(Type $phpDocThrowsType): bool + /** + * @param bool $voidAllowed whether `void` is an acceptable type here; only true inside + * the branches of a conditional `@throws` type + */ + private function isThrowsValid(Type $phpDocThrowsType, bool $voidAllowed): bool { + if ($voidAllowed && $phpDocThrowsType->isVoid()->yes()) { + return true; + } + + // Conditional @throws types like ($x is 0 ? Exception : void) are valid as long + // as both branches are valid throws types (a Throwable subtype or void). + if ($phpDocThrowsType instanceof ConditionalType) { + return $this->isThrowsValid($phpDocThrowsType->getIf(), true) + && $this->isThrowsValid($phpDocThrowsType->getElse(), true); + } + + if ($phpDocThrowsType instanceof ConditionalTypeForParameter) { + return $this->isThrowsValid($phpDocThrowsType->getIf(), true) + && $this->isThrowsValid($phpDocThrowsType->getElse(), true); + } + $throwType = new ObjectType(Throwable::class); if ($phpDocThrowsType instanceof UnionType) { foreach ($phpDocThrowsType->getTypes() as $innerType) { - if (!$this->isThrowsValid($innerType)) { + if (!$this->isThrowsValid($innerType, $voidAllowed)) { return false; } } diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php index 5c0d007b848..b7027176983 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php @@ -52,4 +52,19 @@ public function testRule(): void ]); } + public function testConditionalThrows(): void + { + require_once __DIR__ . '/data/conditional-throws-function.php'; + $this->analyse([__DIR__ . '/data/conditional-throws-function.php'], [ + [ + 'Function ConditionalThrowsFunction\callsZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 23, + ], + [ + 'Function ConditionalThrowsFunction\callsUnknown() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 35, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php index 73bb2894d8b..5d24642b821 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php @@ -110,4 +110,22 @@ public function testBug13792(): void ]); } + public function testConditionalThrows(): void + { + $this->analyse([__DIR__ . '/data/conditional-throws-method.php'], [ + [ + 'Method ConditionalThrowsMethod\Caller::methodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 55, + ], + [ + 'Method ConditionalThrowsMethod\Caller::staticCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 67, + ], + [ + 'Method ConditionalThrowsMethod\Caller::constructorZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 79, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php new file mode 100644 index 00000000000..ccac818d9e0 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php @@ -0,0 +1,45 @@ + $x + * @throws void + */ +function callsRange(int $x): void +{ + inverse($x); +} diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php new file mode 100644 index 00000000000..18b6306be88 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php @@ -0,0 +1,88 @@ +inverse(0); + } + + /** @throws void */ + public function methodCallNonZero(Service $service): void + { + $service->inverse(7); + } + + /** @throws void */ + public function staticCallZero(): void + { + Service::staticInverse(0); + } + + /** @throws void */ + public function staticCallNonZero(): void + { + Service::staticInverse(7); + } + + /** @throws void */ + public function constructorZero(): void + { + new Service(0); + } + + /** @throws void */ + public function constructorNonZero(): void + { + new Service(7); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php index 01edaab08c6..4b50ae12fb3 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php @@ -58,6 +58,10 @@ public function testRule(): void 'PHPDoc tag @throws with type stdClass is not subtype of Throwable', 118, ], + [ + 'PHPDoc tag @throws with type ($x is int ? stdClass : void) is not subtype of Throwable', + 141, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php index 7ce3088fc3f..21e16e90dcb 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php +++ b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php @@ -117,3 +117,27 @@ function inlineThrows() /** @throws \stdClass */ $i = 1; } + +/** + * @param int $x + * @throws ($x is 0 ? \Exception : void) + */ +function conditionalThrows($x) +{ +} + +/** + * @param int $x + * @throws ($x is 0 ? \Exception : \RuntimeException) + */ +function conditionalThrowsBothBranches($x) +{ +} + +/** + * @param int $x + * @throws ($x is 0 ? \stdClass : void) + */ +function conditionalThrowsInvalidBranch($x) +{ +} From 6d00231421b15d53852853e83ef6dbf3e4db5349 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 14 Jun 2026 20:47:43 +0000 Subject: [PATCH 2/4] Resolve template-based conditional `@throws` types like `(TKey is int ? void : Exception)` Conditional `@throws` resolution previously only handled `ConditionalTypeForParameter` (subject is a `$param` variable). A conditional whose subject is a template type produces a `ConditionalType` instead, which was never resolved against the inferred template type map. `ConditionalThrowTypeResolver::resolveForCall()` now also resolves template types via the call-site `resolvedTemplateTypeMap`/`callSiteVarianceMap` when the parameters acceptor is an `ExtendedParametersAcceptor`, so callers see the precise branch (e.g. `lookup(1)` does not throw, `lookup('foo')` throws). Inside the function body, where the template is not bound to a concrete type, `resolveForScope()` collapses the unresolvable conditional to the union of its branches so the body's throw points are matched against the broadest declared set instead of a `Maybe`-certain conditional (which caused a false positive). `InvalidThrowsPhpDocValueRule` already recurses into `ConditionalType` branches, so template-based conditional throws validation works (and still rejects an invalid branch like `(TKey is int ? void : stdClass)`). Co-Authored-By: Claude Opus 4.8 --- src/Analyser/ConditionalThrowTypeResolver.php | 47 ++++++++++++++----- ...eckedExceptionInFunctionThrowsRuleTest.php | 8 ++++ ...CheckedExceptionInMethodThrowsRuleTest.php | 10 ++-- .../data/conditional-throws-function.php | 33 +++++++++++++ .../data/conditional-throws-method.php | 24 ++++++++++ .../InvalidThrowsPhpDocValueRuleTest.php | 4 ++ .../Rules/PhpDoc/data/incompatible-throws.php | 18 +++++++ 7 files changed, 129 insertions(+), 15 deletions(-) diff --git a/src/Analyser/ConditionalThrowTypeResolver.php b/src/Analyser/ConditionalThrowTypeResolver.php index b3317e89444..475fce096ee 100644 --- a/src/Analyser/ConditionalThrowTypeResolver.php +++ b/src/Analyser/ConditionalThrowTypeResolver.php @@ -4,8 +4,11 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr\Variable; +use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; use PHPStan\Type\TypeUtils; @@ -15,13 +18,14 @@ use function substr; /** - * Resolves conditional `@throws` types like `($x is 0 ? Exception : void)`. + * Resolves conditional `@throws` types like `($x is 0 ? Exception : void)` and + * `(TKey is int ? void : Exception)`. * - * The same `ConditionalTypeForParameter` representation used for conditional - * return types is resolved here against either the arguments passed at a call - * site (so callers see whether the call throws) or against the parameter - * variables inside the function body (so the body's throw points are matched - * against the declared `@throws` type). + * The same `ConditionalTypeForParameter` and `ConditionalType` representations + * used for conditional return types are resolved here against either the + * arguments passed at a call site (so callers see whether the call throws) or + * against the parameter variables inside the function body (so the body's throw + * points are matched against the declared `@throws` type). */ final class ConditionalThrowTypeResolver { @@ -40,7 +44,22 @@ public static function resolveForCall( return $throwType; } - return self::resolve($throwType, self::getPassedArgs($parametersAcceptor, $args, $scope)); + // ConditionalTypeForParameter (e.g. ($x is 0 ? Exception : void)) is resolved + // against the argument types passed at the call site. + $throwType = self::mapConditionalTypesForParameter($throwType, self::getPassedArgs($parametersAcceptor, $args, $scope)); + + // ConditionalType whose subject is a template type (e.g. (TKey is int ? void : Exception)) + // is resolved against the template types inferred from the call site. + if ($parametersAcceptor instanceof ExtendedParametersAcceptor) { + $throwType = TemplateTypeHelper::resolveTemplateTypes( + $throwType, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor->getCallSiteVarianceMap(), + TemplateTypeVariance::createCovariant(), + ); + } + + return TypeUtils::resolveLateResolvableTypes($throwType, false); } public static function resolveForScope(Type $throwType, Scope $scope): Type @@ -59,19 +78,25 @@ public static function resolveForScope(Type $throwType, Scope $scope): Type $passedArgs[$parameterName] = $scope->getType(new Variable($variableName)); } - return self::resolve($throwType, $passedArgs); + $throwType = self::mapConditionalTypesForParameter($throwType, $passedArgs); + + // A ConditionalType whose subject is a template type cannot be resolved to a single + // branch inside the function body (the template is not bound to a concrete type there), + // so it is conservatively collapsed to the union of its branches — the broadest set of + // exceptions the declaration permits — rather than left as a Maybe-certain conditional. + return TypeUtils::resolveLateResolvableTypes($throwType, true); } /** * @param array $passedArgs */ - private static function resolve(Type $throwType, array $passedArgs): Type + private static function mapConditionalTypesForParameter(Type $throwType, array $passedArgs): Type { if ($passedArgs === []) { return $throwType; } - $resolved = TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use ($passedArgs): Type { + return TypeTraverser::map($throwType, static function (Type $type, callable $traverse) use ($passedArgs): Type { if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $passedArgs)) { $type = $traverse($type); if ($type instanceof ConditionalTypeForParameter) { @@ -83,8 +108,6 @@ private static function resolve(Type $throwType, array $passedArgs): Type return $traverse($type); }); - - return TypeUtils::resolveLateResolvableTypes($resolved, false); } /** diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php index b7027176983..a6a3b6ae275 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInFunctionThrowsRuleTest.php @@ -64,6 +64,14 @@ public function testConditionalThrows(): void 'Function ConditionalThrowsFunction\callsUnknown() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', 35, ], + [ + 'Function ConditionalThrowsFunction\lookupString() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 68, + ], + [ + 'Function ConditionalThrowsFunction\lookupUnknown() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 77, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php index 5d24642b821..d9448594574 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php @@ -115,15 +115,19 @@ public function testConditionalThrows(): void $this->analyse([__DIR__ . '/data/conditional-throws-method.php'], [ [ 'Method ConditionalThrowsMethod\Caller::methodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 55, + 67, ], [ 'Method ConditionalThrowsMethod\Caller::staticCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 67, + 79, ], [ 'Method ConditionalThrowsMethod\Caller::constructorZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 79, + 91, + ], + [ + 'Method ConditionalThrowsMethod\Caller::lookupString() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 109, ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php index ccac818d9e0..b120acd7d90 100644 --- a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-function.php @@ -43,3 +43,36 @@ function callsRange(int $x): void { inverse($x); } + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : Exception) + */ +function lookup($key): void +{ + if (is_string($key)) { + throw new Exception('String keys are not supported.'); + } +} + +/** @throws void */ +function lookupInt(): void +{ + lookup(1); +} + +/** @throws void */ +function lookupString(): void +{ + lookup('foo'); +} + +/** + * @param int|string $key + * @throws void + */ +function lookupUnknown($key): void +{ + lookup($key); +} diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php index 18b6306be88..aae3813fc4d 100644 --- a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php @@ -44,6 +44,18 @@ public function __construct(int $x) } } + /** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : Exception) + */ + public function lookup($key): void + { + if (is_string($key)) { + throw new Exception('String keys are not supported.'); + } + } + } class Caller @@ -85,4 +97,16 @@ public function constructorNonZero(): void new Service(7); } + /** @throws void */ + public function lookupInt(Service $service): void + { + $service->lookup(1); + } + + /** @throws void */ + public function lookupString(Service $service): void + { + $service->lookup('foo'); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php index 4b50ae12fb3..8c31e1e3741 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php @@ -62,6 +62,10 @@ public function testRule(): void 'PHPDoc tag @throws with type ($x is int ? stdClass : void) is not subtype of Throwable', 141, ], + [ + 'PHPDoc tag @throws with type (TKey of int|string is int ? void : stdClass) is not subtype of Throwable', + 159, + ], ]); } diff --git a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php index 21e16e90dcb..e349be40e31 100644 --- a/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php +++ b/tests/PHPStan/Rules/PhpDoc/data/incompatible-throws.php @@ -141,3 +141,21 @@ function conditionalThrowsBothBranches($x) function conditionalThrowsInvalidBranch($x) { } + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : \Exception) + */ +function conditionalThrowsForTemplate($key) +{ +} + +/** + * @template TKey of int|string + * @param TKey $key + * @throws (TKey is int ? void : \stdClass) + */ +function conditionalThrowsForTemplateInvalidBranch($key) +{ +} From a9304c028a6fb3b05187a85177977df9c52afa98 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 14 Jun 2026 20:50:39 +0000 Subject: [PATCH 3/4] Remap parameter names in inherited conditional @throws types When a method overrides a parent method using a different parameter name and inherits the parent's @throws PHPDoc, a conditional throws type like ($x is 0 ? Exception : void) referenced the parent's parameter name. Apply the same parameter-name remapping used for inherited @return types so the conditional resolves against the overriding method's parameters both at the throw points in the body and at call sites. Co-Authored-By: Claude Opus 4.8 --- src/PhpDoc/ResolvedPhpDocBlock.php | 13 +++++++--- ...CheckedExceptionInMethodThrowsRuleTest.php | 12 ++++++--- .../data/conditional-throws-method.php | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index be6da4fc46d..4192b115f24 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -278,7 +278,7 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping); $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); - $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent); + $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent, $parameterMapping); $result->mixinTags = $this->getMixinTags(); $result->requireExtendsTags = $this->getRequireExtendsTags(); $result->requireImplementsTags = $this->getRequireImplementsTags(); @@ -1016,13 +1016,20 @@ private static function mergeDeprecatedTags(?DeprecatedTag $deprecatedTag, bool return $result; } - private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent): ?ThrowsTag + private static function mergeThrowsTags(?ThrowsTag $throwsTag, self $parent, InheritedPhpDocParameterMapping $parameterMapping): ?ThrowsTag { if ($throwsTag !== null) { return $throwsTag; } - return $parent->getThrowsTag(); + $parentThrowsTag = $parent->getThrowsTag(); + if ($parentThrowsTag === null) { + return null; + } + + // Conditional @throws types like ($x is 0 ? Exception : void) reference parameter + // names that may differ in the overriding method, so remap them just like @return. + return new ThrowsTag($parameterMapping->transformConditionalReturnTypeWithParameterNameMapping($parentThrowsTag->getType())); } /** diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php index d9448594574..ab4652a5aa1 100644 --- a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInMethodThrowsRuleTest.php @@ -115,19 +115,23 @@ public function testConditionalThrows(): void $this->analyse([__DIR__ . '/data/conditional-throws-method.php'], [ [ 'Method ConditionalThrowsMethod\Caller::methodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 67, + 81, ], [ 'Method ConditionalThrowsMethod\Caller::staticCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 79, + 93, ], [ 'Method ConditionalThrowsMethod\Caller::constructorZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 91, + 105, ], [ 'Method ConditionalThrowsMethod\Caller::lookupString() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', - 109, + 123, + ], + [ + 'Method ConditionalThrowsMethod\Caller::inheritedMethodCallZero() throws checked exception Exception but it\'s missing from the PHPDoc @throws tag.', + 129, ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php index aae3813fc4d..580cf88d5ea 100644 --- a/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php +++ b/tests/PHPStan/Rules/Exceptions/data/conditional-throws-method.php @@ -58,6 +58,20 @@ public function lookup($key): void } +class Service2 extends Service +{ + + public function inverse(int $y): float + { + if ($y === 0) { + throw new Exception('Division by zero.'); + } + + return 1 / $y; + } + +} + class Caller { @@ -109,4 +123,16 @@ public function lookupString(Service $service): void $service->lookup('foo'); } + /** @throws void */ + public function inheritedMethodCallZero(Service2 $service): void + { + $service->inverse(0); + } + + /** @throws void */ + public function inheritedMethodCallNonZero(Service2 $service): void + { + $service->inverse(7); + } + } From dc9159fafa2fb92629907d1caec754c6858d1d7c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 14 Jun 2026 20:52:31 +0000 Subject: [PATCH 4/4] Move void check into isThrowsValid and drop voidAllowed parameter The void-acceptance check previously lived both in processNode (for a standalone `@throws void`) and as a `voidAllowed` flag threaded through isThrowsValid. Since `voidAllowed` was only ever false at the single top-level call, fold the standalone-void handling into isThrowsValid and reject `void` only when it appears as a union member (e.g. Throwable|void). Co-Authored-By: Claude Opus 4.8 --- .../PhpDoc/InvalidThrowsPhpDocValueRule.php | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php index 4835ea56ed8..1b3f2ae26b5 100644 --- a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php +++ b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php @@ -71,11 +71,7 @@ public function processNode(Node $node, Scope $scope): array } $phpDocThrowsType = $resolvedPhpDoc->getThrowsTag()->getType(); - if ($phpDocThrowsType->isVoid()->yes()) { - return []; - } - - if ($this->isThrowsValid($phpDocThrowsType, false)) { + if ($this->isThrowsValid($phpDocThrowsType)) { return []; } @@ -87,32 +83,31 @@ public function processNode(Node $node, Scope $scope): array ]; } - /** - * @param bool $voidAllowed whether `void` is an acceptable type here; only true inside - * the branches of a conditional `@throws` type - */ - private function isThrowsValid(Type $phpDocThrowsType, bool $voidAllowed): bool + private function isThrowsValid(Type $phpDocThrowsType): bool { - if ($voidAllowed && $phpDocThrowsType->isVoid()->yes()) { + // `void` standalone means "does not throw" and is a valid @throws type (it is + // likewise allowed as a branch of a conditional throws type). As a union member + // such as Throwable|void it is rejected in the UnionType handling below. + if ($phpDocThrowsType->isVoid()->yes()) { return true; } // Conditional @throws types like ($x is 0 ? Exception : void) are valid as long // as both branches are valid throws types (a Throwable subtype or void). if ($phpDocThrowsType instanceof ConditionalType) { - return $this->isThrowsValid($phpDocThrowsType->getIf(), true) - && $this->isThrowsValid($phpDocThrowsType->getElse(), true); + return $this->isThrowsValid($phpDocThrowsType->getIf()) + && $this->isThrowsValid($phpDocThrowsType->getElse()); } if ($phpDocThrowsType instanceof ConditionalTypeForParameter) { - return $this->isThrowsValid($phpDocThrowsType->getIf(), true) - && $this->isThrowsValid($phpDocThrowsType->getElse(), true); + return $this->isThrowsValid($phpDocThrowsType->getIf()) + && $this->isThrowsValid($phpDocThrowsType->getElse()); } $throwType = new ObjectType(Throwable::class); if ($phpDocThrowsType instanceof UnionType) { foreach ($phpDocThrowsType->getTypes() as $innerType) { - if (!$this->isThrowsValid($innerType, $voidAllowed)) { + if ($innerType->isVoid()->yes() || !$this->isThrowsValid($innerType)) { return false; } }