From 1e7f9c74180edbfef16c536e14bc0e2c0668d8f0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 21:51:03 +0200 Subject: [PATCH 1/9] Keep unions of general array types separate under bleeding edge Previously `TypeCombinator::processArrayTypes` collapsed multiple general array members into a single `ArrayType` with unioned keys and values, so `array|array` was indistinguishable from `array` (phpstan/phpstan#8963). Under `BleedingEdgeToggle::isBleedingEdge()` the members are now kept as distinct union branches; the outer `union()` loop handles subsumption via `compareTypesInUnion`. The `array{} | non-empty- array` -> `array` simplification is preserved by falling back to the old collapse path whenever an empty constant array is present. --- src/Type/TypeCombinator.php | 23 ++++ tests/PHPStan/Analyser/nsrt/array-slice.php | 2 +- .../nsrt/array-union-keep-separate.php | 118 ++++++++++++++++++ tests/PHPStan/Analyser/nsrt/array_splice.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10025.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10089.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10438.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-10640.php | 2 +- .../PHPStan/Analyser/nsrt/bug-11518-types.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12078.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12274.php | 4 +- .../PHPStan/Analyser/nsrt/bug-13270b-php8.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-13747.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-14245.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-4708.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5017.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-6173.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9734.php | 10 +- tests/PHPStan/Analyser/nsrt/bug7856.php | 2 +- .../Analyser/nsrt/conditional-vars-php8.php | 8 +- tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php | 2 +- tests/PHPStan/Analyser/nsrt/getopt.php | 2 +- tests/PHPStan/Analyser/nsrt/in-array-enum.php | 6 +- tests/PHPStan/Analyser/nsrt/list-count2.php | 4 +- .../Analyser/nsrt/narrow-tagged-union.php | 6 +- .../Analyser/nsrt/preg_match_shapes.php | 2 +- .../nsrt/shopware-connection-profiler.php | 2 +- tests/PHPStan/Analyser/nsrt/shuffle.php | 4 +- tests/PHPStan/Analyser/nsrt/sort.php | 12 +- tests/PHPStan/Rules/Arrays/data/bug-6000.php | 2 +- tests/PHPStan/Rules/Arrays/data/bug-8467a.php | 2 +- .../Rules/Comparison/data/bug-4708.php | 2 +- turbo-ext/src/TypeCombinator.cpp | 53 ++++++++ 33 files changed, 249 insertions(+), 55 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 7a2e6e49666..8433c1acc87 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -2,6 +2,7 @@ namespace PHPStan\Type; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\TrinaryLogic; use PHPStan\Turbo\ShadowedByTurboExtension; use PHPStan\Turbo\TurboExtensionEnabler; @@ -1265,6 +1266,28 @@ private static function processArrayTypes(array $arrayTypes): array return [self::intersect($reducedArrayTypes[0], ...$accessoryTypes)]; } + $hasEmptyConstantArray = false; + foreach ($arrayTypes as $arrayType) { + if ($arrayType->isIterableAtLeastOnce()->no() && $arrayType->isConstantArray()->yes()) { + $hasEmptyConstantArray = true; + break; + } + } + + if (BleedingEdgeToggle::isBleedingEdge() && !$hasEmptyConstantArray) { + // Keep each array member distinct (e.g. `list|list` rather + // than `list`). Subsumption is handled by the outer union() + // loop; the `array{} | non-empty-array` -> `array` simplification + // is not expressible here and falls through to the old collapse path. + $results = []; + foreach ($arrayTypes as $arrayType) { + $results[] = $accessoryTypes === [] + ? $arrayType + : self::intersect($arrayType, ...$accessoryTypes); + } + return $results; + } + $templateArrayType = null; foreach ($arrayTypes as $arrayType) { if (!$arrayType instanceof TemplateArrayType) { diff --git a/tests/PHPStan/Analyser/nsrt/array-slice.php b/tests/PHPStan/Analyser/nsrt/array-slice.php index caf08c8d65a..553ac9bd1fb 100644 --- a/tests/PHPStan/Analyser/nsrt/array-slice.php +++ b/tests/PHPStan/Analyser/nsrt/array-slice.php @@ -16,7 +16,7 @@ public function nonEmpty(array $a, array $b, array $c): void { assertType('array', array_slice($a, 1)); assertType('list', array_slice($b, 1)); - assertType('array', array_slice($c, 1)); + assertType('array|list', array_slice($c, 1)); } /** diff --git a/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php b/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php new file mode 100644 index 00000000000..8532f858c07 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-union-keep-separate.php @@ -0,0 +1,118 @@ +|array $arr */ + public function plainArray(array $arr): void + { + assertType('array|array', $arr); + } + + /** @param list|list $list */ + public function listUnion(array $list): void + { + assertType('list|list', $list); + } + + /** @param non-empty-array|non-empty-array $arr */ + public function distinctKeyAndValue(array $arr): void + { + assertType('non-empty-array|non-empty-array', $arr); + } + + /** + * Subsumption: array is a subtype of array, so the + * union must collapse to the wider member. + * + * @param array|array $arr + */ + public function subsumesWider(array $arr): void + { + assertType('array', $arr); + } + + /** + * Subsumption across list/array: list is a subtype of array. + * + * @param list|array $arr + */ + public function subsumesListIntoArray(array $arr): void + { + assertType('array', $arr); + } + + /** + * Identical members dedupe. + * + * @param array|array $arr + */ + public function identicalMembers(array $arr): void + { + assertType('array', $arr); + } + + /** + * Narrowing the value via offset-access propagates back to the array. + * + * @param list|list $list + */ + public function narrowByOffset(array $list): void + { + if (count($list) === 0) { + return; + } + + if (is_string($list[0])) { + assertType('non-empty-list&hasOffsetValue(0, string)', $list); + } else { + assertType('non-empty-list&hasOffsetValue(0, int)', $list); + } + } + + /** + * A mixed array is not a subtype of a union of homogeneous arrays. + * + * @param array|array $arr + */ + public function acceptsTaker(array $arr): void + { + } + + public function callerWithMixedArray(): void + { + /** @var array $mixed */ + $mixed = []; + // phpstan-should-error: passing array does not satisfy array|array + $this->acceptsTaker($mixed); + } + + /** + * Constant array stays separate from a general array (no folding into + * array). + * + * @param array{foo: int}|array $arr + */ + public function constantAndGeneral(array $arr): void + { + assertType("array{foo: int}|array", $arr); + } + + /** + * Iterating a union preserves the element union (existing UnionType + * behavior; regression guard for the keep-separate change). + * + * @param list|list $list + */ + public function iteration(array $list): void + { + foreach ($list as $value) { + assertType('int|string', $value); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/array_splice.php b/tests/PHPStan/Analyser/nsrt/array_splice.php index 9e524d32125..a9e4694533d 100644 --- a/tests/PHPStan/Analyser/nsrt/array_splice.php +++ b/tests/PHPStan/Analyser/nsrt/array_splice.php @@ -186,7 +186,7 @@ function constantArrays(array $arr, array $arr2): void /** @var array{x: 'x', y?: 'y', 3: 66}|array{z: 'z', 5?: 77, 4: int}|array $arr2 */ $arr; $extract = array_splice($arr, 0, 1, $arr2); - assertType('non-empty-array<\'b\'|int<0, max>, \'bar\'|\'baz\'|\'x\'|\'y\'|\'z\'|int|object|null>', $arr); + assertType("array{0: 'x', 1: 66|'y', 2: 66|'baz', b: 'bar', 3?: 'baz'}|array{0: 'z', 1: int, 2: 'baz'|int, b: 'bar', 3?: 'baz'}|non-empty-array<'b'|int<0, max>, 'bar'|'baz'|object|null>", $arr); assertType('array{\'foo\'}', $extract); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10025.php b/tests/PHPStan/Analyser/nsrt/bug-10025.php index 4d172f2aaca..7694995c90b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10025.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10025.php @@ -23,7 +23,7 @@ function x(array $foos, array $bars): void $arr[$bar->groupId]['bar'][] = $bar; } - assertType('array, bar?: non-empty-list}>', $arr); + assertType('non-empty-array, bar: non-empty-list}>|array}>', $arr); foreach ($arr as $groupId => $group) { if (isset($group['foo'])) { } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10089.php b/tests/PHPStan/Analyser/nsrt/bug-10089.php index 01aafa9790e..99f8014d040 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10089.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10089.php @@ -24,7 +24,7 @@ protected function create_matrix(int $size): array $matrix[$size - 1][8] = 3; // non-empty-array&hasOffsetValue(8, 3)> - assertType('non-empty-list, 0|3>>', $matrix); + assertType('non-empty-list<(non-empty-array, 0|3>&hasOffsetValue(8, 3))|non-empty-list<0>>', $matrix); for ($i = 0; $i <= $size; $i++) { if ($matrix[$i][8] === 0) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10438.php b/tests/PHPStan/Analyser/nsrt/bug-10438.php index 89fbd0762c9..063da42957a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10438.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10438.php @@ -25,7 +25,7 @@ public function extract(SimpleXMLElement $data, string $type = 'Meta'): array $meta[$key] = []; assertType('array{}', $meta[$key]); foreach ($tag->{$valueName} as $value) { - assertType('list|string', $meta[$key]); + assertType('array{}|array{0: string, 1?: string}', $meta[$key]); $meta[$key][] = (string)$value; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10640.php b/tests/PHPStan/Analyser/nsrt/bug-10640.php index 12452341c6f..02ae9795d62 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10640.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10640.php @@ -13,7 +13,7 @@ foreach (toRem() as $del) { $changes[$add['id']]['del'][] = doSomething($del); } -assertType('array', $changes); +assertType('non-empty-array|array', $changes); foreach ($changes as $changeSet) { if (isset($changeSet['del'])) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11518-types.php b/tests/PHPStan/Analyser/nsrt/bug-11518-types.php index 6f63647dab2..bafee9b3a3a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11518-types.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11518-types.php @@ -17,7 +17,7 @@ function blah(array $a): array assertType('non-empty-array&hasOffset(\'thing\')', $a); } - assertType('non-empty-array&hasOffsetValue(\'thing\', mixed)', $a); + assertType("non-empty-array&hasOffset('thing')", $a); return $a; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12078.php b/tests/PHPStan/Analyser/nsrt/bug-12078.php index c1d22c1ab11..d7624f3ccae 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12078.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12078.php @@ -57,7 +57,7 @@ function main() ] */ - assertType("array, '3M'?: non-empty-list}>", $arrDataByKey); + assertType("non-empty-array, '3M': non-empty-list}>|array}>", $arrDataByKey); foreach ($arrDataByKey as $key => $arrDataByKeyForKey) { assertType("non-empty-array{'6M'?: non-empty-list, '3M'?: non-empty-list}", $arrDataByKeyForKey); echo [] === ($arrDataByKeyForKey['6M'] ?? []) ? 'No 6M data for key ' . $key . "\n" : 'We got 6M data for key ' . $key . "\n"; diff --git a/tests/PHPStan/Analyser/nsrt/bug-12274.php b/tests/PHPStan/Analyser/nsrt/bug-12274.php index 8768c8a33ff..7c46f3e213c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12274.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12274.php @@ -94,7 +94,7 @@ function testShouldLooseListbyAst(array $list, int $i): void $list[1+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('array, int>', $list); + assertType('non-empty-array, int>|list', $list); } /** @param list $list */ @@ -105,5 +105,5 @@ function testShouldLooseListbyAst2(array $list, int $i): void $list[2+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('array, int>', $list); + assertType('non-empty-array, int>|list', $list); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php index ecab6997b83..b9e38331e58 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13270b-php8.php @@ -19,11 +19,11 @@ public function parseData(array $data): array if (!array_key_exists('priceWithVat', $data['price'])) { $data['price']['priceWithVat'] = null; } - assertType("non-empty-array&hasOffsetValue('priceWithVat', mixed)", $data['price']); + assertType("non-empty-array&hasOffset('priceWithVat')", $data['price']); if (!array_key_exists('priceWithoutVat', $data['price'])) { $data['price']['priceWithoutVat'] = null; } - assertType("non-empty-array&hasOffsetValue('priceWithoutVat', mixed)&hasOffsetValue('priceWithVat', mixed)", $data['price']); + assertType("non-empty-array&hasOffset('priceWithoutVat')&hasOffset('priceWithVat')", $data['price']); } return $data; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13747.php b/tests/PHPStan/Analyser/nsrt/bug-13747.php index a1720ba36be..8b5d48a2865 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13747.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13747.php @@ -113,10 +113,10 @@ public function doMaybeBar($listA, $listB): void } if (2 <= count($list)) { - assertType('non-empty-list&hasOffsetValue(1, int|string)', $list); + assertType('(non-empty-list&hasOffsetValue(1, int))|(non-empty-list&hasOffsetValue(1, string))', $list); assertType('int<2, max>', count($list)); } else { - assertType('list', $list); + assertType('list|non-empty-list', $list); assertType('int<0, 1>', count($list)); } } @@ -134,10 +134,10 @@ public function doMaybeArray($aArray, $aList): void } if (2 <= count($listOrArray)) { - assertType('non-empty-array', $listOrArray); + assertType('non-empty-array|non-empty-list', $listOrArray); assertType('int<2, max>', count($listOrArray)); } else { - assertType('array', $listOrArray); + assertType('array|non-empty-list', $listOrArray); assertType('int<0, 1>', count($listOrArray)); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14245.php b/tests/PHPStan/Analyser/nsrt/bug-14245.php index 63333b5de2e..066dbede812 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14245.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14245.php @@ -63,7 +63,7 @@ function listKnownHugeSize(): void { assertType('non-empty-array, int>', $list); } - assertType('array, int>', $list); + assertType('non-empty-array, int>|list', $list); } function overwriteKeyLast(): void { diff --git a/tests/PHPStan/Analyser/nsrt/bug-4708.php b/tests/PHPStan/Analyser/nsrt/bug-4708.php index b6f23027223..97935a05649 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4708.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4708.php @@ -90,7 +90,7 @@ function GetASCConfig() assertType("non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result); } - assertType('non-empty-array', $result); + assertType("(non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int))|array{result: false, dberror: 'xyz'}", $result); return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5017.php b/tests/PHPStan/Analyser/nsrt/bug-5017.php index aa3abb30359..eeb2ef22411 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5017.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5017.php @@ -12,10 +12,10 @@ public function doFoo() $items = [0, 1, 2, 3, 4]; while ($items) { - assertType('non-empty-list>', $items); + assertType('array{0, 1, 2, 3, 4}|non-empty-list<2|3|4>', $items); $batch = array_splice($items, 0, 2); - assertType('list>', $items); - assertType('non-empty-list>', $batch); + assertType('list<2|3|4>', $items); + assertType('array{0, 1}|non-empty-list<2|3|4>', $batch); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-6173.php b/tests/PHPStan/Analyser/nsrt/bug-6173.php index 41ced55a638..b5702514efc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6173.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6173.php @@ -21,7 +21,7 @@ public function sayHello(array $ids1, array $ids2): bool $res[$id]['bar'] = $id; } - assertType('array', $res); + assertType('non-empty-array|array', $res); foreach ($res as $id => $r) { assertType('non-empty-array{foo?: int, bar?: int}', $r); return isset($r['foo']); diff --git a/tests/PHPStan/Analyser/nsrt/bug-9734.php b/tests/PHPStan/Analyser/nsrt/bug-9734.php index 52c3dd1c20a..456b59acef0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9734.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9734.php @@ -121,25 +121,25 @@ public function doFoo(array $a): void if ($this->assertIsStringList($a)) { assertType('list', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } if ($this->assertIsConstantList($a)) { assertType('array{string, string}', $a); } else { - assertType('array', $a); + assertType('non-empty-array|list', $a); } if ($this->assertIsOptionalConstantList($a)) { assertType('list{0?: string, 1?: string}', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } if ($this->assertIsStringArray($a)) { - assertType('array', $a); + assertType('non-empty-array|list{0?: string, 1?: string}', $a); } else { - assertType('non-empty-array', $a); + assertType('non-empty-array', $a); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug7856.php b/tests/PHPStan/Analyser/nsrt/bug7856.php index 8da8b7343e2..42ba3a4a934 100644 --- a/tests/PHPStan/Analyser/nsrt/bug7856.php +++ b/tests/PHPStan/Analyser/nsrt/bug7856.php @@ -10,7 +10,7 @@ function doFoo() { $endDate = new DateTimeImmutable('+1year'); do { - assertType("list", $intervals); + assertType("array{'+1week', '+1months', '+6months', '+17months'}|list<'+17months'|'+1months'|'+6months'>", $intervals); $periodEnd = $periodEnd->modify(array_shift($intervals)); } while (count($intervals) > 0 && $periodEnd->format('U') < $endDate); } diff --git a/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php b/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php index ca8dacc0a2b..463ba6b7dd6 100644 --- a/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php +++ b/tests/PHPStan/Analyser/nsrt/conditional-vars-php8.php @@ -12,9 +12,9 @@ class HelloWorld public function conditionalVarInTernary(array $innerHits): void { if (array_key_exists('nearest_premise', $innerHits) || array_key_exists('matching_premises', $innerHits)) { - assertType('non-empty-array', $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')", $innerHits); $x = array_key_exists('nearest_premise', $innerHits) - ? assertType("non-empty-array&hasOffset('nearest_premise')", $innerHits) + ? assertType("non-empty-array&hasOffset('matching_premises')&hasOffset('nearest_premise')", $innerHits) : assertType("non-empty-array", $innerHits); assertType('non-empty-array', $innerHits); @@ -26,9 +26,9 @@ public function conditionalVarInTernary(array $innerHits): void public function conditionalVarInIf(array $innerHits): void { if (array_key_exists('nearest_premise', $innerHits) || array_key_exists('matching_premises', $innerHits)) { - assertType('non-empty-array', $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')", $innerHits); if (array_key_exists('nearest_premise', $innerHits)) { - assertType("non-empty-array&hasOffset('nearest_premise')", $innerHits); + assertType("non-empty-array&hasOffset('matching_premises')&hasOffset('nearest_premise')", $innerHits); } else { assertType("non-empty-array", $innerHits); } diff --git a/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php b/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php index 0cf08820232..72e5cc83b64 100644 --- a/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php +++ b/tests/PHPStan/Analyser/nsrt/fgetcsv-php8.php @@ -8,5 +8,5 @@ function test($resource): void { - assertType('non-empty-list|false', fgetcsv($resource)); + assertType('array{null}|non-empty-list|false', fgetcsv($resource)); } diff --git a/tests/PHPStan/Analyser/nsrt/getopt.php b/tests/PHPStan/Analyser/nsrt/getopt.php index aae0e128e54..f0e095dee88 100644 --- a/tests/PHPStan/Analyser/nsrt/getopt.php +++ b/tests/PHPStan/Analyser/nsrt/getopt.php @@ -6,5 +6,5 @@ use function PHPStan\Testing\assertType; $opts = getopt("ab:c::", ["longopt1", "longopt2:", "longopt3::"], $restIndex); -assertType('(array|string|false>|false)', $opts); +assertType('(array|array>|array|false)', $opts); assertType('int<1, max>', $restIndex); diff --git a/tests/PHPStan/Analyser/nsrt/in-array-enum.php b/tests/PHPStan/Analyser/nsrt/in-array-enum.php index 66ae5799800..4ad2457a128 100644 --- a/tests/PHPStan/Analyser/nsrt/in-array-enum.php +++ b/tests/PHPStan/Analyser/nsrt/in-array-enum.php @@ -54,15 +54,15 @@ public function looseCheckEnumSpecifyHaystack(array $haystack): void } if (! in_array(rand() ? FooUnitEnum::A : FooUnitEnum::B, $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } if (! in_array(rand() ? 5 : 6, $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } if (! in_array(rand() ? 5 : rand(), $haystack, true)) { - assertType('array', $haystack); + assertType('array|non-empty-array', $haystack); } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count2.php b/tests/PHPStan/Analyser/nsrt/list-count2.php index 9f2937e9bd1..25f5c172167 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count2.php +++ b/tests/PHPStan/Analyser/nsrt/list-count2.php @@ -133,7 +133,7 @@ function sayEqualArrayShapeAfterNarrowedCount($arrA, array $arrB): void assertType('array{mixed, mixed, mixed}', $arrB); } assertType('array{int, int, int}', $arrA); - assertType('non-empty-list', $arrB); + assertType('non-empty-list&hasOffsetValue(1, mixed)', $arrB); } /** @@ -186,7 +186,7 @@ function skipRecursiveLeftCount($arrA, array $arrB): void assertType('array{mixed, mixed, mixed}', $arrB); } assertType('array{int, int, int}', $arrA); - assertType('non-empty-list', $arrB); + assertType('non-empty-list&hasOffsetValue(1, mixed)', $arrB); } /** diff --git a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index b8bdfe121c8..e115f5ec74e 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -83,11 +83,11 @@ public function nestedArrays(array $arr): void public function mixedArrays(array $arr): void { if (count($arr, COUNT_NORMAL) === 3) { - assertType("non-empty-array", $arr); // could be array{string, '', non-empty-string}|non-empty-array + assertType("array{string, '', non-empty-string}|non-empty-array", $arr); // could be array{string, '', non-empty-string}|non-empty-array } else { - assertType("array", $arr); // could be array{string, '', non-empty-string}|array + assertType("array{string, '', non-empty-string}|array", $arr); // could be array{string, '', non-empty-string}|array } - assertType("array", $arr); // could be array{string, '', non-empty-string}|array + assertType("array{string, '', non-empty-string}#1|array{string, '', non-empty-string}#2|array", $arr); // could be array{string, '', non-empty-string}|array } public function arrayIntRangeSize(): void diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index d8a68124cc7..a9db2c7cd11 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -122,7 +122,7 @@ function doOffsetCapture(string $s): void { function doUnknownFlags(string $s, int $flags): void { if (preg_match('/(foo)(bar)(baz)/xyz', $s, $matches, $flags)) { - assertType('array}|string|null>', $matches); + assertType('array}>|array}>|array', $matches); } assertType('array}|string|null>', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php index 431a78fcbf0..033c24fc897 100644 --- a/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php +++ b/tests/PHPStan/Analyser/nsrt/shopware-connection-profiler.php @@ -29,7 +29,7 @@ public function getGroupedQueries(): void $connectionGroupedQueries[$key]['index'] = $i; // "Explain query" relies on query index in 'queries'. } - assertType("non-empty-array, count: 0, index: int}|array{sql: string, executionMS: float, types: array, count: int<1, max>, index: int}>", $connectionGroupedQueries); + assertType('non-empty-array, count: int<1, max>, index: int}>|non-empty-array, count: 0, index: int}>', $connectionGroupedQueries); $connectionGroupedQueries[$key]['executionMS'] += $query['executionMS']; assertType("non-empty-array, count: int<0, max>, index: int}>", $connectionGroupedQueries); ++$connectionGroupedQueries[$key]['count']; diff --git a/tests/PHPStan/Analyser/nsrt/shuffle.php b/tests/PHPStan/Analyser/nsrt/shuffle.php index 6b699e598ae..9a77d15f669 100644 --- a/tests/PHPStan/Analyser/nsrt/shuffle.php +++ b/tests/PHPStan/Analyser/nsrt/shuffle.php @@ -106,10 +106,10 @@ public function constantArrays6(array $arr): void { /** @var array{foo?: 1, bar: 2, }|array{baz: 3, foobar?: 4} $arr */ shuffle($arr); - assertType('non-empty-list<1|2|3|4>', $arr); + assertType('non-empty-list<1|2>|non-empty-list<3|4>', $arr); assertNativeType('list', $arr); assertType('non-empty-list<0|1>', array_keys($arr)); - assertType('non-empty-list<1|2|3|4>', array_values($arr)); + assertType('non-empty-list<1|2>|non-empty-list<3|4>', array_values($arr)); } public function mixed($arr): void diff --git a/tests/PHPStan/Analyser/nsrt/sort.php b/tests/PHPStan/Analyser/nsrt/sort.php index 47f9b3f030b..d87ef8ca7bc 100644 --- a/tests/PHPStan/Analyser/nsrt/sort.php +++ b/tests/PHPStan/Analyser/nsrt/sort.php @@ -71,18 +71,18 @@ public function constantArrayUnion(): void $arr1 = $arr; sort($arr1); - assertType('non-empty-list<1|2|5>', $arr1); - assertNativeType('non-empty-list<1|2|5>', $arr1); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr1); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr1); $arr2 = $arr; rsort($arr2); - assertType('non-empty-list<1|2|5>', $arr2); - assertNativeType('non-empty-list<1|2|5>', $arr2); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr2); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr2); $arr3 = $arr; usort($arr3, fn(int $a, int $b) => $a <=> $b); - assertType('non-empty-list<1|2|5>', $arr3); - assertNativeType('non-empty-list<1|2|5>', $arr3); + assertType('non-empty-list<1|5>|non-empty-list<2>', $arr3); + assertNativeType('non-empty-list<1|5>|non-empty-list<2>', $arr3); } /** @param array $arr */ diff --git a/tests/PHPStan/Rules/Arrays/data/bug-6000.php b/tests/PHPStan/Rules/Arrays/data/bug-6000.php index c409cbfda10..db68dce1ae1 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-6000.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-6000.php @@ -9,7 +9,7 @@ function (): void { $data = []; foreach ($data as $key => $value) { - assertType('array|string, array|string>', $data[$key]); + assertType('array|string>|list', $data[$key]); if ($key === 'classmap') { assertType('list', $data[$key]); assertType('list', $value); diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8467a.php b/tests/PHPStan/Rules/Arrays/data/bug-8467a.php index bba055bf546..e58e1c40b78 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-8467a.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-8467a.php @@ -27,7 +27,7 @@ public function foo (CompletePackageInterface $package): void { if (\count($package->getAutoload()) > 0) { $autoloadConfig = $package->getAutoload(); foreach ($autoloadConfig as $type => $autoloads) { - assertType('array|string, array|string>', $autoloadConfig[$type]); + assertType('array|string>|list', $autoloadConfig[$type]); if ($type === 'psr-0' || $type === 'psr-4') { } elseif ($type === 'classmap') { diff --git a/tests/PHPStan/Rules/Comparison/data/bug-4708.php b/tests/PHPStan/Rules/Comparison/data/bug-4708.php index 2afa1643402..22d52e7bc4b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-4708.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-4708.php @@ -88,7 +88,7 @@ function GetASCConfig() } } - assertType("non-empty-array", $result); + assertType("(non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int))|array{result: false, dberror: 'xyz'}", $result); return $result; } diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index fa52a6fc038..7b49cc74aff 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -465,6 +465,29 @@ static zv::Val intersectWith(zval *first, TypeList &rest) return pt_type_combinator_intersect((uint32_t) args.size(), args.data()); } +/* BleedingEdgeToggle::isBleedingEdge(): the private static the final class + * returns, read through the engine's static-property path under the + * class's own scope (the same value the method returns, without the + * userland frame); false = pending exception */ +[[nodiscard]] static bool isBleedingEdge(bool &out) +{ + zend_class_entry *ce = pt_class(PT_CLASS_BLEEDING_EDGE_TOGGLE); + if (UNEXPECTED(ce == NULL)) return false; + auto previousScope = EG(fake_scope); /* const from PHP 8.4 */ + EG(fake_scope) = ce; + zval *value = zend_read_static_property(ce, PT_LC("bleedingEdge"), 1); + EG(fake_scope) = previousScope; + if (EXPECTED(value != NULL && (Z_TYPE_P(value) == IS_TRUE || Z_TYPE_P(value) == IS_FALSE))) { + out = Z_TYPE_P(value) == IS_TRUE; + return true; + } + if (UNEXPECTED(EG(exception))) return false; + zv::Val result = pt_type_call_static(PT_CLASS_BLEEDING_EDGE_TOGGLE, PT_LC("isbleedingedge"), 0, NULL); + if (UNEXPECTED(result.isUndef())) return false; + out = zend_is_true(result.raw()); + return true; +} + /* $type instanceof NeverType && !$type->isExplicit(); -1 = pending * exception */ static int isImplicitNever(zval *type) @@ -2131,6 +2154,36 @@ class TypeCombinator return zv::Val(std::move(result)); } + bool hasEmptyConstantArray = false; + for (zv::Val &arrayType : arrayTypes) { + int empty = isEmptyConstantArray(arrayType.raw()); + PT_FAIL_IF_NEG(empty); + if (empty == 1) { + hasEmptyConstantArray = true; + break; + } + } + + bool bleedingEdge; + if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); + if (bleedingEdge && !hasEmptyConstantArray) { + // Keep each array member distinct (e.g. `list|list` rather + // than `list`). Subsumption is handled by the outer union() + // loop; the `array{} | non-empty-array` -> `array` simplification + // is not expressible here and falls through to the old collapse path. + zv::Arr results = zv::Arr::create((uint32_t) arrayTypes.size()); + for (zv::Val &arrayType : arrayTypes) { + if (accessoryTypes.empty()) { + results.push(arrayType.ref()); + continue; + } + zv::Val intersected = intersectWith(arrayType.raw(), accessoryTypes); + PT_FAIL_IF_UNDEF(intersected); + results.push(std::move(intersected)); + } + return zv::Val(std::move(results)); + } + zval *templateArrayType = NULL; for (zv::Val &arrayType : arrayTypes) { if (!isInstance(arrayType.raw(), pt_ce_template_array_type)) { From fee353e8ed545f00cc04e7545422b0d7140bb1dc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 21:55:32 +0200 Subject: [PATCH 2/9] TMP WIP: run keep-separate for everyone, not just bleeding edge Drops the BleedingEdgeToggle gate so CI exercises the new path on all configs. Revert before merging. Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6260-7460-a52b-a418f709e4bf --- src/Type/TypeCombinator.php | 4 ++-- turbo-ext/src/TypeCombinator.cpp | 28 ++-------------------------- 2 files changed, 4 insertions(+), 28 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 8433c1acc87..c9c5db00eac 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -2,7 +2,6 @@ namespace PHPStan\Type; -use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\TrinaryLogic; use PHPStan\Turbo\ShadowedByTurboExtension; use PHPStan\Turbo\TurboExtensionEnabler; @@ -1274,7 +1273,8 @@ private static function processArrayTypes(array $arrayTypes): array } } - if (BleedingEdgeToggle::isBleedingEdge() && !$hasEmptyConstantArray) { + // TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path + if (!$hasEmptyConstantArray) { // Keep each array member distinct (e.g. `list|list` rather // than `list`). Subsumption is handled by the outer union() // loop; the `array{} | non-empty-array` -> `array` simplification diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 7b49cc74aff..f2831247e0f 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -465,29 +465,6 @@ static zv::Val intersectWith(zval *first, TypeList &rest) return pt_type_combinator_intersect((uint32_t) args.size(), args.data()); } -/* BleedingEdgeToggle::isBleedingEdge(): the private static the final class - * returns, read through the engine's static-property path under the - * class's own scope (the same value the method returns, without the - * userland frame); false = pending exception */ -[[nodiscard]] static bool isBleedingEdge(bool &out) -{ - zend_class_entry *ce = pt_class(PT_CLASS_BLEEDING_EDGE_TOGGLE); - if (UNEXPECTED(ce == NULL)) return false; - auto previousScope = EG(fake_scope); /* const from PHP 8.4 */ - EG(fake_scope) = ce; - zval *value = zend_read_static_property(ce, PT_LC("bleedingEdge"), 1); - EG(fake_scope) = previousScope; - if (EXPECTED(value != NULL && (Z_TYPE_P(value) == IS_TRUE || Z_TYPE_P(value) == IS_FALSE))) { - out = Z_TYPE_P(value) == IS_TRUE; - return true; - } - if (UNEXPECTED(EG(exception))) return false; - zv::Val result = pt_type_call_static(PT_CLASS_BLEEDING_EDGE_TOGGLE, PT_LC("isbleedingedge"), 0, NULL); - if (UNEXPECTED(result.isUndef())) return false; - out = zend_is_true(result.raw()); - return true; -} - /* $type instanceof NeverType && !$type->isExplicit(); -1 = pending * exception */ static int isImplicitNever(zval *type) @@ -2164,9 +2141,8 @@ class TypeCombinator } } - bool bleedingEdge; - if (UNEXPECTED(!isBleedingEdge(bleedingEdge))) return zv::Val(); - if (bleedingEdge && !hasEmptyConstantArray) { + // TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path + if (!hasEmptyConstantArray) { // Keep each array member distinct (e.g. `list|list` rather // than `list`). Subsumption is handled by the outer union() // loop; the `array{} | non-empty-array` -> `array` simplification From b7850b802b1702fb9b8777f1b4a8f491989edabb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 08:58:45 +0200 Subject: [PATCH 3/9] Reduce keep-separate output: dedupe + member-count budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-separate previously returned $arrayTypes verbatim, which let analysis-emergent unions accumulate dozens of near-identical members (7 byte-identical entries in bug-7903; 35 distinct non-empty-array<…, array{Gemarkung: 'X'}> shapes in bug-10538 differing only in one inner constant; 4-and-growing same-key shapes with stacking hasOffsetValue accessories in the optional-properties repro). Each later union() pays O(N²) over them and the describes balloon. Mirrors optimizeConstantArrays(): try precise, degrade past a budget. 1. Dedupe by describe(VerbosityLevel::cache()) — cheap, unconditional. 2. Cap at KEEP_SEPARATE_ARRAYS_LIMIT (4) distinct shapes after dedup; larger sets are almost always intermediate analysis state and fall through to the old collapse. Bench (vs base 2.1.x): bug-7903 5.9s → 5.1s (-14%) bug-8503 5.0s → 4.2s (-16%) bug-10538 5.8s → 4.0s (-31%) repro 6.9s → 6.1s (-12%) Re-baselines narrow-tagged-union.php whose 3-member union now stays distinct under the new policy. Adds the runaway repro as a bench file. --- src/Type/TypeCombinator.php | 38 +- .../Analyser/nsrt/narrow-tagged-union.php | 2 +- .../bench/data/optional-properties-blowup.php | 430 ++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 45 +- 4 files changed, 493 insertions(+), 22 deletions(-) create mode 100644 tests/bench/data/optional-properties-blowup.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index c9c5db00eac..83ff232e181 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -57,6 +57,8 @@ final class TypeCombinator { + private const KEEP_SEPARATE_ARRAYS_LIMIT = 4; + /** * Turned on by TurboExtensionEnabler when the native extension is active, so that * union(), intersect() and remove() route through the memoizing TypeCombinatorCache. @@ -1274,18 +1276,36 @@ private static function processArrayTypes(array $arrayTypes): array } // TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path + // Keep distinct array shapes (e.g. `list|list` rather than + // `list`), but reduce the result before returning so analysis- + // emergent intermediate state (foreach over mixed[], deeply nested + // ArrayDimFetch writes, sequential `if ($x !== null) $arr['x'] = $x;`) does + // not blow up. `array{} | non-empty-array` -> `array` still goes to + // the old collapse path. if (!$hasEmptyConstantArray) { - // Keep each array member distinct (e.g. `list|list` rather - // than `list`). Subsumption is handled by the outer union() - // loop; the `array{} | non-empty-array` -> `array` simplification - // is not expressible here and falls through to the old collapse path. - $results = []; + // Dedupe identical members by describe() — many call sites feed the same + // shape multiple times via parallel control flow (cf. bug-7903 with 7 + // byte-identical members). Cheap (cached describe), unconditional. + $deduped = []; foreach ($arrayTypes as $arrayType) { - $results[] = $accessoryTypes === [] - ? $arrayType - : self::intersect($arrayType, ...$accessoryTypes); + $deduped[$arrayType->describe(VerbosityLevel::cache())] = $arrayType; + } + $deduped = array_values($deduped); + + // Budget: when more distinct shapes remain than a small union would have, + // they are almost certainly analysis-emergent rather than user-written; + // fall through to the old collapse path to keep downstream tractable. + // Mirrors optimizeConstantArrays() which generalizes once a similar + // budget (256 constant value types) is exceeded. + if (count($deduped) <= self::KEEP_SEPARATE_ARRAYS_LIMIT) { + $results = []; + foreach ($deduped as $arrayType) { + $results[] = $accessoryTypes === [] + ? $arrayType + : self::intersect($arrayType, ...$accessoryTypes); + } + return $results; } - return $results; } $templateArrayType = null; diff --git a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index e115f5ec74e..6095fb5932d 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -87,7 +87,7 @@ public function mixedArrays(array $arr): void } else { assertType("array{string, '', non-empty-string}|array", $arr); // could be array{string, '', non-empty-string}|array } - assertType("array{string, '', non-empty-string}#1|array{string, '', non-empty-string}#2|array", $arr); // could be array{string, '', non-empty-string}|array + assertType("array{string, '', non-empty-string}|array", $arr); // could be array{string, '', non-empty-string}|array } public function arrayIntRangeSize(): void diff --git a/tests/bench/data/optional-properties-blowup.php b/tests/bench/data/optional-properties-blowup.php new file mode 100644 index 00000000000..0e94f86b4d8 --- /dev/null +++ b/tests/bench/data/optional-properties-blowup.php @@ -0,0 +1,430 @@ += 8.2 + +declare(strict_types=1); + +namespace OptionalPropertiesBlowup; + +class OwnerModel {}; +class PermissionsModel {}; + +final readonly class TemplateRepositoryModel implements \JsonSerializable +{ + public function __construct( + public null|int $id = null, + public null|string $node_id = null, + public null|string $name = null, + public null|string $full_name = null, + public null|OwnerModel $owner = null, + public null|bool $private = null, + public null|string $html_url = null, + public null|string $description = null, + public null|bool $fork = null, + public null|string $url = null, + public null|string $archive_url = null, + public null|string $assignees_url = null, + public null|string $blobs_url = null, + public null|string $branches_url = null, + public null|string $collaborators_url = null, + public null|string $comments_url = null, + public null|string $commits_url = null, + public null|string $compare_url = null, + public null|string $contents_url = null, + public null|string $contributors_url = null, + public null|string $deployments_url = null, + public null|string $downloads_url = null, + public null|string $events_url = null, + public null|string $forks_url = null, + public null|string $git_commits_url = null, + public null|string $git_refs_url = null, + public null|string $git_tags_url = null, + public null|string $git_url = null, + public null|string $issue_comment_url = null, + public null|string $issue_events_url = null, + public null|string $issues_url = null, + public null|string $keys_url = null, + public null|string $labels_url = null, + public null|string $languages_url = null, + public null|string $merges_url = null, + public null|string $milestones_url = null, + public null|string $notifications_url = null, + public null|string $pulls_url = null, + public null|string $releases_url = null, + public null|string $ssh_url = null, + public null|string $stargazers_url = null, + public null|string $statuses_url = null, + public null|string $subscribers_url = null, + public null|string $subscription_url = null, + public null|string $tags_url = null, + public null|string $teams_url = null, + public null|string $trees_url = null, + public null|string $clone_url = null, + public null|string $mirror_url = null, + public null|string $hooks_url = null, + public null|string $svn_url = null, + public null|string $homepage = null, + public null|string $language = null, + public null|int $forks_count = null, + public null|int $stargazers_count = null, + public null|int $watchers_count = null, + public null|int $size = null, + public null|string $default_branch = null, + public null|int $open_issues_count = null, + public null|bool $is_template = null, + /** @var null|list */ + public null|array $topics = null, + public null|bool $has_issues = null, + public null|bool $has_projects = null, + public null|bool $has_wiki = null, + public null|bool $has_pages = null, + public null|bool $has_downloads = null, + public null|bool $archived = null, + public null|bool $disabled = null, + public null|string $visibility = null, + public null|string $pushed_at = null, + public null|string $created_at = null, + public null|string $updated_at = null, + public null|PermissionsModel $permissions = null, + public null|bool $allow_rebase_merge = null, + public null|string $template_repository = null, + public null|string $temp_clone_token = null, + public null|bool $allow_squash_merge = null, + public null|bool $delete_branch_on_merge = null, + public null|bool $allow_merge_commit = null, + public null|int $subscribers_count = null, + public null|int $network_count = null, + ) {} + + /** + * @return array{ + * 'id'?: int, + * 'node_id'?: string, + * 'name'?: string, + * 'full_name'?: string, + * 'owner'?: OwnerModel, + * 'private'?: bool, + * 'html_url'?: string, + * 'description'?: string, + * 'fork'?: bool, + * 'url'?: string, + * 'archive_url'?: string, + * 'assignees_url'?: string, + * 'blobs_url'?: string, + * 'branches_url'?: string, + * 'collaborators_url'?: string, + * 'comments_url'?: string, + * 'commits_url'?: string, + * 'compare_url'?: string, + * 'contents_url'?: string, + * 'contributors_url'?: string, + * 'deployments_url'?: string, + * 'downloads_url'?: string, + * 'events_url'?: string, + * 'forks_url'?: string, + * 'git_commits_url'?: string, + * 'git_refs_url'?: string, + * 'git_tags_url'?: string, + * 'git_url'?: string, + * 'issue_comment_url'?: string, + * 'issue_events_url'?: string, + * 'issues_url'?: string, + * 'keys_url'?: string, + * 'labels_url'?: string, + * 'languages_url'?: string, + * 'merges_url'?: string, + * 'milestones_url'?: string, + * 'notifications_url'?: string, + * 'pulls_url'?: string, + * 'releases_url'?: string, + * 'ssh_url'?: string, + * 'stargazers_url'?: string, + * 'statuses_url'?: string, + * 'subscribers_url'?: string, + * 'subscription_url'?: string, + * 'tags_url'?: string, + * 'teams_url'?: string, + * 'trees_url'?: string, + * 'clone_url'?: string, + * 'mirror_url'?: string, + * 'hooks_url'?: string, + * 'svn_url'?: string, + * 'homepage'?: string, + * 'language'?: string, + * 'forks_count'?: int, + * 'stargazers_count'?: int, + * 'watchers_count'?: int, + * 'size'?: int, + * 'default_branch'?: string, + * 'open_issues_count'?: int, + * 'is_template'?: bool, + * 'topics'?: list, + * 'has_issues'?: bool, + * 'has_projects'?: bool, + * 'has_wiki'?: bool, + * 'has_pages'?: bool, + * 'has_downloads'?: bool, + * 'archived'?: bool, + * 'disabled'?: bool, + * 'visibility'?: string, + * 'pushed_at'?: string, + * 'created_at'?: string, + * 'updated_at'?: string, + * 'permissions'?: PermissionsModel, + * 'allow_rebase_merge'?: bool, + * 'template_repository'?: string, + * 'temp_clone_token'?: string, + * 'allow_squash_merge'?: bool, + * 'delete_branch_on_merge'?: bool, + * 'allow_merge_commit'?: bool, + * 'subscribers_count'?: int, + * 'network_count'?: int, + * } + */ + public function jsonSerialize(): array + { + $properties = []; + if ($this->id !== null) { + $properties['id'] = $this->id; + } + if ($this->node_id !== null) { + $properties['node_id'] = $this->node_id; + } + if ($this->name !== null) { + $properties['name'] = $this->name; + } + if ($this->full_name !== null) { + $properties['full_name'] = $this->full_name; + } + if ($this->owner !== null) { + $properties['owner'] = $this->owner; + } + if ($this->private !== null) { + $properties['private'] = $this->private; + } + if ($this->html_url !== null) { + $properties['html_url'] = $this->html_url; + } + if ($this->description !== null) { + $properties['description'] = $this->description; + } + if ($this->fork !== null) { + $properties['fork'] = $this->fork; + } + if ($this->url !== null) { + $properties['url'] = $this->url; + } + if ($this->archive_url !== null) { + $properties['archive_url'] = $this->archive_url; + } + if ($this->assignees_url !== null) { + $properties['assignees_url'] = $this->assignees_url; + } + if ($this->blobs_url !== null) { + $properties['blobs_url'] = $this->blobs_url; + } + if ($this->branches_url !== null) { + $properties['branches_url'] = $this->branches_url; + } + if ($this->collaborators_url !== null) { + $properties['collaborators_url'] = $this->collaborators_url; + } + if ($this->comments_url !== null) { + $properties['comments_url'] = $this->comments_url; + } + if ($this->commits_url !== null) { + $properties['commits_url'] = $this->commits_url; + } + if ($this->compare_url !== null) { + $properties['compare_url'] = $this->compare_url; + } + if ($this->contents_url !== null) { + $properties['contents_url'] = $this->contents_url; + } + if ($this->contributors_url !== null) { + $properties['contributors_url'] = $this->contributors_url; + } + if ($this->deployments_url !== null) { + $properties['deployments_url'] = $this->deployments_url; + } + if ($this->downloads_url !== null) { + $properties['downloads_url'] = $this->downloads_url; + } + if ($this->events_url !== null) { + $properties['events_url'] = $this->events_url; + } + if ($this->forks_url !== null) { + $properties['forks_url'] = $this->forks_url; + } + if ($this->git_commits_url !== null) { + $properties['git_commits_url'] = $this->git_commits_url; + } + if ($this->git_refs_url !== null) { + $properties['git_refs_url'] = $this->git_refs_url; + } + if ($this->git_tags_url !== null) { + $properties['git_tags_url'] = $this->git_tags_url; + } + if ($this->git_url !== null) { + $properties['git_url'] = $this->git_url; + } + if ($this->issue_comment_url !== null) { + $properties['issue_comment_url'] = $this->issue_comment_url; + } + if ($this->issue_events_url !== null) { + $properties['issue_events_url'] = $this->issue_events_url; + } + if ($this->issues_url !== null) { + $properties['issues_url'] = $this->issues_url; + } + if ($this->keys_url !== null) { + $properties['keys_url'] = $this->keys_url; + } + if ($this->labels_url !== null) { + $properties['labels_url'] = $this->labels_url; + } + if ($this->languages_url !== null) { + $properties['languages_url'] = $this->languages_url; + } + if ($this->merges_url !== null) { + $properties['merges_url'] = $this->merges_url; + } + if ($this->milestones_url !== null) { + $properties['milestones_url'] = $this->milestones_url; + } + if ($this->notifications_url !== null) { + $properties['notifications_url'] = $this->notifications_url; + } + if ($this->pulls_url !== null) { + $properties['pulls_url'] = $this->pulls_url; + } + if ($this->releases_url !== null) { + $properties['releases_url'] = $this->releases_url; + } + if ($this->ssh_url !== null) { + $properties['ssh_url'] = $this->ssh_url; + } + if ($this->stargazers_url !== null) { + $properties['stargazers_url'] = $this->stargazers_url; + } + if ($this->statuses_url !== null) { + $properties['statuses_url'] = $this->statuses_url; + } + if ($this->subscribers_url !== null) { + $properties['subscribers_url'] = $this->subscribers_url; + } + if ($this->subscription_url !== null) { + $properties['subscription_url'] = $this->subscription_url; + } + if ($this->tags_url !== null) { + $properties['tags_url'] = $this->tags_url; + } + if ($this->teams_url !== null) { + $properties['teams_url'] = $this->teams_url; + } + if ($this->trees_url !== null) { + $properties['trees_url'] = $this->trees_url; + } + if ($this->clone_url !== null) { + $properties['clone_url'] = $this->clone_url; + } + if ($this->mirror_url !== null) { + $properties['mirror_url'] = $this->mirror_url; + } + if ($this->hooks_url !== null) { + $properties['hooks_url'] = $this->hooks_url; + } + if ($this->svn_url !== null) { + $properties['svn_url'] = $this->svn_url; + } + if ($this->homepage !== null) { + $properties['homepage'] = $this->homepage; + } + if ($this->language !== null) { + $properties['language'] = $this->language; + } + if ($this->forks_count !== null) { + $properties['forks_count'] = $this->forks_count; + } + if ($this->stargazers_count !== null) { + $properties['stargazers_count'] = $this->stargazers_count; + } + if ($this->watchers_count !== null) { + $properties['watchers_count'] = $this->watchers_count; + } + if ($this->size !== null) { + $properties['size'] = $this->size; + } + if ($this->default_branch !== null) { + $properties['default_branch'] = $this->default_branch; + } + if ($this->open_issues_count !== null) { + $properties['open_issues_count'] = $this->open_issues_count; + } + if ($this->is_template !== null) { + $properties['is_template'] = $this->is_template; + } + if ($this->topics !== null) { + $properties['topics'] = $this->topics; + } + if ($this->has_issues !== null) { + $properties['has_issues'] = $this->has_issues; + } + if ($this->has_projects !== null) { + $properties['has_projects'] = $this->has_projects; + } + if ($this->has_wiki !== null) { + $properties['has_wiki'] = $this->has_wiki; + } + if ($this->has_pages !== null) { + $properties['has_pages'] = $this->has_pages; + } + if ($this->has_downloads !== null) { + $properties['has_downloads'] = $this->has_downloads; + } + if ($this->archived !== null) { + $properties['archived'] = $this->archived; + } + if ($this->disabled !== null) { + $properties['disabled'] = $this->disabled; + } + if ($this->visibility !== null) { + $properties['visibility'] = $this->visibility; + } + if ($this->pushed_at !== null) { + $properties['pushed_at'] = $this->pushed_at; + } + if ($this->created_at !== null) { + $properties['created_at'] = $this->created_at; + } + if ($this->updated_at !== null) { + $properties['updated_at'] = $this->updated_at; + } + if ($this->permissions !== null) { + $properties['permissions'] = $this->permissions; + } + if ($this->allow_rebase_merge !== null) { + $properties['allow_rebase_merge'] = $this->allow_rebase_merge; + } + if ($this->template_repository !== null) { + $properties['template_repository'] = $this->template_repository; + } + if ($this->temp_clone_token !== null) { + $properties['temp_clone_token'] = $this->temp_clone_token; + } + if ($this->allow_squash_merge !== null) { + $properties['allow_squash_merge'] = $this->allow_squash_merge; + } + if ($this->delete_branch_on_merge !== null) { + $properties['delete_branch_on_merge'] = $this->delete_branch_on_merge; + } + if ($this->allow_merge_commit !== null) { + $properties['allow_merge_commit'] = $this->allow_merge_commit; + } + if ($this->subscribers_count !== null) { + $properties['subscribers_count'] = $this->subscribers_count; + } + if ($this->network_count !== null) { + $properties['network_count'] = $this->network_count; + } + return $properties; + } +} diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index f2831247e0f..574a3f42504 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -37,6 +37,8 @@ namespace sigs = ptdecl::TypeCombinator::sig; zend_class_entry *pt_ce_type_combinator = nullptr; +#define PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT 4 + /* an UNDEF zv::Val / a negative int = pending exception */ #define PT_FAIL_IF_UNDEF(v) \ do { \ @@ -2142,22 +2144,40 @@ class TypeCombinator } // TMP WIP: run for everyone, not just bleedingEdge, so CI exercises the new path + // Keep distinct array shapes (e.g. `list|list` rather than + // `list`), but reduce the result before returning so analysis- + // emergent intermediate state does not blow up. `array{} | non-empty-array` + // -> `array` still goes to the old collapse path. if (!hasEmptyConstantArray) { - // Keep each array member distinct (e.g. `list|list` rather - // than `list`). Subsumption is handled by the outer union() - // loop; the `array{} | non-empty-array` -> `array` simplification - // is not expressible here and falls through to the old collapse path. - zv::Arr results = zv::Arr::create((uint32_t) arrayTypes.size()); + // Dedupe identical members by describe(): the last member of a + // description wins, at the position of the first one, like the + // twin's `$deduped[$description] = $arrayType` + Level cacheLevel(PT_LC("cache")); + zv::Arr deduped = zv::Arr::create((uint32_t) arrayTypes.size()); for (zv::Val &arrayType : arrayTypes) { - if (accessoryTypes.empty()) { - results.push(arrayType.ref()); - continue; + zv::Val description = describe(arrayType.raw(), cacheLevel); + zend_string *key = describedKey(description); + if (UNEXPECTED(key == NULL)) return zv::Val(); + deduped.set(key, zv::Val::copyOf(arrayType.ref())); + } + + // Budget: when more distinct shapes remain than a small union would have, + // they are almost certainly analysis-emergent rather than user-written; + // fall through to the old collapse path to keep downstream tractable. + if (countOf(deduped.raw()) <= PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT) { + zv::Arr results = zv::Arr::create(countOf(deduped.raw())); + for (zv::ArrayEntry entry : zv::ArrRef(deduped.raw())) { + zval *arrayType = entry.value().raw(); + if (accessoryTypes.empty()) { + results.push(zv::Ref(arrayType)); + continue; + } + zv::Val intersected = intersectWith(arrayType, accessoryTypes); + PT_FAIL_IF_UNDEF(intersected); + results.push(std::move(intersected)); } - zv::Val intersected = intersectWith(arrayType.raw(), accessoryTypes); - PT_FAIL_IF_UNDEF(intersected); - results.push(std::move(intersected)); + return zv::Val(std::move(results)); } - return zv::Val(std::move(results)); } zval *templateArrayType = NULL; @@ -4449,6 +4469,7 @@ PT_MINIT_REGISTRATION(pt_register_type_combinator) reg::Class cls("PHPStan\\Type\\TypeCombinator"); ptdecl::TypeCombinator::declareClass(cls); + cls.privateClassConstantLong("KEEP_SEPARATE_ARRAYS_LIMIT", PT_TC_KEEP_SEPARATE_ARRAYS_LIMIT); cls.privateStaticTypedPropertyDefaultNull("cacheEnabled", MAY_BE_BOOL); cls.method(sigs::clearCache, [](INTERNAL_FUNCTION_PARAMETERS) { From 9b736aef96677d90e7bc3bd721b7033298af0f3f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:19:06 +0200 Subject: [PATCH 4/9] =?UTF-8?q?Dedupe=20stripped=20arrays,=20keep=20origin?= =?UTF-8?q?als=20=E2=80=94=20preserve=20per-member=20accessories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous dedupe used the full describe (including accessories), so members differing only in stacked hasOffsetValue accessories never collapsed (cf. the optional-properties repro: 4 same-shape members each with a different combination of hasOffsetValue('has_wiki') / hasOffsetValue('has_pages')). Now: processArrayAccessoryTypes returns a tuple [stripped arrays, common accessories]. Dedupe key is the stripped describe; the value kept is the *original* member (with its full accessories), so per-member non-empty / list / etc. survive on whichever original wins the collision. Members that share an underlying shape but only differ in narrowing accessories collapse to one — making the optional-properties union shrink immediately instead of growing. Re-baselines 20 fixtures: most now retain non-empty / list info that the previous full-describe dedupe lost (precision improvement). --- src/Type/TypeCombinator.php | 30 +++++++++----- tests/PHPStan/Analyser/nsrt/binary.php | 8 ++-- tests/PHPStan/Analyser/nsrt/bug-10650.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12274.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-13312.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-13705.php | 6 +-- tests/PHPStan/Analyser/nsrt/bug-14084.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14319.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-2648.php | 6 +-- tests/PHPStan/Analyser/nsrt/bug-6488.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9662.php | 10 ++--- .../Analyser/nsrt/conditional-types.php | 4 +- .../Analyser/nsrt/count-const-array-2.php | 2 +- tests/PHPStan/Analyser/nsrt/for-loop-expr.php | 6 +-- tests/PHPStan/Analyser/nsrt/list-count.php | 4 +- .../data/slevomat-foreach-unset-bug.php | 4 +- .../PHPStan/Rules/Methods/data/bug-12927.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-7511.php | 2 +- .../Rules/Variables/data/bug-13921.php | 12 +++--- .../Rules/Variables/data/bug-14124.php | 2 +- turbo-ext/src/TypeCombinator.cpp | 39 +++++++++++++------ 21 files changed, 91 insertions(+), 62 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 83ff232e181..727ce656419 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1096,22 +1096,27 @@ private static function intersectWithSubtractedType( } /** - * @param Type[] $arrayTypes - * @return list + * @param list $arrayTypes + * @return array{list, list} tuple of [arrays with accessory wrappers stripped, common accessory types] */ private static function processArrayAccessoryTypes(array $arrayTypes): array { $isIterableAtLeastOnce = []; $accessoryTypes = []; + $strippedArrays = []; foreach ($arrayTypes as $i => $arrayType) { $isIterableAtLeastOnce[] = $arrayType->isIterableAtLeastOnce(); if ($arrayType instanceof IntersectionType) { + $nonAccessoryInner = []; + $skipStrip = false; foreach ($arrayType->getTypes() as $innerType) { if ($innerType instanceof TemplateType) { + $skipStrip = true; break; } if (!($innerType instanceof AccessoryType) && !($innerType instanceof CallableType)) { + $nonAccessoryInner[] = $innerType; continue; } if ($innerType instanceof HasOffsetType) { @@ -1124,6 +1129,9 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array $accessoryTypes[$innerType->describe(VerbosityLevel::cache())][$i] = $innerType; } + $strippedArrays[] = $skipStrip || count($nonAccessoryInner) !== 1 ? $arrayType : $nonAccessoryInner[0]; + } else { + $strippedArrays[] = $arrayType; } if (!$arrayType->isConstantArray()->yes()) { @@ -1169,7 +1177,7 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array $commonAccessoryTypes[] = new NonEmptyArrayType(); } - return $commonAccessoryTypes; + return [$strippedArrays, $commonAccessoryTypes]; } /** @@ -1220,7 +1228,7 @@ private static function processArrayTypes(array $arrayTypes): array return [$emptyArray, ...self::processArrayTypes($otherArrayTypes)]; } - $accessoryTypes = self::processArrayAccessoryTypes($arrayTypes); + [$strippedArrays, $accessoryTypes] = self::processArrayAccessoryTypes($arrayTypes); if (count($arrayTypes) === 1) { return [ @@ -1283,12 +1291,16 @@ private static function processArrayTypes(array $arrayTypes): array // not blow up. `array{} | non-empty-array` -> `array` still goes to // the old collapse path. if (!$hasEmptyConstantArray) { - // Dedupe identical members by describe() — many call sites feed the same - // shape multiple times via parallel control flow (cf. bug-7903 with 7 - // byte-identical members). Cheap (cached describe), unconditional. + // Dedupe using the stripped (accessory-free) describe as the key, but + // keep the original (with accessories) as the value. This collapses + // members that share an underlying shape but differ only in stacked + // per-element accessories like hasOffsetValue (cf. the optional- + // properties repro: 4 same-shape members differing only in stacked + // hasOffsetValue accessories), while preserving per-member accessories + // like list / non-empty on whichever original survives. $deduped = []; - foreach ($arrayTypes as $arrayType) { - $deduped[$arrayType->describe(VerbosityLevel::cache())] = $arrayType; + foreach ($strippedArrays as $i => $stripped) { + $deduped[$stripped->describe(VerbosityLevel::cache())] ??= $arrayTypes[$i]; } $deduped = array_values($deduped); diff --git a/tests/PHPStan/Analyser/nsrt/binary.php b/tests/PHPStan/Analyser/nsrt/binary.php index de3b521a48c..fb711baca46 100644 --- a/tests/PHPStan/Analyser/nsrt/binary.php +++ b/tests/PHPStan/Analyser/nsrt/binary.php @@ -369,12 +369,12 @@ public function doFoo(array $generalArray) assertType('0', min(0, ...[1, 2, 3])); assertType('array{5, 6, 9}', max([1, 10, 8], [5, 6, 9])); assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), array(1, 1, 1, 1))); - assertType('array', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers)); + assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers)); assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), 5, array(1, 1, 1, 1))); assertType('array{int, int, int}', max($arrayOfIntegers, 5)); - assertType('array', max($arrayOfUnknownIntegers, 5)); - assertType('array|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers)); - assertType('array', max($arrayOfUnknownIntegers, $conditionalInt)); + assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, 5)); + assertType('(non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int))|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers)); + assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $conditionalInt)); assertType('5', min($arrayOfIntegers, 5)); assertType('5', min($arrayOfUnknownIntegers, 5)); assertType('1|2', min($arrayOfUnknownIntegers, $conditionalInt)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-10650.php b/tests/PHPStan/Analyser/nsrt/bug-10650.php index 97ce54a9af0..814da29c2a8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10650.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10650.php @@ -28,6 +28,6 @@ public function repro(array $distPoints): void } } - assertType('array, \'x\'>', $ranges); + assertType("list<'x'>", $ranges); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12274.php b/tests/PHPStan/Analyser/nsrt/bug-12274.php index 7c46f3e213c..366358e5bf6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12274.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12274.php @@ -94,7 +94,7 @@ function testShouldLooseListbyAst(array $list, int $i): void $list[1+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('non-empty-array, int>|list', $list); + assertType('list', $list); } /** @param list $list */ @@ -105,5 +105,5 @@ function testShouldLooseListbyAst2(array $list, int $i): void $list[2+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('non-empty-array, int>|list', $list); + assertType('list', $list); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13312.php b/tests/PHPStan/Analyser/nsrt/bug-13312.php index 3a7ab22873c..cb37355bb02 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13312.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13312.php @@ -14,7 +14,7 @@ function fooArr(array $arr): void { for ($i = 0; $i < count($arr); ++$i) { assertType('non-empty-array', $arr); } - assertType('array', $arr); + assertType('non-empty-array', $arr); } /** @param list $arr */ @@ -28,7 +28,7 @@ function foo(array $arr): void { for ($i = 0; $i < count($arr); ++$i) { assertType('non-empty-list', $arr); } - assertType('list', $arr); + assertType('non-empty-list', $arr); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php index 905984b8163..f1209642a94 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -36,16 +36,16 @@ function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRang // count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4) if (count($arr) < $unboundedMaxRange) { - assertType('list', $arr); + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); } else { assertType('non-empty-list&hasOffsetValue(1, string)', $arr); } // count($arr) < unbounded min range → fallback branch (min is null) if (count($arr) < $unboundedMinRange) { - assertType('list', $arr); + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); } else { - assertType('list', $arr); + assertType('non-empty-list&hasOffsetValue(1, string)', $arr); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14084.php b/tests/PHPStan/Analyser/nsrt/bug-14084.php index 3f044aa559a..92116b8b7fb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14084.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14084.php @@ -42,5 +42,5 @@ function example3(array &$convert): void $convert[$outerKey][$key] = strtoupper($val); } } - assertType('array>', $convert); + assertType('array>', $convert); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14319.php b/tests/PHPStan/Analyser/nsrt/bug-14319.php index c253aa5cdc2..0713b829e9a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14319.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14319.php @@ -43,7 +43,7 @@ protected function edit(int|string|null $IdNum = null): void if ($rows['rap_roz3']) { $raport .= 'Roz: '.$rows['rap_roz3'].", \n"; } - assertType("(non-empty-array&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))|(ArrayAccess&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))", $rows); + assertType("(non-empty-array&hasOffsetValue('rap_br', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_cz', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_fil', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_ks', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz2', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_roz3', 0|0.0|''|'0'|array{}|false|null)&hasOffsetValue('rap_tr', 0|0.0|''|'0'|array{}|false|null))|(ArrayAccess&hasOffsetValue('rap_br', mixed)&hasOffsetValue('rap_cz', mixed)&hasOffsetValue('rap_fil', mixed)&hasOffsetValue('rap_ks', mixed)&hasOffsetValue('rap_roz', mixed)&hasOffsetValue('rap_roz2', mixed)&hasOffsetValue('rap_roz3', mixed)&hasOffsetValue('rap_tr', mixed))", $rows); } } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-2648.php b/tests/PHPStan/Analyser/nsrt/bug-2648.php index 9acaa05026f..2430de7c929 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2648.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2648.php @@ -28,13 +28,13 @@ public function doBar(array $list): void if (count($list) > 1) { assertType('int<2, max>', count($list)); foreach ($list as $key => $item) { - assertType('0|int<2, max>', count($list)); + assertType('int<2, max>', count($list)); if ($item === false) { unset($list[$key]); assertType('int<0, max>', count($list)); } - assertType('int<0, max>', count($list)); + assertType('int<1, max>', count($list)); if (count($list) === 1) { assertType('1', count($list)); @@ -44,7 +44,7 @@ public function doBar(array $list): void } } - assertType('int<0, max>', count($list)); + assertType('int<1, max>', count($list)); } assertType('int<0, max>', count($list)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-6488.php b/tests/PHPStan/Analyser/nsrt/bug-6488.php index 7f66763d2d1..1768a065f3f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6488.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6488.php @@ -22,5 +22,5 @@ function test() { } } - assertType('bool',sizeof($items) > 0); + assertType('true',sizeof($items) > 0); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9662.php b/tests/PHPStan/Analyser/nsrt/bug-9662.php index d88555a8632..87718bab49b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9662.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9662.php @@ -148,21 +148,21 @@ function doFoo(string $s, $a, $strings, $mixed) { } else { assertType("non-empty-array", $strings); } - assertType('array', $strings); + assertType('non-empty-array', $strings); if (in_array($s, $strings, false) === false) { - assertType('array', $strings); + assertType('non-empty-array', $strings); } else { assertType("non-empty-array", $strings); } - assertType('array', $strings); + assertType('non-empty-array', $strings); if (in_array($s, $strings) === false) { - assertType('array', $strings); + assertType('non-empty-array', $strings); } else { assertType("non-empty-array", $strings); } - assertType('array', $strings); + assertType('non-empty-array', $strings); } /** diff --git a/tests/PHPStan/Analyser/nsrt/conditional-types.php b/tests/PHPStan/Analyser/nsrt/conditional-types.php index 0cdc7415036..029197cb2d3 100644 --- a/tests/PHPStan/Analyser/nsrt/conditional-types.php +++ b/tests/PHPStan/Analyser/nsrt/conditional-types.php @@ -27,8 +27,8 @@ abstract public function arrayKeys(array $array); */ public function testArrayKeys(array $array, array $nonEmptyArray, array $intArray, array $nonEmptyIntArray, array $emptyArray): void { - assertType('list<(int|string)>', $this->arrayKeys($array)); - assertType('list', $this->arrayKeys($intArray)); + assertType('non-empty-list<(int|string)>', $this->arrayKeys($array)); + assertType('non-empty-list', $this->arrayKeys($intArray)); assertType('non-empty-list<(int|string)>', $this->arrayKeys($nonEmptyArray)); assertType('non-empty-list', $this->arrayKeys($nonEmptyIntArray)); diff --git a/tests/PHPStan/Analyser/nsrt/count-const-array-2.php b/tests/PHPStan/Analyser/nsrt/count-const-array-2.php index f83d7d8b5f3..d463d3d09ff 100644 --- a/tests/PHPStan/Analyser/nsrt/count-const-array-2.php +++ b/tests/PHPStan/Analyser/nsrt/count-const-array-2.php @@ -29,7 +29,7 @@ public function searchRecommendedMinPrices(int $limit): array } array_unshift($otherMinPrices, $otherMinPrice); } - assertType('non-empty-list', $otherMinPrices); + assertType('array{stdClass}|(non-empty-list&hasOffsetValue(1, stdClass))', $otherMinPrices); return [$bestMinPrice, ...$otherMinPrices]; } } diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php index 876baaff498..4df95af68df 100644 --- a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -16,7 +16,7 @@ function getItemsWithForLoop(array $items): array $items[$i] = 1; } - assertType('non-empty-list', $items); + assertType('non-empty-list&hasOffsetValue(0, 1)', $items); return $items; } @@ -31,7 +31,7 @@ function getItemsWithForLoopInvertLastCond(array $items): array $items[$i] = 'hello'; } - assertType('list', $items); + assertType("non-empty-list&hasOffsetValue(0, 'hello')", $items); return $items; } @@ -47,6 +47,6 @@ function getItemsArray(array $items): array $items[$i] = 'hello'; } - assertType('array', $items); + assertType("non-empty-array&hasOffsetValue(0, 'hello')", $items); return $items; } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index d9bd37e9b52..7c635d19a00 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -58,7 +58,7 @@ function modeCountOnMaybeArray(array $items, int $mode) { } else { assertType('non-empty-list|int>', $items); } - assertType('list|int>', $items); + assertType('non-empty-list|int>', $items); } @@ -97,7 +97,7 @@ function recursiveCountOnMaybeArray(array $items):void { } else { assertType('non-empty-list|int>', $items); } - assertType('list|int>', $items); + assertType('non-empty-list|int>', $items); } /** diff --git a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php index e0b2af80c3f..90d2f508fbc 100644 --- a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php +++ b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php @@ -18,8 +18,8 @@ public function doFoo() continue; } - assertType('array{items: array, isActive: bool, productsCount: int}', $this->foreignSection); - assertType('array', $this->foreignSection['items']); + assertType('array{items: non-empty-array, isActive: bool, productsCount: int}', $this->foreignSection); + assertType('non-empty-array', $this->foreignSection['items']); unset($this->foreignSection['items'][$foreignCountryNo]); assertType('array{items: array, isActive: bool, productsCount: int}', $this->foreignSection); assertType('array', $this->foreignSection['items']); diff --git a/tests/PHPStan/Rules/Methods/data/bug-12927.php b/tests/PHPStan/Rules/Methods/data/bug-12927.php index 0331446aec2..2a6c7a3b62a 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-12927.php +++ b/tests/PHPStan/Rules/Methods/data/bug-12927.php @@ -55,7 +55,7 @@ public function sayFooBar(array $list): void if (rand(0,1)) { unset($list[$k]); } - assertType('array, array>', $list); + assertType('non-empty-list>', $list); assertType('array', $list[$k]); } assertType('array', $list[$k]); diff --git a/tests/PHPStan/Rules/Methods/data/bug-7511.php b/tests/PHPStan/Rules/Methods/data/bug-7511.php index 217cedf3734..f8c0fb68c02 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-7511.php +++ b/tests/PHPStan/Rules/Methods/data/bug-7511.php @@ -43,7 +43,7 @@ public function computeForFrontByPosition($tgs) } ksort($res); - assertType('array', $res); + assertType('non-empty-array', $res); return $res; } diff --git a/tests/PHPStan/Rules/Variables/data/bug-13921.php b/tests/PHPStan/Rules/Variables/data/bug-13921.php index 91f86ee0db4..384bec20227 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-13921.php +++ b/tests/PHPStan/Rules/Variables/data/bug-13921.php @@ -8,35 +8,35 @@ /** @param list> $x */ function foo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[0] ?? null); } /** @param non-empty-list> $x */ function nonEmptyFoo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("non-empty-list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[0] ?? null); } /** @param list> $x */ function bar(array $x): void { var_dump($x[0] ?? null); - assertType("list>", $x); + assertType('non-empty-list>&hasOffsetValue(0, array)', $x); var_dump($x[0]['bar'] ?? null); } /** @param list> $x */ function baz(array $x): void { var_dump($x[1] ?? null); - assertType("list>", $x); + assertType('non-empty-list>&hasOffsetValue(1, array)', $x); var_dump($x[0]['bar'] ?? null); } /** @param list> $x */ function boo(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list>", $x); + assertType("non-empty-list>&hasOffsetValue(0, non-empty-array&hasOffsetValue('bar', string))", $x); var_dump($x[1] ?? null); } @@ -51,6 +51,6 @@ function doBar(array $array) /** @param list $x */ function sooSimpleElement(array $x): void { var_dump($x[0]['bar'] ?? null); - assertType("list", $x); + assertType("non-empty-list&hasOffsetValue(0, SimpleXMLElement&hasOffset('bar'))", $x); var_dump($x[0] ?? null); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-14124.php b/tests/PHPStan/Rules/Variables/data/bug-14124.php index be3ad1a8ded..4bc0c5cbf57 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-14124.php +++ b/tests/PHPStan/Rules/Variables/data/bug-14124.php @@ -29,5 +29,5 @@ function example3b(array &$convert): void $convert[$outerKey][$key] = strtoupper($val); } } - assertType('array>', $convert); + assertType('array>', $convert); } diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 574a3f42504..2dd216552da 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -1861,8 +1861,10 @@ class TypeCombinator /* {{{ the array arm of doUnion() */ - /* private static processArrayAccessoryTypes(array $arrayTypes): list */ - static zv::Val processArrayAccessoryTypes(TypeList &arrayTypes) + /* private static processArrayAccessoryTypes(array $arrayTypes): + * array{list, list} - the common accessory types are returned, + * the arrays with accessory wrappers stripped go to strippedArrays */ + static zv::Val processArrayAccessoryTypes(TypeList &arrayTypes, TypeList &strippedArrays) { bool allIterableAtLeastOnce = true; zv::Arr accessoryTypes = zv::Arr::create(0); @@ -1878,14 +1880,24 @@ class TypeCombinator if (isInstance(arrayType, pt_ce_intersection_type)) { zv::Val innerTypes = getTypes(arrayType); PT_FAIL_IF_UNDEF(innerTypes); + zval *nonAccessoryInner = NULL; + uint32_t nonAccessoryInnerCount = 0; + bool skipStrip = false; for (zv::ArrayEntry entry : zv::ArrRef(innerTypes.raw())) { zv::Val innerType = copy(entry.value().raw()); int isTemplate = isInstanceMap(innerType.raw(), PT_CLASS_TEMPLATE_TYPE); PT_FAIL_IF_NEG(isTemplate); - if (isTemplate) break; + if (isTemplate) { + skipStrip = true; + break; + } int isAccessory = isInstanceMap(innerType.raw(), PT_CLASS_ACCESSORY_TYPE); PT_FAIL_IF_NEG(isAccessory); - if (!isAccessory && !isInstance(innerType.raw(), pt_ce_callable_type)) continue; + if (!isAccessory && !isInstance(innerType.raw(), pt_ce_callable_type)) { + nonAccessoryInner = entry.value().raw(); + nonAccessoryInnerCount++; + continue; + } if (isInstance(innerType.raw(), pt_ce_has_offset_type)) { zv::Val offsetType = pt_has_offset_type_get_offset_type(Z_OBJ_P(innerType.raw())); PT_FAIL_IF_UNDEF(offsetType); @@ -1911,6 +1923,9 @@ class TypeCombinator if (UNEXPECTED(key == NULL)) return zv::Val(); nestedIndexSet(accessoryTypes, key, (zend_ulong) i, std::move(innerType)); } + strippedArrays.push_back(copy(skipStrip || nonAccessoryInnerCount != 1 ? arrayType : nonAccessoryInner)); + } else { + strippedArrays.push_back(copy(arrayType)); } zend_long isConstantArray = callTrinary(arrayType, PT_LC("isconstantarray")); @@ -2050,7 +2065,8 @@ class TypeCombinator } } - zv::Val accessoryTypesArray = processArrayAccessoryTypes(arrayTypes); + TypeList strippedArrays; + zv::Val accessoryTypesArray = processArrayAccessoryTypes(arrayTypes, strippedArrays); PT_FAIL_IF_UNDEF(accessoryTypesArray); TypeList accessoryTypes; listFrom(accessoryTypesArray.raw(), accessoryTypes); @@ -2149,16 +2165,17 @@ class TypeCombinator // emergent intermediate state does not blow up. `array{} | non-empty-array` // -> `array` still goes to the old collapse path. if (!hasEmptyConstantArray) { - // Dedupe identical members by describe(): the last member of a - // description wins, at the position of the first one, like the - // twin's `$deduped[$description] = $arrayType` + // Dedupe using the stripped (accessory-free) describe as the key, but + // keep the original (with accessories) as the value: the first + // member of a description wins, like the twin's `??=` Level cacheLevel(PT_LC("cache")); zv::Arr deduped = zv::Arr::create((uint32_t) arrayTypes.size()); - for (zv::Val &arrayType : arrayTypes) { - zv::Val description = describe(arrayType.raw(), cacheLevel); + for (size_t i = 0; i < strippedArrays.size(); i++) { + zv::Val description = describe(strippedArrays[i].raw(), cacheLevel); zend_string *key = describedKey(description); if (UNEXPECTED(key == NULL)) return zv::Val(); - deduped.set(key, zv::Val::copyOf(arrayType.ref())); + if (zend_symtable_find(deduped.table(), key) != NULL) continue; + deduped.set(key, zv::Val::copyOf(arrayTypes[i].ref())); } // Budget: when more distinct shapes remain than a small union would have, From 6be4c6ba0a3839aa046ca068fac6c5c4341fe16b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:32:46 +0200 Subject: [PATCH 5/9] Add regression tests for keep-separate-array fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in the behaviour fixed by the keep-separate change so the bugs cannot silently resurface. Each test fails on 2.1.x and passes on the current branch. Type-inference (NSRT): bug-12195 — list|array{0: null} stays distinct bug-11176 — int|int[]|array{module: string} stays as 3 types Rule errors removed (false positives): bug-8648 — OffsetAccessAssignmentRule no longer fires on $this->data['foo']['bar'][] inside a foreach bug-7759 — CallToFunctionParameters no longer reports array|string passed where string expected bug-13394 — ReturnTypeRule no longer reports a generic mismatch after a side-effect-free is_array/isset narrowing Rule errors added (true positives now reported): bug-8963 — CallToFunctionParameters now reports array given where array|array expected Closes https://github.com/phpstan/phpstan/issues/8648 Closes https://github.com/phpstan/phpstan/issues/12195 Closes https://github.com/phpstan/phpstan/issues/13394 Closes https://github.com/phpstan/phpstan/issues/8963 Closes https://github.com/phpstan/phpstan/issues/7759 Closes https://github.com/phpstan/phpstan/issues/11176 --- tests/PHPStan/Analyser/nsrt/bug-11176.php | 13 +++++++++++++ tests/PHPStan/Analyser/nsrt/bug-12195.php | 13 +++++++++++++ .../CallToFunctionParametersRuleTest.php | 15 +++++++++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 8 ++++++++ .../Rules/Functions/data/bug-13394.php | 19 +++++++++++++++++++ .../PHPStan/Rules/Functions/data/bug-7759.php | 15 +++++++++++++++ .../PHPStan/Rules/Functions/data/bug-8963.php | 13 +++++++++++++ 7 files changed, 96 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11176.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12195.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13394.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-7759.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-8963.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-11176.php b/tests/PHPStan/Analyser/nsrt/bug-11176.php new file mode 100644 index 00000000000..1a0f5cdb063 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11176.php @@ -0,0 +1,13 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11176; + +use function PHPStan\Testing\assertType; + +/** @param int|int[]|array{module: string} $arr */ +function test(int|array $arr): void +{ + assertType('array|array{module: string}|int', $arr); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12195.php b/tests/PHPStan/Analyser/nsrt/bug-12195.php new file mode 100644 index 00000000000..edb632b4ecb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12195.php @@ -0,0 +1,13 @@ +|array{0: null} $list + */ +function test(array $list): void +{ + assertType('array{null}|list', $list); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 67b5837fe6d..ec76f65c63c 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -384,6 +384,21 @@ public function testBug13711(): void $this->analyse([__DIR__ . '/data/bug-13711.php'], []); } + public function testBug7759(): void + { + $this->analyse([__DIR__ . '/data/bug-7759.php'], []); + } + + public function testBug8963(): void + { + $this->analyse([__DIR__ . '/data/bug-8963.php'], [ + [ + 'Parameter #1 $array of function Bug8963\test expects array|array, array given.', + 13, + ], + ]); + } + public function testImplodeOnPhp74(): void { $errors = [ diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index b1868e4f98b..368e3b6560f 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -156,6 +156,14 @@ public function testBug7218(): void $this->analyse([__DIR__ . '/data/bug-7218.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug13394(): void + { + $this->checkExplicitMixed = false; + $this->checkNullables = true; + $this->analyse([__DIR__ . '/data/bug-13394.php'], []); + } + public function testBug5751(): void { $this->checkExplicitMixed = true; diff --git a/tests/PHPStan/Rules/Functions/data/bug-13394.php b/tests/PHPStan/Rules/Functions/data/bug-13394.php new file mode 100644 index 00000000000..a7ca4c6c11d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13394.php @@ -0,0 +1,19 @@ += 8.0 + +declare(strict_types=1); + +namespace Bug13394; + +/** + * @template T of int|array + * @param T $bar + * @return T + */ +function foo(int|array $bar): int|array +{ + if (is_array($bar) && isset($bar[0])) { + $unused = $bar[0] ? 1 : 2; + } + + return $bar; +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-7759.php b/tests/PHPStan/Rules/Functions/data/bug-7759.php new file mode 100644 index 00000000000..4c97b5314cf --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-7759.php @@ -0,0 +1,15 @@ + $in + */ +function check(array $in): void +{ + if (array_key_exists('test123', $in)) { + take_string($in['test123']); + } +} diff --git a/tests/PHPStan/Rules/Functions/data/bug-8963.php b/tests/PHPStan/Rules/Functions/data/bug-8963.php new file mode 100644 index 00000000000..89eeb828053 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-8963.php @@ -0,0 +1,13 @@ +|array $array + */ +function test(array $array): void +{ +} + +$array = ['a', 2, 'c']; +test($array); From c5d8c7b51f6d7f148f30ba111a12a478acfbb9c7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:42:35 +0200 Subject: [PATCH 6/9] Add regression test for #12434 Union of two non-empty-list wrappers around distinct array shapes: non-empty-list|non-empty-list. With @phpstan-assert-if-true narrowing to one variant, the else branch previously stayed as the full union; now it correctly narrows to the remaining variant. Closes https://github.com/phpstan/phpstan/issues/12434 --- tests/PHPStan/Analyser/nsrt/bug-12434.php | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12434.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-12434.php b/tests/PHPStan/Analyser/nsrt/bug-12434.php new file mode 100644 index 00000000000..9d85029f710 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12434.php @@ -0,0 +1,33 @@ +|non-empty-list $values + */ + public function sayHello(array $values): void + { + assertType('non-empty-list|non-empty-list', $values); + if ($this->testShape($values)) { + assertType('non-empty-list', $values); + } else { + assertType('non-empty-list', $values); + } + } + + /** + * @param non-empty-list|non-empty-list $values + * @phpstan-assert-if-true non-empty-list $values + */ + private function testShape(array $values): bool + { + return true; + } + +} From 51ff531ec60d555eea466497b030e55d9eb6d2f3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:45:56 +0200 Subject: [PATCH 7/9] Extract OPTIONAL_KEYS_POWER_SET_LIMIT and FLATTEN_CONSTANT_ARRAYS_LIMIT --- src/Type/Constant/ConstantArrayType.php | 3 ++- src/Type/TypeUtils.php | 4 +++- turbo-ext/src/ConstantArrayType.cpp | 9 +++++---- turbo-ext/src/TypeUtils.cpp | 8 ++++++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0d0fa29b07e..19585a32f7e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -118,6 +118,7 @@ class ConstantArrayType implements Type private const DESCRIBE_LIMIT = 8; private const CHUNK_FINITE_TYPES_LIMIT = 5; private const UNSEALED_ARRAY_SHAPES_LINK = 'https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys'; + private const OPTIONAL_KEYS_POWER_SET_LIMIT = 10; private TrinaryLogic $isList; @@ -389,7 +390,7 @@ public function getAllArrays(): array return $this->allArrays; } - if (count($this->optionalKeys) <= 10) { + if (count($this->optionalKeys) <= self::OPTIONAL_KEYS_POWER_SET_LIMIT) { $optionalKeysCombinations = $this->powerSet($this->optionalKeys); } else { $optionalKeysCombinations = [ diff --git a/src/Type/TypeUtils.php b/src/Type/TypeUtils.php index 5d84f222c45..410a77445e8 100644 --- a/src/Type/TypeUtils.php +++ b/src/Type/TypeUtils.php @@ -23,6 +23,8 @@ final class TypeUtils { + private const FLATTEN_CONSTANT_ARRAYS_LIMIT = 16384; + /** * @return list */ @@ -159,7 +161,7 @@ public static function flattenTypes(Type $type): array foreach ($constantArrays as $constantArray) { $optionalCount = count($constantArray->getOptionalKeys()); $arrayCount = $optionalCount <= 20 ? (1 << $optionalCount) : PHP_INT_MAX; - if ($arrayCount > 16384 || $estimatedCount > 16384 / max($arrayCount, 1)) { + if ($arrayCount > self::FLATTEN_CONSTANT_ARRAYS_LIMIT || $estimatedCount > self::FLATTEN_CONSTANT_ARRAYS_LIMIT / max($arrayCount, 1)) { $bail = true; break; } diff --git a/turbo-ext/src/ConstantArrayType.cpp b/turbo-ext/src/ConstantArrayType.cpp index aa3bfd12158..99470a5f078 100644 --- a/turbo-ext/src/ConstantArrayType.cpp +++ b/turbo-ext/src/ConstantArrayType.cpp @@ -39,14 +39,14 @@ zend_class_entry *pt_ce_constant_array_type = nullptr; /* AcceptsResult.cpp: new ($trinary, $reasons, $lazyReasons) */ bool pt_result_object_create(zval *out, zend_class_entry *ce, zval *trinary, zval *reasons, zval *lazyReasons); -/* the twin's private constants */ +/* the twin's private constants; getAllArrays() enumerates the power set of + * up to OPTIONAL_KEYS_POWER_SET_LIMIT optional keys */ #define PT_CAT_DESCRIBE_LIMIT 8 #define PT_CAT_CHUNK_FINITE_TYPES_LIMIT 5 #define PT_CAT_UNSEALED_ARRAY_SHAPES_LINK "https://phpstan.org/blog/phpstan-2-2-unsealed-array-shapes-safer-array-keys" -/* the twin's inline thresholds: getAllArrays() enumerates the power set of - * up to this many optional keys, generalize() keeps per-offset accessories - * below this many required keys */ #define PT_CAT_POWER_SET_OPTIONAL_KEYS_LIMIT 10 +/* the twin's inline threshold: generalize() keeps per-offset accessories + * below this many required keys */ #define PT_CAT_GENERALIZE_OFFSET_ACCESSORIES_LIMIT 32 /* PHP's CASE_LOWER / CASE_UPPER */ @@ -7308,6 +7308,7 @@ PT_MINIT_REGISTRATION(pt_register_constant_array_type) cls.privateClassConstantLong("DESCRIBE_LIMIT", PT_CAT_DESCRIBE_LIMIT); cls.privateClassConstantLong("CHUNK_FINITE_TYPES_LIMIT", PT_CAT_CHUNK_FINITE_TYPES_LIMIT); cls.privateClassConstantString("UNSEALED_ARRAY_SHAPES_LINK", PT_CAT_UNSEALED_ARRAY_SHAPES_LINK); + cls.privateClassConstantLong("OPTIONAL_KEYS_POWER_SET_LIMIT", PT_CAT_POWER_SET_OPTIONAL_KEYS_LIMIT); cls.method(sigs::__construct, [](INTERNAL_FUNCTION_PARAMETERS) { zval *keyTypes, *valueTypes, *nextAutoIndexes = NULL, *optionalKeys = NULL, *isList = NULL, *unsealed = NULL; diff --git a/turbo-ext/src/TypeUtils.cpp b/turbo-ext/src/TypeUtils.cpp index ae07279ed9c..7def5fb2ef0 100644 --- a/turbo-ext/src/TypeUtils.cpp +++ b/turbo-ext/src/TypeUtils.cpp @@ -23,6 +23,9 @@ namespace sigs = ptdecl::TypeUtils::sig; zend_class_entry *pt_ce_type_utils = nullptr; +/* the twin's private constant */ +#define PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT 16384 + namespace phpstanturbo { struct ConstantArrayCombinationContext @@ -215,7 +218,7 @@ class TypeUtils * expanding: each ConstantArrayType with N optional keys produces * 2^N variants from getAllArrays(), multiplied across the * constant arrays; bail out above the limit rather than allocate - * O(2^N). The twin's `16384 / max($arrayCount, 1)` is a float + * O(2^N). The twin's `FLATTEN_CONSTANT_ARRAYS_LIMIT / max($arrayCount, 1)` is a float * division — compared as one here. */ zend_long estimatedCount = 1; bool bail = false; @@ -233,7 +236,7 @@ class TypeUtils } zend_long optionalCount = (zend_long) zv::ArrRef(optionalKeys.raw()).size(); zend_long arrayCount = optionalCount <= 20 ? ((zend_long) 1 << optionalCount) : ZEND_LONG_MAX; - if (arrayCount > 16384 || (double) estimatedCount > 16384.0 / (double) (arrayCount > 1 ? arrayCount : 1)) { + if (arrayCount > PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT || (double) estimatedCount > (double) PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT / (double) (arrayCount > 1 ? arrayCount : 1)) { bail = true; break; } @@ -481,6 +484,7 @@ PT_MINIT_REGISTRATION(pt_register_type_utils) { reg::Class cls("PHPStan\\Type\\TypeUtils"); ptdecl::TypeUtils::declareClass(cls); + cls.privateClassConstantLong("FLATTEN_CONSTANT_ARRAYS_LIMIT", PT_TU_FLATTEN_CONSTANT_ARRAYS_LIMIT); ptdecl::TypeUtils::declareProperties(cls); static const reg::Arg nullableThisType = reg::obj("", "PHPStan\\Type\\ThisType", true); From ca031911c3797620781e5c059b1e424eec3ab1f7 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 31 Oct 2026 13:46:47 +0000 Subject: [PATCH 8/9] Collapse array members of one shape with different accessories to the shape Keeping the first of two members whose stripped shapes describe the same dropped the values of the other one: the union of non-empty-array&hasOffset('foo') and array became non-empty-array&hasOffset('foo'). Such members now collapse to their stripped shape, and the accessories they share are intersected back. Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6260-7460-a52b-a418f709e4bf --- src/Type/TypeCombinator.php | 16 +++++++++-- tests/PHPStan/Analyser/nsrt/binary.php | 8 +++--- tests/PHPStan/Analyser/nsrt/bug-10438.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12274.php | 4 +-- tests/PHPStan/Analyser/nsrt/bug-13312.php | 4 +-- tests/PHPStan/Analyser/nsrt/bug-13705.php | 6 ++-- tests/PHPStan/Analyser/nsrt/bug-14084.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-2648.php | 6 ++-- tests/PHPStan/Analyser/nsrt/bug-4708.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-6488.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9662.php | 28 +++++++++---------- .../Analyser/nsrt/composer-array-bug.php | 2 +- .../Analyser/nsrt/conditional-types.php | 4 +-- .../Analyser/nsrt/count-const-array-2.php | 2 +- tests/PHPStan/Analyser/nsrt/for-loop-expr.php | 2 +- tests/PHPStan/Analyser/nsrt/list-count.php | 4 +-- .../Analyser/nsrt/oversized-array-stages.php | 2 +- .../data/slevomat-foreach-unset-bug.php | 4 +-- .../PHPStan/Rules/Methods/data/bug-12927.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-7511.php | 2 +- .../Rules/Variables/data/bug-14124.php | 2 +- .../PHPStan/Rules/Variables/data/bug-8113.php | 4 +-- turbo-ext/src/TypeCombinator.cpp | 19 ++++++++++--- 23 files changed, 76 insertions(+), 53 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 727ce656419..e8aa4a80c1e 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1297,10 +1297,22 @@ private static function processArrayTypes(array $arrayTypes): array // per-element accessories like hasOffsetValue (cf. the optional- // properties repro: 4 same-shape members differing only in stacked // hasOffsetValue accessories), while preserving per-member accessories - // like list / non-empty on whichever original survives. + // like list / non-empty when only one original has the shape. Two + // originals of one shape that differ in their accessories collapse to + // the stripped shape: keeping either of them would drop values of the + // other, and the accessories they share are added back below. $deduped = []; foreach ($strippedArrays as $i => $stripped) { - $deduped[$stripped->describe(VerbosityLevel::cache())] ??= $arrayTypes[$i]; + $key = $stripped->describe(VerbosityLevel::cache()); + if (!array_key_exists($key, $deduped)) { + $deduped[$key] = $arrayTypes[$i]; + continue; + } + if ($deduped[$key]->equals($arrayTypes[$i])) { + continue; + } + + $deduped[$key] = $stripped; } $deduped = array_values($deduped); diff --git a/tests/PHPStan/Analyser/nsrt/binary.php b/tests/PHPStan/Analyser/nsrt/binary.php index fb711baca46..de3b521a48c 100644 --- a/tests/PHPStan/Analyser/nsrt/binary.php +++ b/tests/PHPStan/Analyser/nsrt/binary.php @@ -369,12 +369,12 @@ public function doFoo(array $generalArray) assertType('0', min(0, ...[1, 2, 3])); assertType('array{5, 6, 9}', max([1, 10, 8], [5, 6, 9])); assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), array(1, 1, 1, 1))); - assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers)); + assertType('array', max($arrayOfUnknownIntegers, $arrayOfUnknownIntegers)); assertType('array{1, 1, 1, 1}', max(array(2, 2, 2), 5, array(1, 1, 1, 1))); assertType('array{int, int, int}', max($arrayOfIntegers, 5)); - assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, 5)); - assertType('(non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int))|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers)); - assertType('non-empty-array&hasOffsetValue(108, int)&hasOffsetValue(42, int)', max($arrayOfUnknownIntegers, $conditionalInt)); + assertType('array', max($arrayOfUnknownIntegers, 5)); + assertType('array|int', max($arrayOfUnknownIntegers, $integer, $arrayOfUnknownIntegers)); + assertType('array', max($arrayOfUnknownIntegers, $conditionalInt)); assertType('5', min($arrayOfIntegers, 5)); assertType('5', min($arrayOfUnknownIntegers, 5)); assertType('1|2', min($arrayOfUnknownIntegers, $conditionalInt)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-10438.php b/tests/PHPStan/Analyser/nsrt/bug-10438.php index 063da42957a..89fbd0762c9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10438.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10438.php @@ -25,7 +25,7 @@ public function extract(SimpleXMLElement $data, string $type = 'Meta'): array $meta[$key] = []; assertType('array{}', $meta[$key]); foreach ($tag->{$valueName} as $value) { - assertType('array{}|array{0: string, 1?: string}', $meta[$key]); + assertType('list|string', $meta[$key]); $meta[$key][] = (string)$value; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12274.php b/tests/PHPStan/Analyser/nsrt/bug-12274.php index 366358e5bf6..8768c8a33ff 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12274.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12274.php @@ -94,7 +94,7 @@ function testShouldLooseListbyAst(array $list, int $i): void $list[1+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('list', $list); + assertType('array, int>', $list); } /** @param list $list */ @@ -105,5 +105,5 @@ function testShouldLooseListbyAst2(array $list, int $i): void $list[2+$i] = 21; assertType('non-empty-array, int>', $list); } - assertType('list', $list); + assertType('array, int>', $list); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13312.php b/tests/PHPStan/Analyser/nsrt/bug-13312.php index cb37355bb02..3a7ab22873c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13312.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13312.php @@ -14,7 +14,7 @@ function fooArr(array $arr): void { for ($i = 0; $i < count($arr); ++$i) { assertType('non-empty-array', $arr); } - assertType('non-empty-array', $arr); + assertType('array', $arr); } /** @param list $arr */ @@ -28,7 +28,7 @@ function foo(array $arr): void { for ($i = 0; $i < count($arr); ++$i) { assertType('non-empty-list', $arr); } - assertType('non-empty-list', $arr); + assertType('list', $arr); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13705.php b/tests/PHPStan/Analyser/nsrt/bug-13705.php index f1209642a94..905984b8163 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13705.php @@ -36,16 +36,16 @@ function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRang // count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4) if (count($arr) < $unboundedMaxRange) { - assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + assertType('list', $arr); } else { assertType('non-empty-list&hasOffsetValue(1, string)', $arr); } // count($arr) < unbounded min range → fallback branch (min is null) if (count($arr) < $unboundedMinRange) { - assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + assertType('list', $arr); } else { - assertType('non-empty-list&hasOffsetValue(1, string)', $arr); + assertType('list', $arr); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14084.php b/tests/PHPStan/Analyser/nsrt/bug-14084.php index 92116b8b7fb..3f044aa559a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14084.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14084.php @@ -42,5 +42,5 @@ function example3(array &$convert): void $convert[$outerKey][$key] = strtoupper($val); } } - assertType('array>', $convert); + assertType('array>', $convert); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-2648.php b/tests/PHPStan/Analyser/nsrt/bug-2648.php index 2430de7c929..9acaa05026f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2648.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2648.php @@ -28,13 +28,13 @@ public function doBar(array $list): void if (count($list) > 1) { assertType('int<2, max>', count($list)); foreach ($list as $key => $item) { - assertType('int<2, max>', count($list)); + assertType('0|int<2, max>', count($list)); if ($item === false) { unset($list[$key]); assertType('int<0, max>', count($list)); } - assertType('int<1, max>', count($list)); + assertType('int<0, max>', count($list)); if (count($list) === 1) { assertType('1', count($list)); @@ -44,7 +44,7 @@ public function doBar(array $list): void } } - assertType('int<1, max>', count($list)); + assertType('int<0, max>', count($list)); } assertType('int<0, max>', count($list)); diff --git a/tests/PHPStan/Analyser/nsrt/bug-4708.php b/tests/PHPStan/Analyser/nsrt/bug-4708.php index 97935a05649..b5f6ae47385 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4708.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4708.php @@ -87,7 +87,7 @@ function GetASCConfig() } } - assertType("non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result); + assertType("non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int)", $result); } assertType("(non-empty-array&hasOffsetValue('bew', int)&hasOffsetValue('bsw', int))|array{result: false, dberror: 'xyz'}", $result); diff --git a/tests/PHPStan/Analyser/nsrt/bug-6488.php b/tests/PHPStan/Analyser/nsrt/bug-6488.php index 1768a065f3f..7f66763d2d1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6488.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6488.php @@ -22,5 +22,5 @@ function test() { } } - assertType('true',sizeof($items) > 0); + assertType('bool',sizeof($items) > 0); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9662.php b/tests/PHPStan/Analyser/nsrt/bug-9662.php index 87718bab49b..207bbe92460 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9662.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9662.php @@ -20,63 +20,63 @@ function doFoo(string $s, $a, $strings, $mixed) { if (in_array('foo', $a, false)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('foo', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('0', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array('1', $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array(true, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array(false, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a, true)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a, false)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); if (in_array($s, $a)) { assertType('non-empty-array', $a); } else { - assertType("array", $a); + assertType("array|non-empty-array", $a); } assertType('array', $a); @@ -148,21 +148,21 @@ function doFoo(string $s, $a, $strings, $mixed) { } else { assertType("non-empty-array", $strings); } - assertType('non-empty-array', $strings); + assertType('array', $strings); if (in_array($s, $strings, false) === false) { - assertType('non-empty-array', $strings); + assertType('array', $strings); } else { assertType("non-empty-array", $strings); } - assertType('non-empty-array', $strings); + assertType('array', $strings); if (in_array($s, $strings) === false) { - assertType('non-empty-array', $strings); + assertType('array', $strings); } else { assertType("non-empty-array", $strings); } - assertType('non-empty-array', $strings); + assertType('array', $strings); } /** diff --git a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php index cca8e42d9d4..1c38eb4cd08 100644 --- a/tests/PHPStan/Analyser/nsrt/composer-array-bug.php +++ b/tests/PHPStan/Analyser/nsrt/composer-array-bug.php @@ -57,7 +57,7 @@ public function doFoo(): void assertType("non-empty-array&hasOffsetValue('authors', mixed~(0|0.0|''|'0'|array{}|false|null))", $this->config); } - assertType("array", $this->config); + assertType("array", $this->config); } } diff --git a/tests/PHPStan/Analyser/nsrt/conditional-types.php b/tests/PHPStan/Analyser/nsrt/conditional-types.php index 029197cb2d3..0cdc7415036 100644 --- a/tests/PHPStan/Analyser/nsrt/conditional-types.php +++ b/tests/PHPStan/Analyser/nsrt/conditional-types.php @@ -27,8 +27,8 @@ abstract public function arrayKeys(array $array); */ public function testArrayKeys(array $array, array $nonEmptyArray, array $intArray, array $nonEmptyIntArray, array $emptyArray): void { - assertType('non-empty-list<(int|string)>', $this->arrayKeys($array)); - assertType('non-empty-list', $this->arrayKeys($intArray)); + assertType('list<(int|string)>', $this->arrayKeys($array)); + assertType('list', $this->arrayKeys($intArray)); assertType('non-empty-list<(int|string)>', $this->arrayKeys($nonEmptyArray)); assertType('non-empty-list', $this->arrayKeys($nonEmptyIntArray)); diff --git a/tests/PHPStan/Analyser/nsrt/count-const-array-2.php b/tests/PHPStan/Analyser/nsrt/count-const-array-2.php index d463d3d09ff..f83d7d8b5f3 100644 --- a/tests/PHPStan/Analyser/nsrt/count-const-array-2.php +++ b/tests/PHPStan/Analyser/nsrt/count-const-array-2.php @@ -29,7 +29,7 @@ public function searchRecommendedMinPrices(int $limit): array } array_unshift($otherMinPrices, $otherMinPrice); } - assertType('array{stdClass}|(non-empty-list&hasOffsetValue(1, stdClass))', $otherMinPrices); + assertType('non-empty-list', $otherMinPrices); return [$bestMinPrice, ...$otherMinPrices]; } } diff --git a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php index 4df95af68df..dced7bf5508 100644 --- a/tests/PHPStan/Analyser/nsrt/for-loop-expr.php +++ b/tests/PHPStan/Analyser/nsrt/for-loop-expr.php @@ -16,7 +16,7 @@ function getItemsWithForLoop(array $items): array $items[$i] = 1; } - assertType('non-empty-list&hasOffsetValue(0, 1)', $items); + assertType('non-empty-list', $items); return $items; } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 7c635d19a00..d9bd37e9b52 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -58,7 +58,7 @@ function modeCountOnMaybeArray(array $items, int $mode) { } else { assertType('non-empty-list|int>', $items); } - assertType('non-empty-list|int>', $items); + assertType('list|int>', $items); } @@ -97,7 +97,7 @@ function recursiveCountOnMaybeArray(array $items):void { } else { assertType('non-empty-list|int>', $items); } - assertType('non-empty-list|int>', $items); + assertType('list|int>', $items); } /** diff --git a/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php index 4d3a9ef9202..fde952a6c26 100644 --- a/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php +++ b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php @@ -68,7 +68,7 @@ function phase2TriangularCollapse(): array return []; } - assertType("non-empty-list", $items); + assertType("non-empty-list|non-empty-list", $items); return $items; } diff --git a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php index 90d2f508fbc..e0b2af80c3f 100644 --- a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php +++ b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-unset-bug.php @@ -18,8 +18,8 @@ public function doFoo() continue; } - assertType('array{items: non-empty-array, isActive: bool, productsCount: int}', $this->foreignSection); - assertType('non-empty-array', $this->foreignSection['items']); + assertType('array{items: array, isActive: bool, productsCount: int}', $this->foreignSection); + assertType('array', $this->foreignSection['items']); unset($this->foreignSection['items'][$foreignCountryNo]); assertType('array{items: array, isActive: bool, productsCount: int}', $this->foreignSection); assertType('array', $this->foreignSection['items']); diff --git a/tests/PHPStan/Rules/Methods/data/bug-12927.php b/tests/PHPStan/Rules/Methods/data/bug-12927.php index 2a6c7a3b62a..0331446aec2 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-12927.php +++ b/tests/PHPStan/Rules/Methods/data/bug-12927.php @@ -55,7 +55,7 @@ public function sayFooBar(array $list): void if (rand(0,1)) { unset($list[$k]); } - assertType('non-empty-list>', $list); + assertType('array, array>', $list); assertType('array', $list[$k]); } assertType('array', $list[$k]); diff --git a/tests/PHPStan/Rules/Methods/data/bug-7511.php b/tests/PHPStan/Rules/Methods/data/bug-7511.php index f8c0fb68c02..217cedf3734 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-7511.php +++ b/tests/PHPStan/Rules/Methods/data/bug-7511.php @@ -43,7 +43,7 @@ public function computeForFrontByPosition($tgs) } ksort($res); - assertType('non-empty-array', $res); + assertType('array', $res); return $res; } diff --git a/tests/PHPStan/Rules/Variables/data/bug-14124.php b/tests/PHPStan/Rules/Variables/data/bug-14124.php index 4bc0c5cbf57..be3ad1a8ded 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-14124.php +++ b/tests/PHPStan/Rules/Variables/data/bug-14124.php @@ -29,5 +29,5 @@ function example3b(array &$convert): void $convert[$outerKey][$key] = strtoupper($val); } } - assertType('array>', $convert); + assertType('array>', $convert); } diff --git a/tests/PHPStan/Rules/Variables/data/bug-8113.php b/tests/PHPStan/Rules/Variables/data/bug-8113.php index b07ba386ced..aec35e94c2e 100644 --- a/tests/PHPStan/Rules/Variables/data/bug-8113.php +++ b/tests/PHPStan/Rules/Variables/data/bug-8113.php @@ -34,7 +34,7 @@ function () { ]; assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', non-empty-array&hasOffsetValue('review', null))", $review); unset($review['SurveyInvitation']['review']); - assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', array)", $review); + assertType("non-empty-array>&hasOffsetValue('Review', array{id: null, text: null, answer: null})&hasOffsetValue('SurveyInvitation', array)", $review); } assertType('array>', $review); if (array_key_exists('User', $review['Review'])) { @@ -44,5 +44,5 @@ function () { unset($review['Review']['User']); assertType("non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', mixed)", $review); } - assertType("non-empty-array&hasOffsetValue('Review', array)", $review); + assertType("(non-empty-array&hasOffsetValue('Review', array)&hasOffsetValue('User', mixed))|(non-empty-array>&hasOffsetValue('Review', array))", $review); }; diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index 2dd216552da..75cb9b7bad0 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -2166,16 +2166,27 @@ class TypeCombinator // -> `array` still goes to the old collapse path. if (!hasEmptyConstantArray) { // Dedupe using the stripped (accessory-free) describe as the key, but - // keep the original (with accessories) as the value: the first - // member of a description wins, like the twin's `??=` + // keep the original (with accessories) as the value when only one + // original has the shape. Two originals of one shape that differ in + // their accessories collapse to the stripped shape: keeping either of + // them would drop values of the other, and the accessories they share + // are added back below. Level cacheLevel(PT_LC("cache")); zv::Arr deduped = zv::Arr::create((uint32_t) arrayTypes.size()); for (size_t i = 0; i < strippedArrays.size(); i++) { zv::Val description = describe(strippedArrays[i].raw(), cacheLevel); zend_string *key = describedKey(description); if (UNEXPECTED(key == NULL)) return zv::Val(); - if (zend_symtable_find(deduped.table(), key) != NULL) continue; - deduped.set(key, zv::Val::copyOf(arrayTypes[i].ref())); + zval *kept = zend_symtable_find(deduped.table(), key); + if (kept == NULL) { + deduped.set(key, zv::Val::copyOf(arrayTypes[i].ref())); + continue; + } + zv::Val equal = call(kept, PT_LC("equals"), 1, arrayTypes[i].raw()); + PT_FAIL_IF_UNDEF(equal); + if (zend_is_true(equal.raw())) continue; + + deduped.set(key, zv::Val::copyOf(strippedArrays[i].ref())); } // Budget: when more distinct shapes remain than a small union would have, From d26490202a46eda3b2129df359c79ffd3f169463 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sat, 31 Oct 2026 17:19:01 +0000 Subject: [PATCH 9/9] Add regression tests for #15431 and #15006 Closes https://github.com/phpstan/phpstan/issues/15431 Closes https://github.com/phpstan/phpstan/issues/15006 Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-6260-7460-a52b-a418f709e4bf --- .../Rules/Methods/CallMethodsRuleTest.php | 8 ++++ .../Rules/Methods/MethodSignatureRuleTest.php | 7 +++ .../PHPStan/Rules/Methods/data/bug-15006.php | 27 ++++++++++++ .../PHPStan/Rules/Methods/data/bug-15431.php | 44 +++++++++++++++++++ 4 files changed, 86 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-15006.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-15431.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 8e6c4084026..ce8fe1e0da5 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4616,4 +4616,12 @@ public function testDeadCatch(): void ]); } + public function testBug15431(): void + { + $this->checkThisOnly = false; + $this->checkNullables = false; + $this->checkUnionTypes = false; + $this->analyse([__DIR__ . '/data/bug-15431.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php index f24317d6b61..ee2b6a7c1bf 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -676,4 +676,11 @@ public function testPureUnlessParameterPassedOverride(): void ]); } + public function testBug15006(): void + { + $this->reportMaybes = true; + $this->reportStatic = true; + $this->analyse([__DIR__ . '/data/bug-15006.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-15006.php b/tests/PHPStan/Rules/Methods/data/bug-15006.php new file mode 100644 index 00000000000..7a087b234e9 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15006.php @@ -0,0 +1,27 @@ + : array) + */ + public function bar(?int $field = null): array { + return [ ]; + } +} + +class child extends base { + /** + * @param ?non-negative-int $field + * @return ($field is null + * ? ($nullable is true ? ($numeric is true ? list : list) : ($numeric is true ? list : list)) + * : ($nullable is true ? ($numeric is true ? array : array) : ($numeric is true ? array : array)) + * ) + */ + #[\Override] + public function bar(?int $field = null, bool $nullable = true, bool $numeric = false): array { + return [ ]; + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-15431.php b/tests/PHPStan/Rules/Methods/data/bug-15431.php new file mode 100644 index 00000000000..118482a5cbf --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15431.php @@ -0,0 +1,44 @@ + + */ +class WeakAnalysingBoxedArray +{ + /** @var T */ + public array $value = []; +} + +/** + * @template TKey of object|array + * @template TValue of object|array + * @template TOwner of object + */ +class WeakAnalysingMap +{ + /** + * @template T of TKey|TValue + * + * @param (T is object ? T : WeakAnalysingBoxedArray) $value + * + * @return T + */ + protected function unboxValue(object $value) + { + return $value instanceof WeakAnalysingBoxedArray + ? $value->value + : $value; + } + + /** + * @param (TValue is object ? TValue : WeakAnalysingBoxedArray) $v + * + * @return TValue|null + */ + public function unboxValueOnly($v) + { + return $this->unboxValue($v); + } +}