From 6938ec56160dec7b270ec1cf86d9361763504411 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:19:59 +0000 Subject: [PATCH 01/12] Fix callable parameter checking for Stringable objects in non-strict mode - Propagated $strictTypes from CallableType::accepts() through to CallableTypeHelper - In non-strict mode, Stringable objects are now accepted where string is expected in callable parameters - This fixes false positives for patterns like uasort($stringableArray, 'strnatcasecmp') - In strict_types mode, the stricter behavior is preserved (e.g. closures with explicit string params) - New regression test in tests/PHPStan/Rules/Functions/data/bug-11619.php Closes https://github.com/phpstan/phpstan/issues/11619 --- src/Type/CallableType.php | 8 ++--- src/Type/CallableTypeHelper.php | 3 +- .../CallToFunctionParametersRuleTest.php | 6 ++++ .../Rules/Functions/data/bug-11619.php | 30 +++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619.php diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 380c87982a..0bb80ddeae 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -139,7 +139,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $type->isAcceptedBy($this, $strictTypes); } - return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + return $this->isSuperTypeOfInternal($type, true, $strictTypes)->toAcceptsResult(); } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult @@ -148,10 +148,10 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return $type->isSubTypeOf($this); } - return $this->isSuperTypeOfInternal($type, false); + return $this->isSuperTypeOfInternal($type, false, true); } - private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny, bool $strictTypes): IsSuperTypeOfResult { $isCallable = new IsSuperTypeOfResult($type->isCallable(), []); if ($isCallable->no()) { @@ -184,7 +184,7 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSup if (!$variant instanceof CallableParametersAcceptor) { return IsSuperTypeOfResult::createNo([]); } - $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny); + $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny, $strictTypes); if ($variantsResult === null) { $variantsResult = $isSuperType; } else { diff --git a/src/Type/CallableTypeHelper.php b/src/Type/CallableTypeHelper.php index 4e99e94cc9..2f5b9f89b2 100644 --- a/src/Type/CallableTypeHelper.php +++ b/src/Type/CallableTypeHelper.php @@ -16,6 +16,7 @@ public static function isParametersAcceptorSuperTypeOf( CallableParametersAcceptor $ours, CallableParametersAcceptor $theirs, bool $treatMixedAsAny, + bool $strictTypes = true, ): IsSuperTypeOfResult { $theirParameters = $theirs->getParameters(); @@ -72,7 +73,7 @@ public static function isParametersAcceptorSuperTypeOf( } if ($treatMixedAsAny) { - $isSuperType = $theirParameter->getType()->accepts($ourParameterType, true); + $isSuperType = $theirParameter->getType()->accepts($ourParameterType, $strictTypes); $isSuperType = new IsSuperTypeOfResult($isSuperType->result, $isSuperType->reasons); } else { $isSuperType = $theirParameter->getType()->isSuperTypeOf($ourParameterType); diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 58faaefd2e..b0101fe261 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2741,6 +2741,12 @@ public function testBug12363(): void $this->analyse([__DIR__ . '/data/bug-12363.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug11619(): void + { + $this->analyse([__DIR__ . '/data/bug-11619.php'], []); + } + public function testBug13247(): void { $this->analyse([__DIR__ . '/data/bug-13247.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619.php b/tests/PHPStan/Rules/Functions/data/bug-11619.php new file mode 100644 index 0000000000..ca66d37b56 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11619.php @@ -0,0 +1,30 @@ += 8.1 + +namespace Bug11619; + +final class Foo implements \Stringable { + + private function __construct(public readonly string $value) { + } + + public static function fromString(string $string): self { + return new self($string); + } + + public function __toString(): string { + return $this->value; + } + +} + +function test(): void +{ + $options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + ]; + + uasort($options, 'strnatcasecmp'); + usort($options, 'strnatcasecmp'); +} From 2913a4c527cc66ade0d118b9793b6ec3d03ffebe Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 26 Mar 2026 12:53:42 +0000 Subject: [PATCH 02/12] Add strict_types test for bug-11619 Stringable callable check Adds a second test file with declare(strict_types=1) to verify that in strict mode, passing strnatcasecmp to uasort/usort with Stringable objects is correctly reported as an error. Co-Authored-By: Claude Opus 4.6 --- .../CallToFunctionParametersRuleTest.php | 15 ++++++++++ .../Rules/Functions/data/bug-11619-strict.php | 30 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619-strict.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index b0101fe261..335a8cc60f 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2747,6 +2747,21 @@ public function testBug11619(): void $this->analyse([__DIR__ . '/data/bug-11619.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug11619Strict(): void + { + $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], [ + [ + 'Parameter #2 $callback of function uasort expects callable(Bug11619Strict\Foo, Bug11619Strict\Foo): int, \'strnatcasecmp\' given.', + 28, + ], + [ + 'Parameter #2 $callback of function usort expects callable(Bug11619Strict\Foo, Bug11619Strict\Foo): int, \'strnatcasecmp\' given.', + 29, + ], + ]); + } + public function testBug13247(): void { $this->analyse([__DIR__ . '/data/bug-13247.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php new file mode 100644 index 0000000000..98108e0395 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php @@ -0,0 +1,30 @@ += 8.1 + +namespace Bug11619Strict; + +final class Foo implements \Stringable { + + private function __construct(public readonly string $value) { + } + + public static function fromString(string $string): self { + return new self($string); + } + + public function __toString(): string { + return $this->value; + } + +} + +function test(): void +{ + $options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + ]; + + uasort($options, 'strnatcasecmp'); + usort($options, 'strnatcasecmp'); +} From d6bd2af191aeedf835aec0a37cf45699fa3de1d6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 26 Mar 2026 13:10:04 +0000 Subject: [PATCH 03/12] Use non-strict type checking for built-in function callables Built-in PHP functions always perform implicit type coercion regardless of declare(strict_types=1), so Stringable objects should be accepted as string parameters even in strict mode. Instead of propagating the calling scope's $strictTypes, detect whether the callable is a built-in function via FunctionCallableVariant::isBuiltin() and use non-strict checking for its parameters. Co-Authored-By: Claude Opus 4.6 --- src/Reflection/Callables/FunctionCallableVariant.php | 10 ++++++++++ src/Type/CallableType.php | 10 ++++++---- .../Functions/CallToFunctionParametersRuleTest.php | 11 +---------- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index 6c48e4b010..b531dd8fe6 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -179,4 +179,14 @@ public function getAsserts(): Assertions return $this->function->getAsserts(); } + public function isBuiltin(): bool + { + $isBuiltin = $this->function->isBuiltin(); + if ($isBuiltin instanceof TrinaryLogic) { + return $isBuiltin->yes(); + } + + return $isBuiltin; + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 0bb80ddeae..271e2f0e7d 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -14,6 +14,7 @@ use PHPStan\PhpDocParser\Printer\Printer; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; +use PHPStan\Reflection\Callables\FunctionCallableVariant; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ClassMemberAccessAnswerer; @@ -139,7 +140,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $type->isAcceptedBy($this, $strictTypes); } - return $this->isSuperTypeOfInternal($type, true, $strictTypes)->toAcceptsResult(); + return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult @@ -148,10 +149,10 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return $type->isSubTypeOf($this); } - return $this->isSuperTypeOfInternal($type, false, true); + return $this->isSuperTypeOfInternal($type, false); } - private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny, bool $strictTypes): IsSuperTypeOfResult + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult { $isCallable = new IsSuperTypeOfResult($type->isCallable(), []); if ($isCallable->no()) { @@ -180,11 +181,12 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny, bool $ $variantsResult = null; foreach ($type->getCallableParametersAcceptors($scope) as $variant) { + $isBuiltinCallable = $variant instanceof FunctionCallableVariant && $variant->isBuiltin(); $variant = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$variant], false); if (!$variant instanceof CallableParametersAcceptor) { return IsSuperTypeOfResult::createNo([]); } - $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny, $strictTypes); + $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny, !$isBuiltinCallable); if ($variantsResult === null) { $variantsResult = $isSuperType; } else { diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 335a8cc60f..adbc283966 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2750,16 +2750,7 @@ public function testBug11619(): void #[RequiresPhp('>= 8.1')] public function testBug11619Strict(): void { - $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], [ - [ - 'Parameter #2 $callback of function uasort expects callable(Bug11619Strict\Foo, Bug11619Strict\Foo): int, \'strnatcasecmp\' given.', - 28, - ], - [ - 'Parameter #2 $callback of function usort expects callable(Bug11619Strict\Foo, Bug11619Strict\Foo): int, \'strnatcasecmp\' given.', - 29, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], []); } public function testBug13247(): void From e1817a5c9c04705b61fc2f20e02e3f9c3d58002f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 26 Mar 2026 13:31:49 +0000 Subject: [PATCH 04/12] Change FunctionCallableVariant::isBuiltin() to return TrinaryLogic Co-Authored-By: Claude Opus 4.6 --- src/Reflection/Callables/FunctionCallableVariant.php | 6 +++--- src/Type/CallableType.php | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index b531dd8fe6..49eb7c066e 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -179,14 +179,14 @@ public function getAsserts(): Assertions return $this->function->getAsserts(); } - public function isBuiltin(): bool + public function isBuiltin(): TrinaryLogic { $isBuiltin = $this->function->isBuiltin(); if ($isBuiltin instanceof TrinaryLogic) { - return $isBuiltin->yes(); + return $isBuiltin; } - return $isBuiltin; + return TrinaryLogic::createFromBoolean($isBuiltin); } } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 271e2f0e7d..0c0c67544a 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -181,7 +181,7 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSup $variantsResult = null; foreach ($type->getCallableParametersAcceptors($scope) as $variant) { - $isBuiltinCallable = $variant instanceof FunctionCallableVariant && $variant->isBuiltin(); + $isBuiltinCallable = $variant instanceof FunctionCallableVariant && $variant->isBuiltin()->yes(); $variant = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$variant], false); if (!$variant instanceof CallableParametersAcceptor) { return IsSuperTypeOfResult::createNo([]); From 250133c3e987c813b0a48b13c939a534ad6c22a5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 26 Mar 2026 14:26:25 +0000 Subject: [PATCH 05/12] Move isBuiltin() method to CallableParametersAcceptor interface Instead of checking `instanceof FunctionCallableVariant` in CallableType, the isBuiltin() method is now part of the CallableParametersAcceptor interface and implemented by all classes. The value is propagated through ExtendedCallableFunctionVariant and ResolvedFunctionVariantWithCallable. Co-Authored-By: Claude Opus 4.6 --- src/Reflection/Callables/CallableParametersAcceptor.php | 2 ++ src/Reflection/ExtendedCallableFunctionVariant.php | 6 ++++++ src/Reflection/GenericParametersAcceptorResolver.php | 1 + src/Reflection/InaccessibleMethod.php | 5 +++++ src/Reflection/ParametersAcceptorSelector.php | 4 ++++ src/Reflection/ResolvedFunctionVariantWithCallable.php | 6 ++++++ src/Reflection/TrivialParametersAcceptor.php | 5 +++++ src/Type/CallableType.php | 8 ++++++-- src/Type/ClosureType.php | 5 +++++ 9 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php index bcef9878ee..bfc3292b0f 100644 --- a/src/Reflection/Callables/CallableParametersAcceptor.php +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -60,4 +60,6 @@ public function mustUseReturnValue(): TrinaryLogic; public function getAsserts(): Assertions; + public function isBuiltin(): TrinaryLogic; + } diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 389893394e..6cb9840004 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -38,6 +38,7 @@ public function __construct( private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, private ?Assertions $assertions = null, + private ?TrinaryLogic $isBuiltinCallable = null, ) { parent::__construct( @@ -92,4 +93,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function isBuiltin(): TrinaryLogic + { + return $this->isBuiltinCallable ?? TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index d9b75bf3e0..6b35ea704b 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -131,6 +131,7 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $originalParametersAcceptor->acceptsNamedArguments(), $originalParametersAcceptor->mustUseReturnValue(), $originalParametersAcceptor->getAsserts(), + $originalParametersAcceptor->isBuiltin(), ); } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 68fce995f8..62672a8a41 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -98,4 +98,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index b4c9b3a382..19f2aff441 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -736,6 +736,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = []; $acceptsNamedArguments = TrinaryLogic::createNo(); $mustUseReturnValue = TrinaryLogic::createMaybe(); + $isBuiltin = TrinaryLogic::createNo(); foreach ($acceptors as $acceptor) { $returnTypes[] = $acceptor->getReturnType(); @@ -753,6 +754,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = array_merge($usedVariables, $acceptor->getUsedVariables()); $acceptsNamedArguments = $acceptsNamedArguments->or($acceptor->acceptsNamedArguments()); $mustUseReturnValue = $mustUseReturnValue->or($acceptor->mustUseReturnValue()); + $isBuiltin = $isBuiltin->or($acceptor->isBuiltin()); } $isVariadic = $isVariadic || $acceptor->isVariadic(); @@ -860,6 +862,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables, $acceptsNamedArguments, $mustUseReturnValue, + isBuiltinCallable: $isBuiltin, ); } @@ -898,6 +901,7 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara $acceptor->acceptsNamedArguments(), $acceptor->mustUseReturnValue(), $acceptor->getAsserts(), + $acceptor->isBuiltin(), ); } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 6f816fa0ac..f32b98faaf 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -30,6 +30,7 @@ public function __construct( private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, private ?Assertions $assertions = null, + private ?TrinaryLogic $isBuiltinCallable = null, ) { } @@ -124,4 +125,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function isBuiltin(): TrinaryLogic + { + return $this->isBuiltinCallable ?? TrinaryLogic::createNo(); + } + } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index 157368d4c0..f8d244e40d 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -108,4 +108,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 0c0c67544a..52ff24c15a 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -14,7 +14,6 @@ use PHPStan\PhpDocParser\Printer\Printer; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\Callables\CallableParametersAcceptor; -use PHPStan\Reflection\Callables\FunctionCallableVariant; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ClassMemberAccessAnswerer; @@ -181,7 +180,7 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSup $variantsResult = null; foreach ($type->getCallableParametersAcceptors($scope) as $variant) { - $isBuiltinCallable = $variant instanceof FunctionCallableVariant && $variant->isBuiltin()->yes(); + $isBuiltinCallable = $variant->isBuiltin()->yes(); $variant = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$variant], false); if (!$variant instanceof CallableParametersAcceptor) { return IsSuperTypeOfResult::createNo([]); @@ -406,6 +405,11 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + public function toNumber(): Type { return new ErrorType(); diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 0a380dff93..46fcf4edb3 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -138,6 +138,11 @@ public function getAsserts(): Assertions return $this->assertions; } + public function isBuiltin(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + /** * @return array */ From 82945c63975e28c9de5ad7110c93de41c0106b96 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 26 Mar 2026 15:39:11 +0100 Subject: [PATCH 06/12] default to maybe --- src/Reflection/ExtendedCallableFunctionVariant.php | 4 ++-- src/Reflection/GenericParametersAcceptorResolver.php | 2 +- src/Reflection/InaccessibleMethod.php | 2 +- src/Reflection/ParametersAcceptorSelector.php | 6 +++--- src/Reflection/ResolvedFunctionVariantWithCallable.php | 4 ++-- src/Reflection/TrivialParametersAcceptor.php | 2 +- src/Type/CallableType.php | 2 +- src/Type/ClosureType.php | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 6cb9840004..322df831ab 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -37,8 +37,8 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, + private TrinaryLogic $isBuiltinCallable, private ?Assertions $assertions = null, - private ?TrinaryLogic $isBuiltinCallable = null, ) { parent::__construct( @@ -95,7 +95,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return $this->isBuiltinCallable ?? TrinaryLogic::createNo(); + return $this->isBuiltinCallable; } } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 6b35ea704b..1187a1b14a 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -130,8 +130,8 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $originalParametersAcceptor->getUsedVariables(), $originalParametersAcceptor->acceptsNamedArguments(), $originalParametersAcceptor->mustUseReturnValue(), - $originalParametersAcceptor->getAsserts(), $originalParametersAcceptor->isBuiltin(), + $originalParametersAcceptor->getAsserts(), ); } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 62672a8a41..5296f39e52 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -100,7 +100,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return TrinaryLogic::createNo(); + return TrinaryLogic::createMaybe(); } } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 19f2aff441..2178b2ca8c 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -736,7 +736,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = []; $acceptsNamedArguments = TrinaryLogic::createNo(); $mustUseReturnValue = TrinaryLogic::createMaybe(); - $isBuiltin = TrinaryLogic::createNo(); + $isBuiltin = TrinaryLogic::createMaybe(); foreach ($acceptors as $acceptor) { $returnTypes[] = $acceptor->getReturnType(); @@ -862,7 +862,7 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables, $acceptsNamedArguments, $mustUseReturnValue, - isBuiltinCallable: $isBuiltin, + $isBuiltin, ); } @@ -900,8 +900,8 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara $acceptor->getUsedVariables(), $acceptor->acceptsNamedArguments(), $acceptor->mustUseReturnValue(), - $acceptor->getAsserts(), $acceptor->isBuiltin(), + $acceptor->getAsserts(), ); } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index f32b98faaf..4a12f23a41 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -29,8 +29,8 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, + private TrinaryLogic $isBuiltinCallable, private ?Assertions $assertions = null, - private ?TrinaryLogic $isBuiltinCallable = null, ) { } @@ -127,7 +127,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return $this->isBuiltinCallable ?? TrinaryLogic::createNo(); + return $this->isBuiltinCallable; } } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index f8d244e40d..e789dc4776 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -110,7 +110,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return TrinaryLogic::createNo(); + return TrinaryLogic::createMaybe(); } } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 52ff24c15a..0b3b293c29 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -407,7 +407,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return TrinaryLogic::createNo(); + return TrinaryLogic::createMaybe(); } public function toNumber(): Type diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 46fcf4edb3..97a9c24dff 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -140,7 +140,7 @@ public function getAsserts(): Assertions public function isBuiltin(): TrinaryLogic { - return TrinaryLogic::createNo(); + return TrinaryLogic::createMaybe(); } /** From cd097f9c46d8d3be9a3dca3f74efe07b763d8a13 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 26 Mar 2026 15:58:17 +0100 Subject: [PATCH 07/12] more tests --- .../CallToFunctionParametersRuleTest.php | 21 ++++++++++++ .../Rules/Functions/data/bug-11619-error.php | 34 +++++++++++++++++++ .../Rules/Functions/data/bug-11619-typed.php | 34 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619-error.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619-typed.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index adbc283966..2d3d2d71fa 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2753,6 +2753,27 @@ public function testBug11619Strict(): void $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug11619Error(): void + { + $this->analyse([__DIR__ . '/data/bug-11619-error.php'], [ + [ + 'Parameter #1 $string1 of function strnatcasecmp expects string, Bug11619Error\Foo given.', + 32, + ], + [ + 'Parameter #2 $string2 of function strnatcasecmp expects string, Bug11619Error\Foo given.', + 32, + ], + ]); + } + + #[RequiresPhp('>= 8.1')] + public function testBug11619Typed(): void + { + $this->analyse([__DIR__ . '/data/bug-11619-typed.php'], []); + } + public function testBug13247(): void { $this->analyse([__DIR__ . '/data/bug-13247.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-error.php b/tests/PHPStan/Rules/Functions/data/bug-11619-error.php new file mode 100644 index 0000000000..19597f851d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11619-error.php @@ -0,0 +1,34 @@ +value; + } + +} + +$options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + Foo::fromString('ccc'), + Foo::fromString('bcc'), +]; + + +uasort($options, fn($a, $b) => strnatcasecmp($a, $b)); + +var_export($options); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php b/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php new file mode 100644 index 0000000000..4340818336 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php @@ -0,0 +1,34 @@ +value; + } + +} + +$options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + Foo::fromString('ccc'), + Foo::fromString('bcc'), +]; + + +uasort($options, fn(string $a, string $b) => strnatcasecmp($a, $b)); + +var_export($options); From ef20977d6c6be0aeadda34448aedf9b62d663584 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 29 Mar 2026 12:17:36 +0200 Subject: [PATCH 08/12] Merge tests --- .../CallToFunctionParametersRuleTest.php | 21 ------------ .../Rules/Functions/data/bug-11619-error.php | 34 ------------------- .../Rules/Functions/data/bug-11619-strict.php | 25 ++++++++++++++ .../Rules/Functions/data/bug-11619-typed.php | 34 ------------------- .../Rules/Functions/data/bug-11619.php | 25 ++++++++++++++ 5 files changed, 50 insertions(+), 89 deletions(-) delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619-error.php delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-11619-typed.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 2d3d2d71fa..adbc283966 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2753,27 +2753,6 @@ public function testBug11619Strict(): void $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], []); } - #[RequiresPhp('>= 8.1')] - public function testBug11619Error(): void - { - $this->analyse([__DIR__ . '/data/bug-11619-error.php'], [ - [ - 'Parameter #1 $string1 of function strnatcasecmp expects string, Bug11619Error\Foo given.', - 32, - ], - [ - 'Parameter #2 $string2 of function strnatcasecmp expects string, Bug11619Error\Foo given.', - 32, - ], - ]); - } - - #[RequiresPhp('>= 8.1')] - public function testBug11619Typed(): void - { - $this->analyse([__DIR__ . '/data/bug-11619-typed.php'], []); - } - public function testBug13247(): void { $this->analyse([__DIR__ . '/data/bug-13247.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-error.php b/tests/PHPStan/Rules/Functions/data/bug-11619-error.php deleted file mode 100644 index 19597f851d..0000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-11619-error.php +++ /dev/null @@ -1,34 +0,0 @@ -value; - } - -} - -$options = [ - Foo::fromString('c'), - Foo::fromString('b'), - Foo::fromString('a'), - Foo::fromString('ccc'), - Foo::fromString('bcc'), -]; - - -uasort($options, fn($a, $b) => strnatcasecmp($a, $b)); - -var_export($options); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php index 98108e0395..28b48eace9 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php @@ -27,4 +27,29 @@ function test(): void uasort($options, 'strnatcasecmp'); usort($options, 'strnatcasecmp'); + + uasort($options, fn($a, $b) => strnatcasecmp($a, $b)); + uasort($options, fn(string $a, string $b) => strnatcasecmp($a, $b)); +} + +/** + * @param array<\Stringable> $a + * @param callable(\Stringable, \Stringable): int $f + */ +function customUsort(array &$a, callable $f): void +{ + for ($i = 1; $i < count($a); $i++) + for ($j = $i; $j > 0 && $f($a[$j-1], $a[$j]) > 0; $j--) + [$a[$j-1], $a[$j]] = [$a[$j], $a[$j-1]]; +} + +function test2(): void +{ + $options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + ]; + + customUsort($options, 'strnatcasecmp'); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php b/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php deleted file mode 100644 index 4340818336..0000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-11619-typed.php +++ /dev/null @@ -1,34 +0,0 @@ -value; - } - -} - -$options = [ - Foo::fromString('c'), - Foo::fromString('b'), - Foo::fromString('a'), - Foo::fromString('ccc'), - Foo::fromString('bcc'), -]; - - -uasort($options, fn(string $a, string $b) => strnatcasecmp($a, $b)); - -var_export($options); diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619.php b/tests/PHPStan/Rules/Functions/data/bug-11619.php index ca66d37b56..ead57245e3 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11619.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11619.php @@ -27,4 +27,29 @@ function test(): void uasort($options, 'strnatcasecmp'); usort($options, 'strnatcasecmp'); + + uasort($options, fn($a, $b) => strnatcasecmp($a, $b)); + uasort($options, fn(string $a, string $b) => strnatcasecmp($a, $b)); +} + +/** + * @param array<\Stringable> $a + * @param callable(\Stringable, \Stringable): int $f + */ +function customUsort(array &$a, callable $f): void +{ + for ($i = 1; $i < count($a); $i++) + for ($j = $i; $j > 0 && $f($a[$j-1], $a[$j]) > 0; $j--) + [$a[$j-1], $a[$j]] = [$a[$j], $a[$j-1]]; +} + +function test2(): void +{ + $options = [ + Foo::fromString('c'), + Foo::fromString('b'), + Foo::fromString('a'), + ]; + + customUsort($options, 'strnatcasecmp'); } From 627c741bb28ccfba043b8523780f7a6401f316ab Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 10:37:21 +0000 Subject: [PATCH 09/12] Check if the calling function is builtin, not the callback The non-error on `uasort($options, 'strnatcasecmp')` in strict mode is because uasort (the caller) is builtin, not because strnatcasecmp (the callback) is builtin. Per PHP internals, when a builtin function calls a callback, it always uses strict_types=0. - Remove isBuiltin() from CallableParametersAcceptor interface and all implementations (was checking the wrong function) - Forward $strictTypes through CallableType/ClosureType accepts() to CallableTypeHelper - In FunctionCallParametersCheck, set strictTypes=false for callable parameters of builtin functions - Update tests: customUsort (user-defined) in strict mode now correctly reports an error, while uasort/usort (builtin) do not Co-Authored-By: Claude Opus 4.6 --- .../Callables/CallableParametersAcceptor.php | 2 -- .../Callables/FunctionCallableVariant.php | 10 ---------- .../ExtendedCallableFunctionVariant.php | 6 ------ .../GenericParametersAcceptorResolver.php | 1 - src/Reflection/InaccessibleMethod.php | 5 ----- src/Reflection/ParametersAcceptorSelector.php | 4 ---- .../ResolvedFunctionVariantWithCallable.php | 6 ------ src/Reflection/TrivialParametersAcceptor.php | 5 ----- src/Rules/FunctionCallParametersCheck.php | 6 +++++- src/Type/CallableType.php | 12 +++--------- src/Type/ClosureType.php | 10 +++------- .../CallToFunctionParametersRuleTest.php | 15 ++++++++++++++- 12 files changed, 25 insertions(+), 57 deletions(-) diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php index bfc3292b0f..bcef9878ee 100644 --- a/src/Reflection/Callables/CallableParametersAcceptor.php +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -60,6 +60,4 @@ public function mustUseReturnValue(): TrinaryLogic; public function getAsserts(): Assertions; - public function isBuiltin(): TrinaryLogic; - } diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index 49eb7c066e..6c48e4b010 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -179,14 +179,4 @@ public function getAsserts(): Assertions return $this->function->getAsserts(); } - public function isBuiltin(): TrinaryLogic - { - $isBuiltin = $this->function->isBuiltin(); - if ($isBuiltin instanceof TrinaryLogic) { - return $isBuiltin; - } - - return TrinaryLogic::createFromBoolean($isBuiltin); - } - } diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 322df831ab..389893394e 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -37,7 +37,6 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, - private TrinaryLogic $isBuiltinCallable, private ?Assertions $assertions = null, ) { @@ -93,9 +92,4 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } - public function isBuiltin(): TrinaryLogic - { - return $this->isBuiltinCallable; - } - } diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 1187a1b14a..d9b75bf3e0 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -130,7 +130,6 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $originalParametersAcceptor->getUsedVariables(), $originalParametersAcceptor->acceptsNamedArguments(), $originalParametersAcceptor->mustUseReturnValue(), - $originalParametersAcceptor->isBuiltin(), $originalParametersAcceptor->getAsserts(), ); } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 5296f39e52..68fce995f8 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -98,9 +98,4 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } - public function isBuiltin(): TrinaryLogic - { - return TrinaryLogic::createMaybe(); - } - } diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 2178b2ca8c..b4c9b3a382 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -736,7 +736,6 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = []; $acceptsNamedArguments = TrinaryLogic::createNo(); $mustUseReturnValue = TrinaryLogic::createMaybe(); - $isBuiltin = TrinaryLogic::createMaybe(); foreach ($acceptors as $acceptor) { $returnTypes[] = $acceptor->getReturnType(); @@ -754,7 +753,6 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables = array_merge($usedVariables, $acceptor->getUsedVariables()); $acceptsNamedArguments = $acceptsNamedArguments->or($acceptor->acceptsNamedArguments()); $mustUseReturnValue = $mustUseReturnValue->or($acceptor->mustUseReturnValue()); - $isBuiltin = $isBuiltin->or($acceptor->isBuiltin()); } $isVariadic = $isVariadic || $acceptor->isVariadic(); @@ -862,7 +860,6 @@ public static function combineAcceptors(array $acceptors): ExtendedParametersAcc $usedVariables, $acceptsNamedArguments, $mustUseReturnValue, - $isBuiltin, ); } @@ -900,7 +897,6 @@ private static function wrapAcceptor(ParametersAcceptor $acceptor): ExtendedPara $acceptor->getUsedVariables(), $acceptor->acceptsNamedArguments(), $acceptor->mustUseReturnValue(), - $acceptor->isBuiltin(), $acceptor->getAsserts(), ); } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 4a12f23a41..6f816fa0ac 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -29,7 +29,6 @@ public function __construct( private array $usedVariables, private TrinaryLogic $acceptsNamedArguments, private TrinaryLogic $mustUseReturnValue, - private TrinaryLogic $isBuiltinCallable, private ?Assertions $assertions = null, ) { @@ -125,9 +124,4 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } - public function isBuiltin(): TrinaryLogic - { - return $this->isBuiltinCallable; - } - } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index e789dc4776..157368d4c0 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -108,9 +108,4 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } - public function isBuiltin(): TrinaryLogic - { - return TrinaryLogic::createMaybe(); - } - } diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 16c0938b06..234848e699 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -363,7 +363,11 @@ public function check( !$parameter->passedByReference()->createsNewVariable() || (!$isBuiltin && !$argumentValueType instanceof ErrorType) ) { - $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $scope->isDeclareStrictTypes()); + $callableStrictTypes = $scope->isDeclareStrictTypes(); + if ($isBuiltin && $parameterType->isCallable()->yes()) { + $callableStrictTypes = false; + } + $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $callableStrictTypes); if (!$accepts->result) { $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $argumentValueType); diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 0b3b293c29..adad99a633 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -139,7 +139,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $type->isAcceptedBy($this, $strictTypes); } - return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + return $this->isSuperTypeOfInternal($type, true, $strictTypes)->toAcceptsResult(); } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult @@ -151,7 +151,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return $this->isSuperTypeOfInternal($type, false); } - private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny, bool $strictTypes = true): IsSuperTypeOfResult { $isCallable = new IsSuperTypeOfResult($type->isCallable(), []); if ($isCallable->no()) { @@ -180,12 +180,11 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSup $variantsResult = null; foreach ($type->getCallableParametersAcceptors($scope) as $variant) { - $isBuiltinCallable = $variant->isBuiltin()->yes(); $variant = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$variant], false); if (!$variant instanceof CallableParametersAcceptor) { return IsSuperTypeOfResult::createNo([]); } - $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny, !$isBuiltinCallable); + $isSuperType = CallableTypeHelper::isParametersAcceptorSuperTypeOf($this, $variant, $treatMixedAsAny, $strictTypes); if ($variantsResult === null) { $variantsResult = $isSuperType; } else { @@ -405,11 +404,6 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } - public function isBuiltin(): TrinaryLogic - { - return TrinaryLogic::createMaybe(); - } - public function toNumber(): Type { return new ErrorType(); diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 97a9c24dff..23ee9dc162 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -138,11 +138,6 @@ public function getAsserts(): Assertions return $this->assertions; } - public function isBuiltin(): TrinaryLogic - { - return TrinaryLogic::createMaybe(); - } - /** * @return array */ @@ -230,7 +225,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $this->objectType->accepts($type, $strictTypes); } - return $this->isSuperTypeOfInternal($type, true)->toAcceptsResult(); + return $this->isSuperTypeOfInternal($type, true, $strictTypes)->toAcceptsResult(); } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult @@ -242,7 +237,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return $this->isSuperTypeOfInternal($type, false); } - private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSuperTypeOfResult + private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny, bool $strictTypes = true): IsSuperTypeOfResult { if ($type instanceof self) { $parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters()); @@ -254,6 +249,7 @@ private function isSuperTypeOfInternal(Type $type, bool $treatMixedAsAny): IsSup $this, $variant, $treatMixedAsAny, + $strictTypes, ); } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index adbc283966..06dc042094 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2750,7 +2750,20 @@ public function testBug11619(): void #[RequiresPhp('>= 8.1')] public function testBug11619Strict(): void { - $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], []); + $this->analyse([__DIR__ . '/data/bug-11619-strict.php'], [ + [ + 'Parameter #1 $string1 of function strnatcasecmp expects string, Bug11619Strict\Foo given.', + 31, + ], + [ + 'Parameter #2 $string2 of function strnatcasecmp expects string, Bug11619Strict\Foo given.', + 31, + ], + [ + 'Parameter #2 $f of function Bug11619Strict\customUsort expects callable(Stringable, Stringable): int, \'strnatcasecmp\' given.', + 54, + ], + ]); } public function testBug13247(): void From fc59c3b01a678bf3cb6dc0573dabcc8053302cd5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 10:50:14 +0000 Subject: [PATCH 10/12] Simplify callable strict types check to one-liner Co-Authored-By: Claude Opus 4.6 --- src/Rules/FunctionCallParametersCheck.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 234848e699..850c33d83a 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -363,11 +363,7 @@ public function check( !$parameter->passedByReference()->createsNewVariable() || (!$isBuiltin && !$argumentValueType instanceof ErrorType) ) { - $callableStrictTypes = $scope->isDeclareStrictTypes(); - if ($isBuiltin && $parameterType->isCallable()->yes()) { - $callableStrictTypes = false; - } - $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $callableStrictTypes); + $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $scope->isDeclareStrictTypes() && !($isBuiltin && $parameterType->isCallable()->yes())); if (!$accepts->result) { $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $argumentValueType); From cefb24b26838587471c2412e77f1ecafda4eb422 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 29 Mar 2026 12:57:42 +0200 Subject: [PATCH 11/12] Use var with comment --- src/Rules/FunctionCallParametersCheck.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 850c33d83a..4308efe56c 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -363,7 +363,10 @@ public function check( !$parameter->passedByReference()->createsNewVariable() || (!$isBuiltin && !$argumentValueType instanceof ErrorType) ) { - $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $scope->isDeclareStrictTypes() && !($isBuiltin && $parameterType->isCallable()->yes())); + // @see https://github.com/php/php-src/issues/21568#issuecomment-4148832540 + $isStrictTypes = $scope->isDeclareStrictTypes() + && (!$isBuiltin || !$parameterType->isCallable()->yes()); + $accepts = $this->ruleLevelHelper->accepts($parameterType, $argumentValueType, $isStrictTypes); if (!$accepts->result) { $verbosityLevel = VerbosityLevel::getRecommendedLevelByType($parameterType, $argumentValueType); From 97ce9f2ac78d5777e0e7e4bf16e67ade7a1e63ae Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 11:02:40 +0000 Subject: [PATCH 12/12] Add tests for userland comparators with uasort/usort When a builtin function like uasort calls a userland callback, it's always treated as strict_types=0, so Stringable objects are accepted for string parameters. Added test cases for both strict and non-strict mode to verify no false positives. Co-Authored-By: Claude Opus 4.6 --- .../Rules/Functions/CallToFunctionParametersRuleTest.php | 2 +- tests/PHPStan/Rules/Functions/data/bug-11619-strict.php | 7 +++++++ tests/PHPStan/Rules/Functions/data/bug-11619.php | 7 +++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 06dc042094..018d63e9f7 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2761,7 +2761,7 @@ public function testBug11619Strict(): void ], [ 'Parameter #2 $f of function Bug11619Strict\customUsort expects callable(Stringable, Stringable): int, \'strnatcasecmp\' given.', - 54, + 58, ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php index 28b48eace9..5c2277ad50 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11619-strict.php @@ -43,6 +43,10 @@ function customUsort(array &$a, callable $f): void [$a[$j-1], $a[$j]] = [$a[$j], $a[$j-1]]; } +function userlandComparator(string $a, string $b): int { + return strnatcasecmp($a, $b); +} + function test2(): void { $options = [ @@ -52,4 +56,7 @@ function test2(): void ]; customUsort($options, 'strnatcasecmp'); + + uasort($options, 'Bug11619Strict\userlandComparator'); + usort($options, 'Bug11619Strict\userlandComparator'); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11619.php b/tests/PHPStan/Rules/Functions/data/bug-11619.php index ead57245e3..984b59f042 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11619.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11619.php @@ -43,6 +43,10 @@ function customUsort(array &$a, callable $f): void [$a[$j-1], $a[$j]] = [$a[$j], $a[$j-1]]; } +function userlandComparator(string $a, string $b): int { + return strnatcasecmp($a, $b); +} + function test2(): void { $options = [ @@ -52,4 +56,7 @@ function test2(): void ]; customUsort($options, 'strnatcasecmp'); + + uasort($options, 'Bug11619\userlandComparator'); + usort($options, 'Bug11619\userlandComparator'); }