From 36b0f460341b00d0eff7e56ec36b79fae3fb828e Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 26 Apr 2026 19:15:00 +0000 Subject: [PATCH 1/5] Fix phpstan/phpstan#3842: Narrow offset types for callable array intersections When is_array() narrows a callable parameter, the resulting array&callable intersection type now correctly reports that offset 0 is class-string|object and offset 1 is string. This fixes false positives when passing the narrowed value to functions expecting array{string|object, string} or similar callable-array shapes. --- src/Type/IntersectionType.php | 23 +++++++- tests/PHPStan/Analyser/nsrt/bug-3842.php | 55 +++++++++++++++++++ .../CallToFunctionParametersRuleTest.php | 5 ++ .../PHPStan/Rules/Functions/data/bug-3842.php | 29 ++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-3842.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-3842.php diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 109d6f233a..134c6dfde0 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -943,7 +943,19 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic } } - return $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); + $result = $this->intersectResults(static fn (Type $type): TrinaryLogic => $type->hasOffsetValueType($offsetType)); + + if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) { + $arrayKeyOffsetType = $offsetType->toArrayKey(); + if ( + (new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes() + || (new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes() + ) { + return TrinaryLogic::createYes(); + } + } + + return $result; } public function getOffsetValueType(Type $offsetType): Type @@ -953,6 +965,15 @@ public function getOffsetValueType(Type $offsetType): Type return TypeUtils::toBenevolentUnion($result); } + if ($this->isCallable()->yes() && $this->isArray()->yes()) { + $arrayKeyOffsetType = $offsetType->toArrayKey(); + if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $result = TypeCombinator::intersect($result, TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType())); + } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $result = TypeCombinator::intersect($result, new StringType()); + } + } + return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php new file mode 100644 index 0000000000..64cfdff9da --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -0,0 +1,55 @@ +&callable(): mixed', $value); + assertType('class-string|object', $value[0]); + assertType('string', $value[1]); + } +} + +/** @param callable-array $value */ +function testCallableArrayPhpDoc(array $value): void { + assertType('array&callable(): mixed', $value); + assertType('class-string|object', $value[0]); + assertType('string', $value[1]); +} + +function testIsStringOnCallable(callable $value): void { + if (is_string($value)) { + assertType('callable-string', $value); + } +} + +/** @param array{string|object, string} $values */ +function check(array $values): void { +} + +/** @param array{class-string|object, string} $values */ +function checkClassString(array $values): void { +} + +function testPassCallableArray(callable $value): void { + if (is_array($value)) { + check($value); + checkClassString($value); + } +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index bfa38f42d5..7ac84fcc94 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2866,4 +2866,9 @@ public function testBug13643(): void $this->analyse([__DIR__ . '/data/bug-13643.php'], []); } + public function testBug3842(): void + { + $this->analyse([__DIR__ . '/data/bug-3842.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-3842.php b/tests/PHPStan/Rules/Functions/data/bug-3842.php new file mode 100644 index 0000000000..428607dad0 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-3842.php @@ -0,0 +1,29 @@ + Date: Sun, 26 Apr 2026 20:20:08 +0000 Subject: [PATCH 2/5] Use UnionType->isSuperTypeOf for callable array offset checks Address review feedback: use a single UnionType(0|1)->isSuperTypeOf check instead of separate ConstantIntegerType checks, and handle the case where the offset is itself a union (e.g. 0|1) by narrowing to the union of both offset types (object|string). Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 22 ++++++++++++++-------- tests/PHPStan/Analyser/nsrt/bug-3842.php | 7 +++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 134c6dfde0..07009dccbb 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -947,10 +947,8 @@ public function hasOffsetValueType(Type $offsetType): TrinaryLogic if (!$result->yes() && $this->isCallable()->yes() && $this->isArray()->yes()) { $arrayKeyOffsetType = $offsetType->toArrayKey(); - if ( - (new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes() - || (new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes() - ) { + $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); + if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { return TrinaryLogic::createYes(); } } @@ -967,10 +965,18 @@ public function getOffsetValueType(Type $offsetType): Type if ($this->isCallable()->yes() && $this->isArray()->yes()) { $arrayKeyOffsetType = $offsetType->toArrayKey(); - if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $result = TypeCombinator::intersect($result, TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType())); - } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $result = TypeCombinator::intersect($result, new StringType()); + $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); + if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $narrowedTypes = []; + if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(0))->no()) { + $narrowedTypes[] = TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType()); + } + if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(1))->no()) { + $narrowedTypes[] = new StringType(); + } + if ($narrowedTypes !== []) { + $result = TypeCombinator::intersect($result, TypeCombinator::union(...$narrowedTypes)); + } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-3842.php b/tests/PHPStan/Analyser/nsrt/bug-3842.php index 64cfdff9da..51e607ea40 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-3842.php +++ b/tests/PHPStan/Analyser/nsrt/bug-3842.php @@ -47,6 +47,13 @@ function check(array $values): void { function checkClassString(array $values): void { } +/** @param 0|1 $offset */ +function testCallableArrayUnionOffset(callable $value, int $offset): void { + if (is_array($value)) { + assertType('object|string', $value[$offset]); + } +} + function testPassCallableArray(callable $value): void { if (is_array($value)) { check($value); From 89aac2435f52c5c332f99ab25597da0a6cfa3035 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:27:51 +0000 Subject: [PATCH 3/5] Use UnionType directly instead of TypeCombinator::union for ClassStringType|ObjectWithoutClassType Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index 07009dccbb..dc2dc0be17 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -969,7 +969,7 @@ public function getOffsetValueType(Type $offsetType): Type if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { $narrowedTypes = []; if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(0))->no()) { - $narrowedTypes[] = TypeCombinator::union(new ClassStringType(), new ObjectWithoutClassType()); + $narrowedTypes[] = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); } if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(1))->no()) { $narrowedTypes[] = new StringType(); From 0942e8d8075b7e15be52282a48754c6bf38ecdff Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:31:00 +0000 Subject: [PATCH 4/5] Simplify callable array offset type narrowing with if/elseif/else Use direct UnionType construction instead of building an array and calling TypeCombinator::union, as suggested in review. Co-Authored-By: Claude Opus 4.6 --- src/Type/IntersectionType.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index dc2dc0be17..81e6d3272d 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -967,16 +967,14 @@ public function getOffsetValueType(Type $offsetType): Type $arrayKeyOffsetType = $offsetType->toArrayKey(); $callableArrayOffsetType = new UnionType([new ConstantIntegerType(0), new ConstantIntegerType(1)]); if ($callableArrayOffsetType->isSuperTypeOf($arrayKeyOffsetType)->yes()) { - $narrowedTypes = []; - if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(0))->no()) { - $narrowedTypes[] = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); - } - if (!$arrayKeyOffsetType->isSuperTypeOf(new ConstantIntegerType(1))->no()) { - $narrowedTypes[] = new StringType(); - } - if ($narrowedTypes !== []) { - $result = TypeCombinator::intersect($result, TypeCombinator::union(...$narrowedTypes)); + if ((new ConstantIntegerType(0))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $narrowedType = new UnionType([new ClassStringType(), new ObjectWithoutClassType()]); + } elseif ((new ConstantIntegerType(1))->isSuperTypeOf($arrayKeyOffsetType)->yes()) { + $narrowedType = new StringType(); + } else { + $narrowedType = new UnionType([new StringType(), new ObjectWithoutClassType()]); } + $result = TypeCombinator::intersect($result, $narrowedType); } } From 35a6959024fc77692097e0e572abb406526b0d64 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 16:42:48 +0000 Subject: [PATCH 5/5] Merge bug-3842 test data into single shared nsrt file The rule test now references the nsrt test file directly instead of maintaining a separate duplicate data file. Co-Authored-By: Claude Opus 4.6 --- .../CallToFunctionParametersRuleTest.php | 2 +- .../PHPStan/Rules/Functions/data/bug-3842.php | 29 ------------------- 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-3842.php diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 7ac84fcc94..3da9519243 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2868,7 +2868,7 @@ public function testBug13643(): void public function testBug3842(): void { - $this->analyse([__DIR__ . '/data/bug-3842.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.php'], []); } } diff --git a/tests/PHPStan/Rules/Functions/data/bug-3842.php b/tests/PHPStan/Rules/Functions/data/bug-3842.php deleted file mode 100644 index 428607dad0..0000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-3842.php +++ /dev/null @@ -1,29 +0,0 @@ -