From d4dcc5cbc14ae57a040885bc3aa053cb021537df 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/9] 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 7507cc481b856d6ebf064308f1da1b87a308b132 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:20:20 +0000 Subject: [PATCH 2/9] 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 20dccde9c4b0b3b541cd119643ee1e49b26a40ee Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:20:28 +0000 Subject: [PATCH 3/9] 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 158b918330..c2ff65aa5f 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 8b4e9fd5bc..6a13b06008 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4025,4 +4025,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 8bbf9bb1736ea3a22e6eebd0cc440770ce15f676 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 09:54:25 +0000 Subject: [PATCH 4/9] 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 6a13b06008..c4c8dcde07 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4030,14 +4030,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 ca76429626c99984f7dc692a05d2fb42615a9ae3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 11:09:06 +0000 Subject: [PATCH 5/9] 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 c2ff65aa5f..702ceb64a8 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 c4c8dcde07..135113d0a0 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4055,6 +4055,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 3afa0acde8c7809f5867d1adc5b7156707904421 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 30 Apr 2026 11:45:54 +0000 Subject: [PATCH 6/9] 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 - { - } - } From 3b83616057107dee7bb36cad3aaf8ba06fc3b023 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 08:16:34 +0000 Subject: [PATCH 7/9] Add IntersectionType isAcceptedBy and isSubTypeOf tests for array&callable and array&hasOffsetValue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover both success and failure cases: - array&callable isAcceptedBy array/constantArray - array&hasOffsetValue isAcceptedBy array/constantArray - array&callable isSubTypeOf array - array&hasOffsetValue isSubTypeOf array The isSubTypeOf tests confirm that the issue does NOT exist there — isSubTypeOf correctly returns No for incompatible types (e.g. array&callable is not a subtype of array, and array&hasOffsetValue(3,int) is not a subtype of array). Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/IntersectionTypeTest.php | 159 ++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/tests/PHPStan/Type/IntersectionTypeTest.php b/tests/PHPStan/Type/IntersectionTypeTest.php index e3ed23eb46..b255a4d064 100644 --- a/tests/PHPStan/Type/IntersectionTypeTest.php +++ b/tests/PHPStan/Type/IntersectionTypeTest.php @@ -109,6 +109,118 @@ public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedR ); } + /** + * @return Iterator + */ + public static function dataIsAcceptedBy(): Iterator + { + // array&callable isAcceptedBy array - success + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ArrayType(new MixedType(), new MixedType()), + TrinaryLogic::createYes(), + ]; + + // array&callable isAcceptedBy array - failure + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ArrayType(new MixedType(), new IntegerType()), + TrinaryLogic::createNo(), + ]; + + // array&callable isAcceptedBy constantArray - success + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new UnionType([new ObjectType('stdClass'), new StringType()]), new StringType()], + ), + TrinaryLogic::createMaybe(), + ]; + + // array&callable isAcceptedBy constantArray - failure + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new StringType(), new StringType()], + ), + TrinaryLogic::createMaybe(), + ]; + + // array&hasOffsetValue isAcceptedBy array - success + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new MixedType()), + TrinaryLogic::createYes(), + ]; + + // array&hasOffsetValue isAcceptedBy array - failure + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new StringType()), + TrinaryLogic::createNo(), + ]; + + // array&hasOffsetValue isAcceptedBy array - success (matching value type) + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new IntegerType()), + TrinaryLogic::createYes(), + ]; + + // array&hasOffsetValue isAcceptedBy constantArray - success + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), + ]), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new IntegerType()], + ), + TrinaryLogic::createMaybe(), + ]; + + // array&hasOffsetValue isAcceptedBy constantArray - failure + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new StringType(), new StringType()], + ), + TrinaryLogic::createMaybe(), + ]; + } + + #[DataProvider('dataIsAcceptedBy')] + public function testIsAcceptedBy(Type $type, Type $acceptingType, TrinaryLogic $expectedResult): void + { + $actualResult = $acceptingType->accepts($type, true)->result; + $this->assertSame( + $expectedResult->describe(), + $actualResult->describe(), + sprintf('%s -> isAcceptedBy(%s)', $type->describe(VerbosityLevel::precise()), $acceptingType->describe(VerbosityLevel::precise())), + ); + } + public static function dataIsCallable(): array { return [ @@ -362,6 +474,53 @@ public static function dataIsSubTypeOf(): Iterator ]), TrinaryLogic::createYes(), ]; + + // array&callable isSubTypeOf array - success + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ArrayType(new MixedType(), new MixedType()), + TrinaryLogic::createYes(), + ]; + + // array&callable isSubTypeOf array - failure + yield [ + new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), + new ArrayType(new MixedType(), new IntegerType()), + TrinaryLogic::createNo(), + ]; + + // array&hasOffsetValue isSubTypeOf array - success + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new MixedType()), + TrinaryLogic::createYes(), + ]; + + // array&hasOffsetValue isSubTypeOf array - maybe + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new IntegerType()), + TrinaryLogic::createMaybe(), + ]; + + // array&hasOffsetValue isSubTypeOf array - failure + yield [ + new IntersectionType([ + new ArrayType(new MixedType(), new MixedType()), + new NonEmptyArrayType(), + new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + ]), + new ArrayType(new MixedType(), new StringType()), + TrinaryLogic::createNo(), + ]; } #[DataProvider('dataIsSubTypeOf')] From 9759075d6d239bb975079baa489eab64650be5db Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 09:39:16 +0000 Subject: [PATCH 8/9] Improve IntersectionTypeTest comments and fix hasOffsetValue test case - Make test comments more descriptive by including the accepting type details - Fix last hasOffsetValue failure test to use offset 0 (matching the constant array's offsets) so it properly tests type incompatibility, and update expected result to No since int at offset 0 conflicts with string Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/IntersectionTypeTest.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/PHPStan/Type/IntersectionTypeTest.php b/tests/PHPStan/Type/IntersectionTypeTest.php index b255a4d064..05d07a9f7a 100644 --- a/tests/PHPStan/Type/IntersectionTypeTest.php +++ b/tests/PHPStan/Type/IntersectionTypeTest.php @@ -121,14 +121,14 @@ public static function dataIsAcceptedBy(): Iterator TrinaryLogic::createYes(), ]; - // array&callable isAcceptedBy array - failure + // array&callable isAcceptedBy array - failure yield [ new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), new ArrayType(new MixedType(), new IntegerType()), TrinaryLogic::createNo(), ]; - // array&callable isAcceptedBy constantArray - success + // array&callable isAcceptedBy constantArray{stdClass, string} - maybe yield [ new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), new ConstantArrayType( @@ -138,7 +138,7 @@ public static function dataIsAcceptedBy(): Iterator TrinaryLogic::createMaybe(), ]; - // array&callable isAcceptedBy constantArray - failure + // array&callable isAcceptedBy constantArray{string, string} - maybe yield [ new IntersectionType([new ArrayType(new MixedType(), new MixedType()), new CallableType()]), new ConstantArrayType( @@ -170,7 +170,7 @@ public static function dataIsAcceptedBy(): Iterator TrinaryLogic::createNo(), ]; - // array&hasOffsetValue isAcceptedBy array - success (matching value type) + // array&hasOffsetValue isAcceptedBy array - success (matching value type) yield [ new IntersectionType([ new ArrayType(new MixedType(), new MixedType()), @@ -181,7 +181,7 @@ public static function dataIsAcceptedBy(): Iterator TrinaryLogic::createYes(), ]; - // array&hasOffsetValue isAcceptedBy constantArray - success + // array&hasOffsetValue isAcceptedBy constantArray{int, int} - success yield [ new IntersectionType([ new ArrayType(new MixedType(), new MixedType()), @@ -195,18 +195,18 @@ public static function dataIsAcceptedBy(): Iterator TrinaryLogic::createMaybe(), ]; - // array&hasOffsetValue isAcceptedBy constantArray - failure + // array&hasOffsetValue isAcceptedBy constantArray{string, string} - failure yield [ new IntersectionType([ new ArrayType(new MixedType(), new MixedType()), new NonEmptyArrayType(), - new HasOffsetValueType(new ConstantIntegerType(3), new IntegerType()), + new HasOffsetValueType(new ConstantIntegerType(0), new IntegerType()), ]), new ConstantArrayType( [new ConstantIntegerType(0), new ConstantIntegerType(1)], [new StringType(), new StringType()], ), - TrinaryLogic::createMaybe(), + TrinaryLogic::createNo(), ]; } From e06310aa1bdd2e1c17bb7d55cbbf57389022a3d4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 6 May 2026 10:32:42 +0000 Subject: [PATCH 9/9] Replace isSuperTypeOf guard with targeted non-array component re-check The isSuperTypeOf guard in IntersectionType::isAcceptedBy() conflated accepts semantics (lenient, "could be") with isSuperTypeOf semantics (strict, "always is"), causing false positives when mixed key/value types were involved. For example, non-empty-array was incorrectly rejected by array because isSuperTypeOf correctly said mixed keys aren't a subtype of string keys, even though accepts correctly treats mixed as potentially string. The new approach: when lazyMaxMin returns yes (typically from ArrayType(mixed, mixed) being accepted) and the accepting type is an array, re-check all non-array intersection components that lazyMaxMin may have skipped due to short-circuiting. This catches: - array&callable vs array (CallableType returns no) - array&hasOffsetValue(3,int) vs array (HasOffsetValueType returns no) without producing false positives for legitimate cases like non-empty-array vs array. Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 702ceb64a8..5218a2367f 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -304,10 +304,15 @@ public function isAcceptedBy(Type $acceptingType, bool $strictTypes): AcceptsRes static fn (Type $innerType) => $acceptingType->accepts($innerType, $strictTypes), ); - if ($result->yes()) { - $isSuperType = $acceptingType->isSuperTypeOf($this); - if ($isSuperType->no()) { - return $isSuperType->toAcceptsResult(); + if ($result->yes() && $acceptingType->isArray()->yes()) { + foreach ($this->types as $innerType) { + if ($innerType->isArray()->yes()) { + continue; + } + $innerResult = $acceptingType->accepts($innerType, $strictTypes); + if ($innerResult->no()) { + return $innerResult; + } } }