From 8848e2b4fa9e5e05cba299080887ac7aeaf615ba Mon Sep 17 00:00:00 2001 From: moellekenl Date: Thu, 7 May 2026 10:02:25 +0200 Subject: [PATCH 1/2] add regression tests for bug #4192 + handle ClosureType and non-common CallableType comparison in unions --- src/Type/TypeCombinator.php | 14 ++++ .../Analyser/NodeScopeResolverTest.php | 1 + .../Rules/Functions/CallCallablesRuleTest.php | 5 ++ .../Rules/Methods/CallMethodsRuleTest.php | 9 +++ tests/PHPStan/Rules/Methods/data/bug-4192.php | 78 +++++++++++++++++++ 5 files changed, 107 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-4192.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 605c71c9da..2acb391ec9 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -507,6 +507,20 @@ private static function isAlreadyNormalized(array $alreadyNormalized, Type $a, T */ private static function compareTypesInUnion(Type $a, Type $b): ?array { + if ( + ( + $a instanceof ClosureType + || ($a instanceof CallableType && !$a->isCommonCallable()) + ) + && ( + $b instanceof ClosureType + || ($b instanceof CallableType && !$b->isCommonCallable()) + ) + && !$a->equals($b) + ) { + return null; + } + if ($a instanceof IntegerRangeType) { $type = $a->tryUnion($b); if ($type !== null) { diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 810af0c7dd..23dce5c33f 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -280,6 +280,7 @@ private static function findTestFiles(): iterable yield __DIR__ . '/../Rules/Variables/data/bug-14124.php'; yield __DIR__ . '/../Rules/Variables/data/bug-14124b.php'; yield __DIR__ . '/../Rules/Arrays/data/bug-14308.php'; + yield __DIR__ . '/../Rules/Methods/data/bug-4192.php'; } /** diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index f226cb3658..a1cc63a8bf 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -198,6 +198,11 @@ public function testNamedArguments(): void ]); } + public function testBug4192(): void + { + $this->analyse([__DIR__ . '/../Methods/data/bug-4192.php'], []); + } + public static function dataBug3566(): array { return [ diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index fcf9d6d449..e79c902ddc 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -1992,6 +1992,15 @@ public function testBug4188(): void $this->analyse([__DIR__ . '/data/bug-4188.php'], []); } + public function testBug4192(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + + $this->analyse([__DIR__ . '/data/bug-4192.php'], []); + } + public function testOnlyRelevantUnableToResolveTemplateType(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-4192.php b/tests/PHPStan/Rules/Methods/data/bug-4192.php new file mode 100644 index 0000000000..6d7979f30b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-4192.php @@ -0,0 +1,78 @@ + */ + private array $array; + + /** + * @param array $array + */ + public function __construct(array $array) + { + $this->array = $array; + } + + /** + * @param \Closure|null $closure + * @phpstan-param null|(\Closure(T,TKey): bool)|(\Closure(T): bool)|(\Closure(TKey): bool) $closure + */ + public function filter($closure = null, int $flag = \ARRAY_FILTER_USE_BOTH): void + { + if (!$closure) { + return; + } + + if ($flag === \ARRAY_FILTER_USE_KEY) { + /** @phpstan-var \Closure(TKey): bool $closure */ + $closure = $closure; + $generator = function () use ($closure): void { + foreach ($this->array as $key => $value) { + assertType('bool', $closure($key)); + } + }; + $generator(); + } elseif ($flag === \ARRAY_FILTER_USE_BOTH) { + /** @phpstan-var \Closure(T,TKey): bool $closure */ + $closure = $closure; + $generator = function () use ($closure): void { + foreach ($this->array as $key => $value) { + assertType('bool', $closure($value, $key)); + } + }; + $generator(); + } else { + /** @phpstan-var \Closure(T): bool $closure */ + $closure = $closure; + $generator = function () use ($closure): void { + foreach ($this->array as $key => $value) { + assertType('bool', $closure($value)); + } + }; + $generator(); + } + } + +} + +(new Arrayy([0 => 1, 1 => 2, 2 => 3, 3 => 4, 7 => 7]))->filter( + static function ($value): bool { + return $value % 2 !== 0; + }, +); + +(new Arrayy([0 => 1, 1 => 2, 2 => 3, 3 => 4, 7 => 7]))->filter( + static function ($key, $value): bool { + return ($value % 2 !== 0) && (($key & 2) !== 0); + }, + \ARRAY_FILTER_USE_BOTH, +); From 1e0f4e4ecba2e2ecd75c87ff9ca4a2ff6e9dea31 Mon Sep 17 00:00:00 2001 From: moellekenl Date: Thu, 7 May 2026 11:02:06 +0200 Subject: [PATCH 2/2] extract helper method for ClosureType and non-common CallableType comparison in unions --- src/Type/TypeCombinator.php | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 2acb391ec9..280b94658a 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -508,14 +508,8 @@ private static function isAlreadyNormalized(array $alreadyNormalized, Type $a, T private static function compareTypesInUnion(Type $a, Type $b): ?array { if ( - ( - $a instanceof ClosureType - || ($a instanceof CallableType && !$a->isCommonCallable()) - ) - && ( - $b instanceof ClosureType - || ($b instanceof CallableType && !$b->isCommonCallable()) - ) + self::isClosureOrNonCommonCallableType($a) + && self::isClosureOrNonCommonCallableType($b) && !$a->equals($b) ) { return null; @@ -669,6 +663,16 @@ private static function compareTypesInUnion(Type $a, Type $b): ?array return null; } + private static function isClosureOrNonCommonCallableType(Type $type): bool + { + if ($type instanceof ClosureType) { + return true; + } + + return get_class($type) === CallableType::class + && !$type->equals(new CallableType()); + } + /** * @return list */