From 6d1350d98b132d3e715aa6bf5e3401d71630afda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:33:59 +0000 Subject: [PATCH 1/6] Use position-specific `getOffsetValueType()` instead of `getIterableValueType()` when intersecting two `ConstantArrayType`s - In TypeCombinator::intersect(), when the "maybe supertype" fallback intersects a ConstantArrayType with another array type, it previously used getIterableValueType() (the union of ALL value types) for each position. This caused value types from other positions to leak across. - Now uses hasOffsetValueType()/getOffsetValueType() to get the position-specific value type when the other array has the key, falling back to getIterableValueType() only when the key is absent. - The bug manifested when a ConstantArrayType value was a union of constants (e.g. 0|1|2|3) that got generalized to its base type (int) during template inference, changing the isSuperTypeOf result from "yes" to "maybe" and triggering the buggy fallback code path. - Fixed both the primary block and its mirror block in intersect(). - Also removes instanceof ConstantArrayType checks that PHPStan's own analysis flags as deprecated. --- src/Type/TypeCombinator.php | 16 +++- tests/PHPStan/Analyser/nsrt/bug-11234.php | 90 +++++++++++++++++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 19 +++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11234.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 22bffb68484..1671b7c8b6c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1515,10 +1515,14 @@ public static function intersect(Type ...$types): Type $newArray = ConstantArrayTypeBuilder::createEmpty(); $valueTypes = $types[$i]->getValueTypes(); foreach ($types[$i]->getKeyTypes() as $k => $keyType) { + $hasOffset = $types[$j]->hasOffsetValueType($keyType); + $otherValueType = !$hasOffset->no() + ? $types[$j]->getOffsetValueType($keyType) + : $types[$j]->getIterableValueType(); $newArray->setOffsetValueType( self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$j]->getIterableValueType()), - $types[$i]->isOptionalKey($k) && !$types[$j]->hasOffsetValueType($keyType)->yes(), + self::intersect($valueTypes[$k], $otherValueType), + $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), ); } $types[$i] = $newArray->getArray(); @@ -1531,10 +1535,14 @@ public static function intersect(Type ...$types): Type $newArray = ConstantArrayTypeBuilder::createEmpty(); $valueTypes = $types[$j]->getValueTypes(); foreach ($types[$j]->getKeyTypes() as $k => $keyType) { + $hasOffset = $types[$i]->hasOffsetValueType($keyType); + $otherValueType = !$hasOffset->no() + ? $types[$i]->getOffsetValueType($keyType) + : $types[$i]->getIterableValueType(); $newArray->setOffsetValueType( self::intersect($keyType, $types[$i]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$i]->getIterableValueType()), - $types[$j]->isOptionalKey($k) && !$types[$i]->hasOffsetValueType($keyType)->yes(), + self::intersect($valueTypes[$k], $otherValueType), + $types[$j]->isOptionalKey($k) && !$hasOffset->yes(), ); } $types[$j] = $newArray->getArray(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-11234.php b/tests/PHPStan/Analyser/nsrt/bug-11234.php new file mode 100644 index 00000000000..771bdbc11c5 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11234.php @@ -0,0 +1,90 @@ + Date: Wed, 15 Apr 2026 09:05:13 +0000 Subject: [PATCH 2/6] Remove verbose comments from test files per review feedback Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11234.php | 51 ++++------------------- tests/PHPStan/Type/TypeCombinatorTest.php | 1 - 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11234.php b/tests/PHPStan/Analyser/nsrt/bug-11234.php index 771bdbc11c5..0e9d2511122 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11234.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11234.php @@ -6,84 +6,49 @@ class Payload {} -/** - * When the value at index 0 is a union of constants (0|1|2|3), int leaks - * into the resolved type of the adjacent value during intersection. - * The generalization of 0|1|2|3 to int causes getIterableValueType() - * to include int in the value union, leaking it to position 1. - * - * @param array{0|1|2|3, int|Payload|string|null}&array{int, Payload} $x - */ +/** @param array{0|1|2|3, int|Payload|string|null}&array{int, Payload} $x */ function testIntersectConstantUnionWithInt(mixed $x): void { assertType('array{0|1|2|3, Bug11234\Payload}', $x); } -/** - * Reverse order. - * - * @param array{int, Payload}&array{0|1|2|3, int|Payload|string|null} $x - */ +/** @param array{int, Payload}&array{0|1|2|3, int|Payload|string|null} $x */ function testIntersectConstantUnionWithIntReverse(mixed $x): void { assertType('array{0|1|2|3, Bug11234\Payload}', $x); } -/** - * Both sides have constant unions. - * - * @param array{0|1|2|3, int|Payload|string|null}&array{0|1|2|3, Payload} $x - */ +/** @param array{0|1|2|3, int|Payload|string|null}&array{0|1|2|3, Payload} $x */ function testIntersectBothConstantUnion(mixed $x): void { assertType('array{0|1|2|3, Bug11234\Payload}', $x); } -/** - * Works fine when the first value is just int. - * - * @param array{int, int|Payload|string|null}&array{int, Payload} $y - */ +/** @param array{int, int|Payload|string|null}&array{int, Payload} $y */ function testIntersectPlainInt(mixed $y): void { assertType('array{int, Bug11234\Payload}', $y); } -/** - * Three-value array shape: leaks should not happen across multiple positions. - * - * @param array{0|1, string|int, Payload|null}&array{int, string, Payload} $z - */ +/** @param array{0|1, string|int, Payload|null}&array{int, string, Payload} $z */ function testIntersectThreePositions(mixed $z): void { assertType('array{0|1, string, Bug11234\Payload}', $z); } -/** - * String constant union at first position. - * - * @param array{'a'|'b', int|Payload|string|null}&array{string, Payload} $w - */ +/** @param array{'a'|'b', int|Payload|string|null}&array{string, Payload} $w */ function testIntersectStringConstantUnion(mixed $w): void { assertType("array{'a'|'b', Bug11234\Payload}", $w); } -/** - * Different key count — extra keys from one side are dropped. - * - * @param array{0|1, int|string}&array{int, int, extra?: bool} $v - */ +/** @param array{0|1, int|string}&array{int, int, extra?: bool} $v */ function testIntersectOptionalKey(mixed $v): void { assertType('array{0|1, int}', $v); } -/** - * Boolean constant union. - * - * @param array{true|false, int|string}&array{bool, string} $u - */ +/** @param array{true|false, int|string}&array{bool, string} $u */ function testIntersectBoolConstantUnion(mixed $u): void { assertType('array{bool, string}', $u); diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 867de1d7463..3607adcddbe 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -4934,7 +4934,6 @@ public static function dataIntersect(): iterable 'T of Countable&Iterator (function a(), parameter)', ]; - // https://github.com/phpstan/phpstan/issues/11234 yield [ [ new ConstantArrayType( From 7af188e7e65d8637e3659c4e55d13d1ad6746a49 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 09:14:20 +0000 Subject: [PATCH 3/6] Add lint >= 8.0 directive to skip test on PHP 7.4 The test file uses `mixed` type hints which require PHP 8.0+. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11234.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11234.php b/tests/PHPStan/Analyser/nsrt/bug-11234.php index 0e9d2511122..d2016d9d475 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11234.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11234.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug11234; From 3ba87844c6c16eba797df89bcd0f853b3ab01242 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 09:41:52 +0000 Subject: [PATCH 4/6] Skip keys absent from the other array instead of falling back to getIterableValueType() When intersecting two ConstantArrayTypes and hasOffsetValueType() returns no for a key, that key cannot exist in the intersection. Previously it fell back to getIterableValueType() which could leak unrelated value types. Now those keys are simply skipped via continue. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeCombinator.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1671b7c8b6c..f027c402f05 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1516,12 +1516,12 @@ public static function intersect(Type ...$types): Type $valueTypes = $types[$i]->getValueTypes(); foreach ($types[$i]->getKeyTypes() as $k => $keyType) { $hasOffset = $types[$j]->hasOffsetValueType($keyType); - $otherValueType = !$hasOffset->no() - ? $types[$j]->getOffsetValueType($keyType) - : $types[$j]->getIterableValueType(); + if ($hasOffset->no()) { + continue; + } $newArray->setOffsetValueType( self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $otherValueType), + self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)), $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), ); } @@ -1536,12 +1536,12 @@ public static function intersect(Type ...$types): Type $valueTypes = $types[$j]->getValueTypes(); foreach ($types[$j]->getKeyTypes() as $k => $keyType) { $hasOffset = $types[$i]->hasOffsetValueType($keyType); - $otherValueType = !$hasOffset->no() - ? $types[$i]->getOffsetValueType($keyType) - : $types[$i]->getIterableValueType(); + if ($hasOffset->no()) { + continue; + } $newArray->setOffsetValueType( self::intersect($keyType, $types[$i]->getIterableKeyType()), - self::intersect($valueTypes[$k], $otherValueType), + self::intersect($valueTypes[$k], $types[$i]->getOffsetValueType($keyType)), $types[$j]->isOptionalKey($k) && !$hasOffset->yes(), ); } From 06925a82ccf7b0a6cc42c233bed3953612a1a510 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 18:42:32 +0000 Subject: [PATCH 5/6] Add test cases for int-range and non-empty-string value types in array intersection Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11234.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11234.php b/tests/PHPStan/Analyser/nsrt/bug-11234.php index d2016d9d475..4e307908861 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11234.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11234.php @@ -53,3 +53,15 @@ function testIntersectBoolConstantUnion(mixed $u): void { assertType('array{bool, string}', $u); } + +/** @param array{int<0, 3>, int|Payload|string|null}&array{int, Payload} $x */ +function testIntersectIntRangeValue(mixed $x): void +{ + assertType('array{int<0, 3>, Bug11234\Payload}', $x); +} + +/** @param array{non-empty-string, int|Payload|string|null}&array{string, Payload} $x */ +function testIntersectNonEmptyStringValue(mixed $x): void +{ + assertType('array{non-empty-string, Bug11234\Payload}', $x); +} From 1189a0637cc559e42da31e32ba26f286eebaccaa Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 15 Apr 2026 18:42:56 +0000 Subject: [PATCH 6/6] Add test cases for int-range and non-empty-string intersection types Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11234.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11234.php b/tests/PHPStan/Analyser/nsrt/bug-11234.php index 4e307908861..bfbcb27e2fa 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11234.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11234.php @@ -65,3 +65,15 @@ function testIntersectNonEmptyStringValue(mixed $x): void { assertType('array{non-empty-string, Bug11234\Payload}', $x); } + +/** @param array{0|1|2|3, non-empty-string|int|null}&array{int, string} $x */ +function testIntersectNonEmptyStringInUnion(mixed $x): void +{ + assertType('array{0|1|2|3, non-empty-string}', $x); +} + +/** @param array{0|1|2|3, string|null}&array{int, non-empty-string} $x */ +function testIntersectWithNonEmptyStringOtherSide(mixed $x): void +{ + assertType('array{0|1|2|3, non-empty-string}', $x); +}