From 6148fb6895147ee5dbf500924a99662c3c761aef Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:16:27 +0000 Subject: [PATCH 1/6] Report missing iterable value type for array part of `array&callable` intersections in `MissingTypehintCheck` - In `MissingTypehintCheck::getIterableTypesWithMissingValueTypehint()`, the special-case block for `array&callable` intersection types was skipping the array part entirely, never checking it for missing value types - Added a check for the array inner type's iterable value type before skipping it, so `callable-array`, `callable&array`, and `array&callable(...)` now correctly report `missingType.iterableValue` when the array has no value type - Updated test expectations for `doIntersection()` (gains a third error for the outer array) and `doFoo()` (now reports missing iterable value type) - Added new test case `doBaz()` with explicit `callable&array` parameter - All consumers of `MissingTypehintCheck` (method/function parameters, return types, properties, constants, @var tags, @phpstan-assert) are automatically fixed since they share the same check --- src/Rules/MissingTypehintCheck.php | 4 ++++ ...MissingMethodParameterTypehintRuleTest.php | 19 +++++++++++++++++++ .../PHPStan/Rules/Methods/data/bug-14549.php | 7 +++++++ 3 files changed, 30 insertions(+) diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index b43d51b6dd..65108fc219 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -81,6 +81,10 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array $nonArrayInner = []; foreach ($type->getTypes() as $innerType) { if ($innerType->isArray()->yes()) { + $iterableValue = $innerType->getIterableValueType(); + if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { + $iterablesWithMissingValueTypehint[] = $innerType; + } continue; } $nonArrayInner[] = $innerType; diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index 9f2056500d..ec1287fe05 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -151,6 +151,11 @@ public function testBug7662(): void public function testBug14549(): void { $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + [ + 'Method Bug14549\Foo::doFoo() has parameter $task with no value type specified in iterable type array.', + 12, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], [ 'Method Bug14549\Foo::doFoo() has parameter $task with no signature specified for callable.', 12, @@ -165,6 +170,20 @@ public function testBug14549(): void 46, MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, ], + [ + 'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.', + 46, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method Bug14549\Foo::doBaz() has parameter $task with no value type specified in iterable type array.', + 53, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method Bug14549\Foo::doBaz() has parameter $task with no signature specified for callable.', + 53, + ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php index c713ac3092..671522645f 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14549.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -47,6 +47,13 @@ public function doIntersection($array): void { } + /** + * @param callable&array $task + */ + public function doBaz(array $task): void + { + } + } From 3aa818de30302ed33180dd24b7598c8478bddc73 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:20:20 +0000 Subject: [PATCH 2/6] Revert missing iterable value type report for array&callable in MissingTypehintCheck The missing typehint suppression for array&callable is intentional because PHPStan can infer the value type (object|non-falsy-string). Reverts the MissingTypehintCheck changes and their test expectations. Co-Authored-By: Claude Opus 4.6 --- src/Rules/MissingTypehintCheck.php | 4 ---- ...MissingMethodParameterTypehintRuleTest.php | 19 ++++++------------- 2 files changed, 6 insertions(+), 17 deletions(-) diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 65108fc219..b43d51b6dd 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -81,10 +81,6 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array $nonArrayInner = []; foreach ($type->getTypes() as $innerType) { if ($innerType->isArray()->yes()) { - $iterableValue = $innerType->getIterableValueType(); - if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { - $iterablesWithMissingValueTypehint[] = $innerType; - } continue; } $nonArrayInner[] = $innerType; diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index ec1287fe05..04b362f4f1 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -151,11 +151,6 @@ public function testBug7662(): void public function testBug14549(): void { $this->analyse([__DIR__ . '/data/bug-14549.php'], [ - [ - 'Method Bug14549\Foo::doFoo() has parameter $task with no value type specified in iterable type array.', - 12, - MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, - ], [ 'Method Bug14549\Foo::doFoo() has parameter $task with no signature specified for callable.', 12, @@ -171,18 +166,16 @@ public function testBug14549(): void MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, ], [ - 'Method Bug14549\Foo::doIntersection() has parameter $array with no value type specified in iterable type array.', - 46, - MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + 'Method Bug14549\Foo::doBaz() has parameter $task with no signature specified for callable.', + 53, ], [ - 'Method Bug14549\Foo::doBaz() has parameter $task with no value type specified in iterable type array.', - 53, - MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + 'Method Bug14549\Foo::doCallWithCallableArray() has parameter $task with no signature specified for callable.', + 65, ], [ - 'Method Bug14549\Foo::doBaz() has parameter $task with no signature specified for callable.', - 53, + 'Method Bug14549\Foo::doCallWithCallableAndArray() has parameter $task with no signature specified for callable.', + 73, ], ]); } From a9501e98ec05fd83d5a489d7c455d5e7b5fcb0db Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:20:28 +0000 Subject: [PATCH 3/6] Use narrowed iterable types in IntersectionType::isAcceptedBy() for array&callable When an array&callable intersection is checked for acceptance, the raw ArrayType(mixed, mixed) component was used, causing array to incorrectly accept array&callable. Now the array component uses the narrowed key/value types (int<0,1>, object|non-falsy-string) so the acceptance check correctly rejects incompatible array types. Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 17 ++++++++++++++- .../Rules/Methods/CallMethodsRuleTest.php | 17 +++++++++++++++ .../PHPStan/Rules/Methods/data/bug-14549.php | 21 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index f5dceab600..ef1fb46cd9 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -299,8 +299,23 @@ public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult { + $types = $this->types; + if ($this->isCallable()->yes() && $this->isArray()->yes()) { + $narrowedKeyType = $this->getIterableKeyType(); + $narrowedValueType = $this->getIterableValueType(); + $types = array_map(static function (Type $innerType) use ($narrowedKeyType, $narrowedValueType): Type { + if (!$innerType->isArray()->yes()) { + return $innerType; + } + if (!$innerType->getIterableValueType() instanceof MixedType) { + return $innerType; + } + return new ArrayType($narrowedKeyType, $narrowedValueType); + }, $types); + } + $result = AcceptsResult::lazyMaxMin( - $this->types, + $types, static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), ); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 9a2a8e1a9f..f7a3f53838 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4017,4 +4017,21 @@ public function testBug13272(): void $this->analyse([__DIR__ . '/data/bug-13272.php'], []); } + public function testBug14549(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + [ + 'Parameter #1 $param of method Bug14549\Foo::call() expects array, array&callable given.', + 67, + ], + [ + 'Parameter #1 $param of method Bug14549\Foo::call() expects array, array&callable given.', + 75, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php index 671522645f..e15ae08d1a 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14549.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -54,6 +54,27 @@ public function doBaz(array $task): void { } + /** @param array $param */ + public function call(array $param): void + { + } + + /** + * @param callable-array $task + */ + public function doCallWithCallableArray(array $task): void + { + $this->call($task); + } + + /** + * @param callable&array $task + */ + public function doCallWithCallableAndArray(array $task): void + { + $this->call($task); + } + } From 9b9c1281395552294c3a43b0e2e197015de08fd1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:54:25 +0000 Subject: [PATCH 4/6] Move CallMethodsRule tests for array&callable to separate file and add constant array test cases Test callable&array passed to methods expecting constant array types: - array{string, string} => Error (object at index 0 is not string) - array{object|string, string} => No error (matches callable-array shape) - array{object|string, string, string} => Error (callable-array has 2 elements, not 3) Co-Authored-By: Claude Opus 4.6 --- .../Rules/Methods/CallMethodsRuleTest.php | 26 ++++++++-- ...MissingMethodParameterTypehintRuleTest.php | 8 --- .../Rules/Methods/data/bug-14549-bis.php | 50 +++++++++++++++++++ .../PHPStan/Rules/Methods/data/bug-14549.php | 21 -------- 4 files changed, 71 insertions(+), 34 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14549-bis.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index f7a3f53838..bde8ef5a3b 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4022,14 +4022,30 @@ public function testBug14549(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-14549.php'], [ + $this->analyse([__DIR__ . '/data/bug-14549-bis.php'], [ [ - 'Parameter #1 $param of method Bug14549\Foo::call() expects array, array&callable given.', - 67, + 'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array, array&callable given.', + 33, ], [ - 'Parameter #1 $param of method Bug14549\Foo::call() expects array, array&callable given.', - 75, + 'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.', + 34, + ], + [ + 'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.', + 36, + ], + [ + 'Parameter #1 $param of method Bug14549Bis\Foo::callArrayInt() expects array, array&callable given.', + 44, + ], + [ + 'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayStringString() expects array{string, string}, array&callable(): mixed given.', + 45, + ], + [ + 'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.', + 47, ], ]); } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index 04b362f4f1..ad997bcd06 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -169,14 +169,6 @@ public function testBug14549(): void 'Method Bug14549\Foo::doBaz() has parameter $task with no signature specified for callable.', 53, ], - [ - 'Method Bug14549\Foo::doCallWithCallableArray() has parameter $task with no signature specified for callable.', - 65, - ], - [ - 'Method Bug14549\Foo::doCallWithCallableAndArray() has parameter $task with no signature specified for callable.', - 73, - ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php b/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php new file mode 100644 index 0000000000..01ab0f6538 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php @@ -0,0 +1,50 @@ + $param */ + public function callArrayInt(array $param): void + { + } + + /** @param array{string, string} $param */ + public function callConstantArrayStringString(array $param): void + { + } + + /** @param array{object|string, string} $param */ + public function callConstantArrayObjectOrStringString(array $param): void + { + } + + /** @param array{object|string, string, string} $param */ + public function callConstantArrayObjectOrStringStringString(array $param): void + { + } + + /** + * @param callable-array $task + */ + public function doCallWithCallableArray(array $task): void + { + $this->callArrayInt($task); + $this->callConstantArrayStringString($task); + $this->callConstantArrayObjectOrStringString($task); + $this->callConstantArrayObjectOrStringStringString($task); + } + + /** + * @param callable&array $task + */ + public function doCallWithCallableAndArray(array $task): void + { + $this->callArrayInt($task); + $this->callConstantArrayStringString($task); + $this->callConstantArrayObjectOrStringString($task); + $this->callConstantArrayObjectOrStringStringString($task); + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php index e15ae08d1a..671522645f 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14549.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -54,27 +54,6 @@ public function doBaz(array $task): void { } - /** @param array $param */ - public function call(array $param): void - { - } - - /** - * @param callable-array $task - */ - public function doCallWithCallableArray(array $task): void - { - $this->call($task); - } - - /** - * @param callable&array $task - */ - public function doCallWithCallableAndArray(array $task): void - { - $this->call($task); - } - } From 9d0a8952a52d5ff0ff7eb39a9c844e458f6330b3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 11:09:06 +0000 Subject: [PATCH 5/6] Use isSuperTypeOf as guard in IntersectionType::isAcceptedBy() instead of special-casing callable&array Replace the callable&array-specific narrowing logic with a general approach: after the standard lazyMaxMin check, if it returns Yes, verify with isSuperTypeOf that the accepting type is actually a supertype of the full intersection. This catches cases where MixedType's accepts-everything behavior causes false acceptances (e.g. array falsely accepting array&hasOffsetValue or array&callable intersections). Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 24 +++++++------------ .../Rules/Methods/CallMethodsRuleTest.php | 4 ++++ .../Rules/Methods/data/bug-14549-bis.php | 13 ++++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index ef1fb46cd9..a300794a53 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -299,26 +299,18 @@ public function isSubTypeOf(Type $otherType): IsSuperTypeOfResult public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsResult { - $types = $this->types; - if ($this->isCallable()->yes() && $this->isArray()->yes()) { - $narrowedKeyType = $this->getIterableKeyType(); - $narrowedValueType = $this->getIterableValueType(); - $types = array_map(static function (Type $innerType) use ($narrowedKeyType, $narrowedValueType): Type { - if (!$innerType->isArray()->yes()) { - return $innerType; - } - if (!$innerType->getIterableValueType() instanceof MixedType) { - return $innerType; - } - return new ArrayType($narrowedKeyType, $narrowedValueType); - }, $types); - } - $result = AcceptsResult::lazyMaxMin( - $types, + $this->types, static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), ); + if ($result->yes()) { + $isSuperType = $acceptingType->isSuperTypeOf($this); + if ($isSuperType->no()) { + return $isSuperType->toAcceptsResult(); + } + } + if ($this->isOversizedArray()->yes()) { if (!$result->no()) { return AcceptsResult::createYes(); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index bde8ef5a3b..8c7e7a0aeb 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4047,6 +4047,10 @@ public function testBug14549(): void 'Parameter #1 $param of method Bug14549Bis\Foo::callConstantArrayObjectOrStringStringString() expects array{object|string, string, string}, array&callable(): mixed given.', 47, ], + [ + 'Parameter #1 $param of method Bug14549Bis\Foo::callArrayString() expects array, array given.', + 58, + ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php b/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php index 01ab0f6538..ea38ed6806 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14549-bis.php @@ -47,4 +47,17 @@ public function doCallWithCallableAndArray(array $task): void $this->callConstantArrayObjectOrStringStringString($task); } + /** @param array $param */ + public function callArrayString(array $param): void + { + } + + public function doCallWithHasOffsetValue(array $arr): void + { + if (isset($arr[1]) && $arr[1] === 1) { + $this->callArrayString($arr); + $this->callArrayInt($arr); + } + } + } From e3e1b99c39eaeaa9678ba2d6187052e49c189f06 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 11:45:54 +0000 Subject: [PATCH 6/6] Remove doBaz() test case from bug-14549.php per reviewer request Co-Authored-By: Claude Opus 4.6 --- .../Methods/MissingMethodParameterTypehintRuleTest.php | 4 ---- tests/PHPStan/Rules/Methods/data/bug-14549.php | 7 ------- 2 files changed, 11 deletions(-) diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index ad997bcd06..9f2056500d 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -165,10 +165,6 @@ public function testBug14549(): void 46, MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, ], - [ - 'Method Bug14549\Foo::doBaz() has parameter $task with no signature specified for callable.', - 53, - ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14549.php b/tests/PHPStan/Rules/Methods/data/bug-14549.php index 671522645f..c713ac3092 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14549.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14549.php @@ -47,13 +47,6 @@ public function doIntersection($array): void { } - /** - * @param callable&array $task - */ - public function doBaz(array $task): void - { - } - }