From 9ea4f072e275fc8ffd7adb552ac141b296512382 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:59:45 +0000 Subject: [PATCH] `ArrayType::isCallable()`: ask `StringType::isSuperTypeOf()` about the item type instead of only `isString()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * `ArrayType::isCallable()` returned `No` whenever the item type answered `isString()` with `No`. `StrictMixedType` answers `No` to every `isX()` query even though it is a supertype of everything, so `array` was declared non-callable. * At levels 9/10 `RuleLevelHelper::transformCommonType()` / `transformAcceptedType()` rewrite nested `mixed` to `StrictMixedType` and `IntersectionType::traverse()` re-intersects the members, so `callable&array`, `callable&list` and `callable-array` collapsed to `never` — and `never` accepts everything, which silenced the whole acceptance check. * `isCallable()` now falls back to `(new StringType())->isSuperTypeOf($this->itemType)->maybe()` when `isString()` says `No`, so supertypes-of-string stay in play while `array`, `array`, `array` and `array` keep answering `No` exactly as before. * Fixes the reported argument position plus the analogous positions that go through the same `RuleLevelHelper` path and were equally silent: return statements, property assignments, method/static-method/constructor/`__invoke`/callable-value arguments, and arrays narrowed by `is_callable()`. * Probed the sibling constructs and found them already correct: `callable&string` / `callable-string`, `callable&object` / `callable-object`, `callable&iterable`, `ConstantArrayType::isCallable()` for `array{mixed, mixed}`. Comparing every zero-argument `TrinaryLogic` method of `Type` for `array` / `list` / `non-empty-array` / `iterable` / `array{mixed, mixed}` / `ArrayObject` against their strict-mixed counterparts showed `isCallable()` as the only divergence. --- src/Type/ArrayType.php | 13 ++- .../CallToFunctionParametersRuleTest.php | 63 +++++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 12 +++ .../Rules/Functions/data/bug-13114-return.php | 23 ++++++ .../Rules/Functions/data/bug-13114.php | 79 +++++++++++++++++++ .../TypesAssignedToPropertiesRuleTest.php | 21 +++++ .../Rules/Properties/data/bug-13114.php | 31 ++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 18 +++++ 8 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13114-return.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13114.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-13114.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index c54f9668437..1eb12c177a2 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -808,7 +808,18 @@ private static function foldConstantStringKeyCase(ConstantStringType $type, ?int public function isCallable(): TrinaryLogic { - return TrinaryLogic::createMaybe()->and($this->itemType->isString()); + if (!$this->itemType->isString()->no()) { + return TrinaryLogic::createMaybe(); + } + + // StrictMixedType denies isString() even though it is a supertype of + // string, so a value of that item type can still be the method name + // of a callable array. + if ((new StringType())->isSuperTypeOf($this->itemType)->maybe()) { + return TrinaryLogic::createMaybe(); + } + + return TrinaryLogic::createNo(); } public function getCallableParametersAcceptors(ClassMemberAccessAnswerer $scope): array diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 44bc0a56d08..df3def0c39d 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -3029,4 +3029,67 @@ public function testBug11494(): void ]); } + public static function dataBug13114(): array + { + return [ + [false, false], + [true, false], + [true, true], + ]; + } + + #[DataProvider('dataBug13114')] + public function testBug13114(bool $checkExplicitMixed, bool $checkImplicitMixed): void + { + $this->checkExplicitMixed = $checkExplicitMixed; + $this->checkImplicitMixed = $checkImplicitMixed; + $this->analyse([__DIR__ . '/data/bug-13114.php'], [ + [ + 'Parameter #1 $arg of function Bug13114\\foo expects list{class-string|object, string}&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 37, + ], + [ + 'Parameter #1 $arg of function Bug13114\\bar expects non-empty-list&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 42, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, array{Bug13114\\C, \'h\'} given.', + 47, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, array{1, 2} given.', + 48, + ], + [ + 'Parameter #1 $arg of function Bug13114\\baz expects non-empty-list&callable(): mixed, 42 given.', + 49, + "\u{2022} 42 is not a list.\n\u{2022} 42 is empty.", + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableString expects callable-string, \'nonexistentFunction\' given.', + 52, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableString expects callable-string, 42 given.', + 53, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableObject expects callable-object, Bug13114\\C given.', + 55, + ], + [ + 'Parameter #1 $arg of function Bug13114\\callableObject expects callable-object, 42 given.', + 57, + ], + [ + 'Parameter #1 $i of function Bug13114\\takesInt expects int, callable&list given.', + 67, + ], + [ + 'Parameter #1 $i of function Bug13114\\takesInt expects int, callable&list given.', + 77, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 21c047c0612..88a4b5562cf 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -481,4 +481,16 @@ public function testBug13190(): void ]); } + public function testBug13114(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13114-return.php'], [ + [ + 'Function Bug13114Return\\returnsCallableArray() should return non-empty-list&callable(): mixed but returns array{Bug13114Return\\C, \'h\'}.', + 14, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13114-return.php b/tests/PHPStan/Rules/Functions/data/bug-13114-return.php new file mode 100644 index 00000000000..8dd9898c130 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13114-return.php @@ -0,0 +1,23 @@ + + */ +function returnsCallableArray(): array +{ + return [new C, 'h']; +} + +/** + * @return callable&array + */ +function returnsValidCallableArray(): array +{ + return [C::class, 'f']; +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-13114.php b/tests/PHPStan/Rules/Functions/data/bug-13114.php new file mode 100644 index 00000000000..7426d7a112e --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13114.php @@ -0,0 +1,79 @@ + $arg + */ +function bar(array $arg): void {} + +/** + * @param callable-array $arg + */ +function baz($arg): void {} + +/** + * @param callable&string $arg + */ +function callableString($arg): void {} + +/** + * @param callable&object $arg + */ +function callableObject($arg): void {} + +foo([new C, 'f']); +foo([new C, 'g']); +foo([new C, 'h']); // error +foo([C::class, 'f']); + +bar([new C, 'f']); +bar([new C, 'g']); +bar([new C, 'h']); // error +bar([C::class, 'f']); + +baz([new C, 'f']); +baz([new C, 'g']); +baz([new C, 'h']); // error +baz([1, 2]); // error +baz(42); // error + +callableString('strtoupper'); +callableString('nonexistentFunction'); // error +callableString(42); // error + +callableObject(new C()); // error +callableObject(function (): void {}); +callableObject(42); // error + +function takesInt(int $i): void {} + +/** + * @param array $a + */ +function narrowedByIsCallable(array $a): void +{ + if (is_callable($a)) { + takesInt($a); // error + } +} + +/** + * @param mixed $m + */ +function narrowedFromMixed($m): void +{ + if (is_callable($m) && is_array($m)) { + takesInt($m); // error + } +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 4251d2d2d3b..749059f548f 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1067,4 +1067,25 @@ public function testBug10749(): void $this->analyse([__DIR__ . '/data/bug-10749.php'], []); } + public function testBug13114(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13114.php'], [ + [ + 'Property Bug13114Property\\Holder::$implicit (non-empty-list&callable(): mixed) does not accept array{Bug13114Property\\C, \'h\'}.', + 20, + ], + [ + 'Property Bug13114Property\\Holder::$explicit (non-empty-list&callable(): mixed) does not accept array{Bug13114Property\\C, \'h\'}.', + 21, + ], + [ + 'Property Bug13114Property\\Holder::$implicit (non-empty-list&callable(): mixed) does not accept 42.', + 22, + "\u{2022} 42 is not a list.\n\u{2022} 42 is empty.", + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13114.php b/tests/PHPStan/Rules/Properties/data/bug-13114.php new file mode 100644 index 00000000000..e07e474dd08 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13114.php @@ -0,0 +1,31 @@ + */ + private $explicit; + + public function doFoo(): void + { + $this->implicit = [new C, 'h']; + $this->explicit = [new C, 'h']; + $this->implicit = 42; + } + + public function doBar(): void + { + $this->implicit = [C::class, 'f']; + $this->explicit = [C::class, 'f']; + } + +} diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index efd6f985883..b4aec71755c 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5760,6 +5760,24 @@ public static function dataIntersect(): iterable UnionType::class, "1|'0'", ]; + + yield [ + [ + new ArrayType(new MixedType(), new StrictMixedType()), + new CallableType(), + ], + IntersectionType::class, + 'non-empty-list&callable(): mixed', + ]; + + yield [ + [ + new ArrayType(new MixedType(), new IntegerType()), + new CallableType(), + ], + NeverType::class, + '*NEVER*=implicit', + ]; } /**