From 61b5d3eac7b883a3208f83d474f7657fe353cf0b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 11 Mar 2026 06:07:17 +0000 Subject: [PATCH 1/8] Propagate @phpstan-assert annotations to first-class callables and string callables - Added Assertions support to ClosureType so first-class callables preserve assertion metadata - Modified InitializerExprTypeResolver::createFirstClassCallable() to pass assertions from function/method reflection to the created ClosureType - Added specifyTypesFromCallableCall() in TypeSpecifier to handle variable function calls ($f($v)) by extracting assertions from ClosureType or resolving string callables to their function reflection - New regression test in tests/PHPStan/Analyser/nsrt/bug-14249.php Closes https://github.com/phpstan/phpstan/issues/14249 --- src/Analyser/TypeSpecifier.php | 77 +++++++++++++++++++ .../InitializerExprTypeResolver.php | 2 + src/Type/ClosureType.php | 12 +++ tests/PHPStan/Analyser/nsrt/bug-14249.php | 31 ++++++++ 4 files changed, 122 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14249.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 335b8dcdd0..15d0ec10e3 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -45,6 +45,7 @@ use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; +use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; @@ -570,6 +571,13 @@ public function specifyTypesInCondition( } } + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + } elseif ($expr instanceof FuncCall && !($expr->name instanceof Name)) { + $specifiedTypes = $this->specifyTypesFromCallableCall($context, $expr, $scope); + if ($specifiedTypes !== null) { + return $specifiedTypes; + } + return $this->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); } elseif ($expr instanceof MethodCall && $expr->name instanceof Node\Identifier) { $methodCalledOnType = $scope->getType($expr->var); @@ -1764,6 +1772,75 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai return $types; } + private function specifyTypesFromCallableCall(TypeSpecifierContext $context, FuncCall $call, Scope $scope): ?SpecifiedTypes + { + if (!$call->name instanceof Expr) { + return null; + } + + $calleeType = $scope->getType($call->name); + $args = $call->getArgs(); + + $assertions = null; + $parametersAcceptor = null; + + // Check for ClosureType with assertions (from first-class callables) + if ($calleeType->isCallable()->yes()) { + foreach ($calleeType->getCallableParametersAcceptors($scope) as $variant) { + if (!$variant instanceof ClosureType) { + continue; + } + + $variantAssertions = $variant->getAsserts(); + if ($variantAssertions->getAll() === []) { + continue; + } + + $assertions = $variantAssertions; + $parametersAcceptor = $variant; + break; + } + } + + // Check for constant string callables (e.g. $f = 'is_positive_int'; $f($v)) + if ($assertions === null) { + foreach ($calleeType->getConstantStrings() as $constantString) { + if ($constantString->getValue() === '') { + continue; + } + $functionName = new Name($constantString->getValue()); + if (!$this->reflectionProvider->hasFunction($functionName, $scope)) { + continue; + } + + $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); + $functionAssertions = $functionReflection->getAsserts(); + if ($functionAssertions->getAll() === []) { + continue; + } + + $assertions = $functionAssertions; + if (count($args) > 0) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); + } + break; + } + } + + if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) { + return null; + } + + $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( + $type, + $parametersAcceptor->getResolvedTemplateTypeMap(), + $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + )); + + return $this->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + } + /** * @return array */ diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 07848715f3..0891f487b7 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -958,6 +958,7 @@ public function createFirstClassCallable( } $parameters = $variant->getParameters(); + $assertions = $function !== null ? $function->getAsserts() : Assertions::createEmpty(); $closureTypes[] = new ClosureType( $parameters, $returnType, @@ -970,6 +971,7 @@ public function createFirstClassCallable( $impurePoints, acceptsNamedArguments: $acceptsNamedArguments, mustUseReturnValue: $mustUseReturnValue, + assertions: $assertions, ); } diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index dac3a2a07f..0a380dff93 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -13,6 +13,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Printer\Printer; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; @@ -84,6 +85,8 @@ class ClosureType implements TypeWithClassName, CallableParametersAcceptor private TrinaryLogic $mustUseReturnValue; + private Assertions $assertions; + /** * @api * @param list|null $parameters @@ -107,6 +110,7 @@ public function __construct( private array $usedVariables = [], ?TrinaryLogic $acceptsNamedArguments = null, ?TrinaryLogic $mustUseReturnValue = null, + ?Assertions $assertions = null, ) { if ($acceptsNamedArguments === null) { @@ -126,6 +130,12 @@ public function __construct( $this->resolvedTemplateTypeMap = $resolvedTemplateTypeMap ?? TemplateTypeMap::createEmpty(); $this->callSiteVarianceMap = $callSiteVarianceMap ?? TemplateTypeVarianceMap::createEmpty(); $this->impurePoints = $impurePoints ?? [new SimpleImpurePoint('functionCall', 'call to an unknown Closure', false)]; + $this->assertions = $assertions ?? Assertions::createEmpty(); + } + + public function getAsserts(): Assertions + { + return $this->assertions; } /** @@ -664,6 +674,7 @@ public function traverse(callable $cb): Type $this->usedVariables, $this->acceptsNamedArguments, $this->mustUseReturnValue, + $this->assertions, ); } @@ -715,6 +726,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type $this->usedVariables, $this->acceptsNamedArguments, $this->mustUseReturnValue, + $this->assertions, ); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14249.php b/tests/PHPStan/Analyser/nsrt/bug-14249.php new file mode 100644 index 0000000000..96733c042b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14249.php @@ -0,0 +1,31 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14249; + +use function PHPStan\Testing\assertType; + +/** + * @phpstan-assert-if-true positive-int $value + */ +function is_positive_int(mixed $value): bool { + return is_int($value) && $value > 0; +} + +function f(mixed $v): void { + $f1 = is_positive_int(...); + $f2 = 'Bug14249\is_positive_int'; + + if (is_positive_int($v)) { + assertType('int<1, max>', $v); + } + + if ($f1($v)) { + assertType('int<1, max>', $v); + } + + if ($f2($v)) { + assertType('int<1, max>', $v); + } +} From ed365ba1537ea419bd5452e70c6a8e66d4b898a8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 12 Mar 2026 10:15:12 +0000 Subject: [PATCH 2/8] Add getAsserts() to CallableParametersAcceptor interface Move assertion retrieval from ClosureType-specific code to the CallableParametersAcceptor interface, so TypeSpecifier can use the interface method instead of instanceof ClosureType checks. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 7 +------ src/Reflection/Callables/CallableParametersAcceptor.php | 3 +++ src/Reflection/Callables/FunctionCallableVariant.php | 6 ++++++ src/Reflection/ExtendedCallableFunctionVariant.php | 7 +++++++ src/Reflection/GenericParametersAcceptorResolver.php | 1 + src/Reflection/InaccessibleMethod.php | 6 ++++++ src/Reflection/ParametersAcceptorSelector.php | 1 + src/Reflection/ResolvedFunctionVariantWithCallable.php | 6 ++++++ src/Reflection/TrivialParametersAcceptor.php | 5 +++++ src/Type/CallableType.php | 6 ++++++ 10 files changed, 42 insertions(+), 6 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 15d0ec10e3..a5f160ae3b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -45,7 +45,6 @@ use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; -use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalTypeForParameter; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; @@ -1784,13 +1783,9 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun $assertions = null; $parametersAcceptor = null; - // Check for ClosureType with assertions (from first-class callables) + // Check for CallableParametersAcceptor with assertions (from first-class callables) if ($calleeType->isCallable()->yes()) { foreach ($calleeType->getCallableParametersAcceptors($scope) as $variant) { - if (!$variant instanceof ClosureType) { - continue; - } - $variantAssertions = $variant->getAsserts(); if ($variantAssertions->getAll() === []) { continue; diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php index 30c0254cd1..bcef9878ee 100644 --- a/src/Reflection/Callables/CallableParametersAcceptor.php +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection\Callables; use PHPStan\Node\InvalidateExprNode; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; @@ -57,4 +58,6 @@ public function getUsedVariables(): array; */ public function mustUseReturnValue(): TrinaryLogic; + public function getAsserts(): Assertions; + } diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index ca45e92e3c..6c48e4b010 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection\Callables; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; @@ -173,4 +174,9 @@ public function mustUseReturnValue(): TrinaryLogic return $this->function->mustUseReturnValue(); } + public function getAsserts(): Assertions + { + return $this->function->getAsserts(); + } + } diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 4cac48123d..9acd7724cd 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -6,6 +6,7 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; +use PHPStan\Reflection\Assertions; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -37,6 +38,7 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, + private ?Assertions $assertions = null, ) { parent::__construct( @@ -86,4 +88,9 @@ public function mustUseReturnValue(): TrinaryLogic return $this->mustUseReturnValue; } + public function getAsserts(): Assertions + { + return $this->assertions ?? Assertions::createEmpty(); + } + } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 27d8cfc11c..d9b75bf3e0 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -130,6 +130,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $originalParametersAcceptor->getUsedVariables(), $originalParametersAcceptor->acceptsNamedArguments(), $originalParametersAcceptor->mustUseReturnValue(), + $originalParametersAcceptor->getAsserts(), ); } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index f340cf3127..438d931b7d 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -4,6 +4,7 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; +use PHPStan\Reflection\Assertions; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -93,4 +94,9 @@ public function mustUseReturnValue(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 427c5bee51..b4c9b3a382 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -897,6 +897,7 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara $acceptor->getUsedVariables(), $acceptor->acceptsNamedArguments(), $acceptor->mustUseReturnValue(), + $acceptor->getAsserts(), ); } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 17574ec30e..6f816fa0ac 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -29,6 +29,7 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, + private ?Assertions $assertions = null, ) { } @@ -118,4 +119,9 @@ public function mustUseReturnValue(): TrinaryLogic return $this->mustUseReturnValue; } + public function getAsserts(): Assertions + { + return $this->assertions ?? Assertions::createEmpty(); + } + } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index 2863ff83cd..157368d4c0 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -103,4 +103,9 @@ public function mustUseReturnValue(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 9d9cfb65e4..380c87982a 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -12,6 +12,7 @@ use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\PhpDocParser\Printer\Printer; +use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; @@ -398,6 +399,11 @@ public function mustUseReturnValue(): TrinaryLogic return TrinaryLogic::createMaybe(); } + public function getAsserts(): Assertions + { + return Assertions::createEmpty(); + } + public function toNumber(): Type { return new ErrorType(); From 512705e9fdaa6ad41b3d829812af098fa4841d7c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 11:31:38 +0100 Subject: [PATCH 3/8] simplify --- src/Analyser/TypeSpecifier.php | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index a5f160ae3b..8010df3e23 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -26,6 +26,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; @@ -1783,7 +1784,6 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun $assertions = null; $parametersAcceptor = null; - // Check for CallableParametersAcceptor with assertions (from first-class callables) if ($calleeType->isCallable()->yes()) { foreach ($calleeType->getCallableParametersAcceptors($scope) as $variant) { $variantAssertions = $variant->getAsserts(); @@ -1797,31 +1797,6 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun } } - // Check for constant string callables (e.g. $f = 'is_positive_int'; $f($v)) - if ($assertions === null) { - foreach ($calleeType->getConstantStrings() as $constantString) { - if ($constantString->getValue() === '') { - continue; - } - $functionName = new Name($constantString->getValue()); - if (!$this->reflectionProvider->hasFunction($functionName, $scope)) { - continue; - } - - $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); - $functionAssertions = $functionReflection->getAsserts(); - if ($functionAssertions->getAll() === []) { - continue; - } - - $assertions = $functionAssertions; - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); - } - break; - } - } - if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) { return null; } From dd8c05b3ce95a32589a24c6f490f60f8f9ec82d7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 11:38:32 +0100 Subject: [PATCH 4/8] cs --- src/Analyser/TypeSpecifier.php | 2 -- src/Reflection/ExtendedCallableFunctionVariant.php | 1 - src/Reflection/InaccessibleMethod.php | 1 - 3 files changed, 4 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 8010df3e23..e13508efbd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -26,7 +26,6 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\Assertions; -use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; @@ -1779,7 +1778,6 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun } $calleeType = $scope->getType($call->name); - $args = $call->getArgs(); $assertions = null; $parametersAcceptor = null; diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 9acd7724cd..389893394e 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -6,7 +6,6 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; -use PHPStan\Reflection\Assertions; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 438d931b7d..68fce995f8 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -4,7 +4,6 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; -use PHPStan\Reflection\Assertions; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; From 746788014f97f73490bf373ce4d62046e18e2e8b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 11:57:34 +0100 Subject: [PATCH 5/8] added failling tests --- tests/PHPStan/Analyser/nsrt/bug-14249.php | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14249.php b/tests/PHPStan/Analyser/nsrt/bug-14249.php index 96733c042b..48c04d42e3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14249.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14249.php @@ -29,3 +29,44 @@ function f(mixed $v): void { assertType('int<1, max>', $v); } } + + +/** + * @template T of bool + * @param T $if + * @phpstan-assert (T is true ? true : false) $condition + */ +function assertIfTemplated(mixed $condition, bool $if) +{ +} + +function doTemplated(): void { + $f1 = assertIfTemplated(...); + $f2 = 'Bug14249\assertIfTemplated'; + + $v = getMixed(); + assertIfTemplated($v, true); + assertType('true', $v); + + $v = getMixed(); + $f1($v, true); + assertType('true', $v); + + $v = getMixed(); + $f2($v, true); + assertType('true', $v); + + $v = getMixed(); + assertIfTemplated($v, false); + assertType('false', $v); + + $v = getMixed(); + $f1($v, false); + assertType('false', $v); + + $v = getMixed(); + $f2($v, false); + assertType('false', $v); +} + +function getMixed(): mixed {} From 917a9125af164152ed89159aa2564d2848b6c0dd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 12 Mar 2026 11:17:41 +0000 Subject: [PATCH 6/8] Fix template type resolving for first-class callable assertions Use ParametersAcceptorSelector::selectFromArgs() to resolve template types from actual call arguments before applying assertions, matching the pattern used for direct function calls. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index e13508efbd..1bcb8da8af 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -26,6 +26,7 @@ use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; @@ -1783,16 +1784,25 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun $parametersAcceptor = null; if ($calleeType->isCallable()->yes()) { - foreach ($calleeType->getCallableParametersAcceptors($scope) as $variant) { + $variants = $calleeType->getCallableParametersAcceptors($scope); + $hasAssertions = false; + foreach ($variants as $variant) { $variantAssertions = $variant->getAsserts(); if ($variantAssertions->getAll() === []) { continue; } - $assertions = $variantAssertions; - $parametersAcceptor = $variant; + $hasAssertions = true; break; } + + if ($hasAssertions) { + $resolvedAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $variants); + $parametersAcceptor = $resolvedAcceptor; + if ($resolvedAcceptor instanceof CallableParametersAcceptor) { + $assertions = $resolvedAcceptor->getAsserts(); + } + } } if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) { From 32abf9f3e0df215514423fbd9bfcb96f6ba7690d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 12:24:22 +0100 Subject: [PATCH 7/8] simplify --- src/Analyser/TypeSpecifier.php | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 1bcb8da8af..0aafa3956b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1782,26 +1782,11 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun $assertions = null; $parametersAcceptor = null; - if ($calleeType->isCallable()->yes()) { $variants = $calleeType->getCallableParametersAcceptors($scope); - $hasAssertions = false; - foreach ($variants as $variant) { - $variantAssertions = $variant->getAsserts(); - if ($variantAssertions->getAll() === []) { - continue; - } - - $hasAssertions = true; - break; - } - - if ($hasAssertions) { - $resolvedAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $variants); - $parametersAcceptor = $resolvedAcceptor; - if ($resolvedAcceptor instanceof CallableParametersAcceptor) { - $assertions = $resolvedAcceptor->getAsserts(); - } + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $variants); + if ($parametersAcceptor instanceof CallableParametersAcceptor) { + $assertions = $parametersAcceptor->getAsserts(); } } From ff0e9818f7748db8f02ed66fe65921d9b0e93505 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 14:01:53 +0100 Subject: [PATCH 8/8] kill mutant --- tests/PHPStan/Analyser/nsrt/bug-14249.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14249.php b/tests/PHPStan/Analyser/nsrt/bug-14249.php index 48c04d42e3..f3408725b6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14249.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14249.php @@ -70,3 +70,14 @@ function doTemplated(): void { } function getMixed(): mixed {} + +function maybeCallable() { + $f2 = 'Bug14249\assertIfTemplated'; + if (rand(0,1)) { + $f2 = 'notCallable'; + } + + $v = getMixed(); + $f2($v, false); + assertType('mixed', $v); +}