From c7b806c01ea02d249cc1722a03d6722ba64be045 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 | 4 +- 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 +- tests/PHPStan/Analyser/nsrt/bug-12393.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-12393b.php | 2 +- .../PHPStan/Analyser/nsrt/bug-13270b-php8.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-13509.php | 2 +- 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 +- 35 files changed, 200 insertions(+), 59 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 901c78de7ee..c960c1dd0cb 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\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; @@ -922,6 +923,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 da8dd13cc52..063da42957a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10438.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10438.php @@ -21,11 +21,11 @@ public function extract(SimpleXMLElement $data, string $type = 'Meta'): array $meta[$key] = (string)$tag->{$valueName}; continue; } - assertType('array|string>', $meta); + assertType('array', $meta); $meta[$key] = []; assertType('array{}', $meta[$key]); foreach ($tag->{$valueName} as $value) { - assertType('list', $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 7e899600be4..ceacc22329e 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-12393.php b/tests/PHPStan/Analyser/nsrt/bug-12393.php index 4edd2300c13..44f50b80040 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393.php @@ -160,7 +160,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12393b.php b/tests/PHPStan/Analyser/nsrt/bug-12393b.php index a21dcf561a0..0ac6389f711 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12393b.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12393b.php @@ -669,7 +669,7 @@ class CallableArray { public function doFoo(callable $foo): void { $this->foo = $foo; - assertType('array', $this->foo); // could be non-empty-array + assertType('array', $this->foo); // could be non-empty-array } } 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-13509.php b/tests/PHPStan/Analyser/nsrt/bug-13509.php index 6ff710ae4b8..998245d0a30 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13509.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13509.php @@ -80,7 +80,7 @@ function alert(): ?array return null; } - assertType('non-empty-list&oversized-array>&oversized-array', $alerts); + assertType("non-empty-list<(array{message: 'Foo', details: 'bar', duration: int<1, max>|null, severity: 100}&oversized-array)|(array{message: 'Idle', duration: int<1, max>|null, severity: 23}&oversized-array)|(array{message: 'No Queue', duration: int<1, max>|null, severity: 60}&oversized-array)|(array{message: 'Not Scheduled', duration: null, severity: 25}&oversized-array)|(array{message: 'Offline', duration: int<1, max>|null, severity: 99}&oversized-array)|(array{message: 'On Break'|'On Lunch', duration: int<1, max>|null, severity: 24}&oversized-array)|(array{message: 'Running W/O Operator', duration: int<1, max>|null, severity: 75}&oversized-array)>&oversized-array", $alerts); usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']); 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 8ecf3438e77..e0a462ad714 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 543a1b84ac8..aa67ee2d4c4 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 93dfe0d1473..15735ee577b 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; } From 8c3554c54ccd9a1d5c4f6ff1f9bf7e41b057a941 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. --- src/Type/TypeCombinator.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index c960c1dd0cb..fa166cc0c99 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\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\AccessoryLowercaseStringType; @@ -931,7 +930,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 From 21565219d26084fc9d686c2a401bcfe78e504031 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 | 40 +- .../Analyser/nsrt/narrow-tagged-union.php | 2 +- .../bench/data/optional-properties-blowup.php | 428 ++++++++++++++++++ 3 files changed, 459 insertions(+), 11 deletions(-) create mode 100644 tests/bench/data/optional-properties-blowup.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index fa166cc0c99..825e0b592f1 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -48,6 +48,8 @@ final class TypeCombinator { + private const KEEP_SEPARATE_ARRAYS_LIMIT = 4; + public static function addNull(Type $type): Type { $nullType = new NullType(); @@ -931,18 +933,36 @@ private static function processArrayTypes(array $arrayTypes): array } // 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 - // is not expressible here and falls through to the old collapse path. - $results = []; + // 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` and the + // $overflowed safety valve still go to the old collapse path. + if (!$hasEmptyConstantArray && !$overflowed) { + // 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 e0a462ad714..a2e5c703a3e 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..44a4e70a59d --- /dev/null +++ b/tests/bench/data/optional-properties-blowup.php @@ -0,0 +1,428 @@ + */ + 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; + } +} From 669abe005757ccfc4630eda1150a2cd622240a97 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-13629.php | 2 +- 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 +- 21 files changed, 64 insertions(+), 52 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 825e0b592f1..37dc40c0b28 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -776,22 +776,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) { @@ -804,6 +809,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()) { @@ -849,7 +857,7 @@ private static function processArrayAccessoryTypes(array $arrayTypes): array $commonAccessoryTypes[] = new NonEmptyArrayType(); } - return $commonAccessoryTypes; + return [$strippedArrays, $commonAccessoryTypes]; } /** @@ -862,7 +870,7 @@ private static function processArrayTypes(array $arrayTypes): array return []; } - $accessoryTypes = self::processArrayAccessoryTypes($arrayTypes); + [$strippedArrays, $accessoryTypes] = self::processArrayAccessoryTypes($arrayTypes); if (count($arrayTypes) === 1) { return [ @@ -940,12 +948,16 @@ private static function processArrayTypes(array $arrayTypes): array // not blow up. `array{} | non-empty-array` -> `array` and the // $overflowed safety valve still go to the old collapse path. if (!$hasEmptyConstantArray && !$overflowed) { - // 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 ce3a13e6cc6..6b18b479b22 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 ceacc22329e..03260e4f3aa 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-13629.php b/tests/PHPStan/Analyser/nsrt/bug-13629.php index a7ca04defac..72a8b077926 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13629.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13629.php @@ -32,7 +32,7 @@ function test(array $xsdFiles, array $groupedByNamespace, array $extraNamespaces } } // After assigning with string keys ($viewHelper['name']), $xsdFiles[$xmlNamespace] should NOT be a list - assertType('array|string, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]); + assertType('non-empty-array|string, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]); $xsdFiles[$xmlNamespace] = array_values($xsdFiles[$xmlNamespace]); } } 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 cbf89d13dcf..605a24b93eb 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 24bfc6fa63f..d62d9fd5c4e 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); } From cc1e75c79754c5cad584e5756ebe58ed3744400b 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 ++++++++ .../Arrays/OffsetAccessAssignmentRuleTest.php | 6 ++++ tests/PHPStan/Rules/Arrays/data/bug-8648.php | 30 +++++++++++++++++++ .../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 ++++++++ 9 files changed, 132 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/Arrays/data/bug-8648.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/Arrays/OffsetAccessAssignmentRuleTest.php b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php index 507d8b99491..c06001a18cf 100644 --- a/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/OffsetAccessAssignmentRuleTest.php @@ -162,6 +162,12 @@ public function testBug8015(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-8015.php'], []); } + public function testBug8648(): void + { + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-8648.php'], []); + } + public function testBug11572(): void { $this->checkUnionTypes = true; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8648.php b/tests/PHPStan/Rules/Arrays/data/bug-8648.php new file mode 100644 index 00000000000..7354c66f664 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-8648.php @@ -0,0 +1,30 @@ +data['foo'] = [ + 'id' => 'some_id', + ]; + + foreach (['a' => 'aa', 'b' => 'bb', 'c' => 'cc'] as $type => $value) { + $this->data['foo']['bar'][] = [ + 'type' => $type, + 'value' => $value, + ]; + } + + return $this->data; + } +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index a202c8e271d..b224b7eba1f 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -346,6 +346,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 8a6c9109093..75a8985eb64 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -144,6 +144,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 14c956550a03a726c63838949ac92719b533bfe9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:40:36 +0200 Subject: [PATCH 6/9] Add regression test for #13789 list> was widened to list> after a foreach-by-ref loop in which $row was first sanitized then re-populated. Locks in the expected post-loop type. Closes https://github.com/phpstan/phpstan/issues/13789 --- tests/PHPStan/Analyser/nsrt/bug-13789.php | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13789.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13789.php b/tests/PHPStan/Analyser/nsrt/bug-13789.php new file mode 100644 index 00000000000..5e9e5d37e1b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13789.php @@ -0,0 +1,27 @@ +> */ +function get_list_of_non_empty_array(): array { return [[1]]; } + +/** @param array $row */ +function sanitize(array &$row): void { } + +function doFoo(): void +{ + $foo = get_list_of_non_empty_array(); + assertType('list>', $foo); + + foreach ($foo as &$row) { + sanitize($row); + assertType('array', $row); + $row[random_bytes(2)] = random_bytes(2); + assertType('non-empty-array', $row); + } + unset($row); + + assertType('list>', $foo); +} From 57b1749a75fcd8d19f283a1febe0d1fc4fc5b7d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:42:35 +0200 Subject: [PATCH 7/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 bf3cb72562adc207c5db11e6ec4d58466442ac4a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 09:45:56 +0200 Subject: [PATCH 8/9] Extract OPTIONAL_KEYS_POWER_SET_LIMIT and FLATTEN_CONSTANT_ARRAYS_LIMIT --- src/Type/Constant/ConstantArrayType.php | 3 ++- src/Type/TypeUtils.php | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index eb5aec70cae..5c88d4b8462 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -84,6 +84,7 @@ class ConstantArrayType implements Type private const DESCRIBE_LIMIT = 8; private const CHUNK_FINITE_TYPES_LIMIT = 5; + private const OPTIONAL_KEYS_POWER_SET_LIMIT = 10; private TrinaryLogic $isList; @@ -232,7 +233,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 1974767f9f2..48ad89a4429 100644 --- a/src/Type/TypeUtils.php +++ b/src/Type/TypeUtils.php @@ -21,6 +21,8 @@ final class TypeUtils { + private const FLATTEN_CONSTANT_ARRAYS_LIMIT = 16384; + /** * @return list */ @@ -157,7 +159,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; } From a70db9e1b8b650a685a890593d38491363f52964 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:36:06 +0200 Subject: [PATCH 9/9] =?UTF-8?q?Lower=20KEEP=5FSEPARATE=5FARRAYS=5FLIMIT=20?= =?UTF-8?q?to=202=20=E2=80=94=20faster=20than=20base=20on=20hot=20bench=20?= =?UTF-8?q?cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-7903 / bug-8503 / bug-11283 each spend a noticeable fraction of their analysis inside the keep-separate path returning 2-member unions. Each subsequent union() over those 2-member results pays O(N²) in compareTypesInUnion that the old single-array collapse skipped. Lowering the cap from 4 to 2 means anything bigger falls through to the old collapse, while 2-member intentional unions (array|array, list|list, the regression cases in #13789 / #12434 / #8963 / etc.) still survive distinct. Bench (median of 3, vs 2.1.x base): bug-7903 5.0s vs 5.9s (-15%) bug-8503 4.1s vs 5.9s (-30%) bug-11283 6.5s vs 8.0s (-19%) optional-props 5.0s vs 6.9s (-28%) NodeScopeResolverTest: 10 known Bucket B failures remain (unchanged). All regression tests continue to pass. Re-baselines 4 fixtures whose 3-member unions now collapse under the lower cap. --- src/Type/TypeCombinator.php | 2 +- tests/PHPStan/Analyser/nsrt/array_splice.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13509.php | 2 +- tests/PHPStan/Analyser/nsrt/getopt.php | 2 +- tests/PHPStan/Analyser/nsrt/preg_match_shapes.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 37dc40c0b28..d56c9dba81b 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -48,7 +48,7 @@ final class TypeCombinator { - private const KEEP_SEPARATE_ARRAYS_LIMIT = 4; + private const KEEP_SEPARATE_ARRAYS_LIMIT = 2; public static function addNull(Type $type): Type { diff --git a/tests/PHPStan/Analyser/nsrt/array_splice.php b/tests/PHPStan/Analyser/nsrt/array_splice.php index a9e4694533d..279a9df25f3 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("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("non-empty-array<'b'|int<0, max>, 'bar'|'baz'|'x'|'y'|'z'|int|object|null>", $arr); assertType('array{\'foo\'}', $extract); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13509.php b/tests/PHPStan/Analyser/nsrt/bug-13509.php index 998245d0a30..6ff710ae4b8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13509.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13509.php @@ -80,7 +80,7 @@ function alert(): ?array return null; } - assertType("non-empty-list<(array{message: 'Foo', details: 'bar', duration: int<1, max>|null, severity: 100}&oversized-array)|(array{message: 'Idle', duration: int<1, max>|null, severity: 23}&oversized-array)|(array{message: 'No Queue', duration: int<1, max>|null, severity: 60}&oversized-array)|(array{message: 'Not Scheduled', duration: null, severity: 25}&oversized-array)|(array{message: 'Offline', duration: int<1, max>|null, severity: 99}&oversized-array)|(array{message: 'On Break'|'On Lunch', duration: int<1, max>|null, severity: 24}&oversized-array)|(array{message: 'Running W/O Operator', duration: int<1, max>|null, severity: 75}&oversized-array)>&oversized-array", $alerts); + assertType('non-empty-list&oversized-array>&oversized-array', $alerts); usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']); diff --git a/tests/PHPStan/Analyser/nsrt/getopt.php b/tests/PHPStan/Analyser/nsrt/getopt.php index f0e095dee88..aae0e128e54 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|array>|array|false)', $opts); +assertType('(array|string|false>|false)', $opts); assertType('int<1, max>', $restIndex); diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index aa67ee2d4c4..b5c23441dc8 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}>|array}>|array', $matches); + assertType('array}>|array}|string|null>', $matches); } assertType('array}|string|null>', $matches); }