From 9c54fe745d36e0f60483e3d538bd63ffc5006753 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:14:18 +0200 Subject: [PATCH 1/2] Two-stage collapse for oversized constant arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of `TypeCombinator` changes from two `unsealed`-branch commits, plus their test updates: - `optimizeConstantArrays` now runs a stage 1 same-key-set collapse before falling back to the lossy generalization. Variants sharing a key signature `mergeWith` losslessly into a single shape; the per-position record structure survives, only the values widen. - `reduceArrays` final pass collapses the loop-accumulator triangular variant pattern (conditional `$xs[] = …` push sites leaving behind list variants of progressively longer length) into a single `non-empty-list` when their cumulative `countConstantArrayValueTypes` exceeds the limit. Skips when every list variant shares one key signature — those are stage 1's job (per-position precision instead of a flat fold), and on 2.1.x without the unsealed-branch's `getUnsealedTypes()` pre-pass the flat fold would otherwise pre-empt stage 1 and regress bug-7963's 144-record literal. Knock-on effects: - bug-10717: language-code lookup now produces the precise union of every code instead of `bool|literal-string`. - bug-13509: alert variants land on the precise union of seven record shapes instead of the previous `&oversized-array` decomposition. - New nsrt `oversized-array-stages.php` exercises both phases: Phase 1 (small literal preserved as-is) and Phase 2 (eight conditional pushes with same-shape records — the triangular union of list variants pushes the count past the limit, list-collapse folds them into a `non-empty-list` whose value type preserves the per-record `(kind, value, opts)` correlation as a tagged union of the eight original record shapes). Co-Authored-By: Claude Opus 4.7 (1M context) --- phpstan-baseline.neon | 2 +- src/Type/TypeCombinator.php | 94 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-10717.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13509.php | 2 +- .../Analyser/nsrt/oversized-array-stages.php | 74 +++++++++++++++ 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/oversized-array-stages.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8616d227c86..9f83d85139e 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1698,7 +1698,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 18 + count: 19 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 901c78de7ee..8181f93fe0d 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -35,6 +35,7 @@ use function array_values; use function count; use function get_class; +use function implode; use function in_array; use function is_int; use function sprintf; @@ -976,6 +977,53 @@ private static function optimizeConstantArrays(array $types): array return $types; } + // Stage 1: collapse same-key-set ConstantArrayType variants per-position + // before the (lossy) generalization below kicks in. Variants with the + // same key signature mergeWith losslessly into a single shape whose + // values at each position are the union of the variants' values, which + // drops the count while keeping the per-position structure. Without + // this, a list of N similarly-shaped records (e.g. bug-7963) hits the + // limit and the generalization decomposes every nested constant array + // into a flat `non-empty-list`, losing the + // shape entirely. + $signatureGroups = []; + $nonConstantTypes = []; + foreach ($types as $idx => $type) { + if (!$type instanceof ConstantArrayType) { + $nonConstantTypes[$idx] = $type; + continue; + } + $signatureParts = []; + $signatureParts[] = $type->isList()->yes() ? 'L' : 'A'; + foreach ($type->getKeyTypes() as $i => $keyType) { + $signatureParts[] = ($type->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue(); + } + $signatureGroups[implode(',', $signatureParts)][] = $type; + } + if ($signatureGroups !== []) { + $collapsed = $nonConstantTypes; + $anyMerged = false; + foreach ($signatureGroups as $group) { + if (count($group) === 1) { + $collapsed[] = $group[0]; + continue; + } + $merged = $group[0]; + for ($i = 1, $count = count($group); $i < $count; $i++) { + $merged = $merged->mergeWith($group[$i]); + } + $collapsed[] = $merged; + $anyMerged = true; + } + if ($anyMerged) { + $types = array_values($collapsed); + $constantArrayValuesCount = self::countConstantArrayValueTypes($types); + if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return $types; + } + } + } + $results = []; $eachIsOversized = true; foreach ($types as $type) { @@ -1208,6 +1256,52 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + // Final pass: collapse the loop-accumulator pattern where each iteration + // produced a longer non-empty list variant. When several non-empty list + // ConstantArrayTypes survive earlier merging and together push the + // constant-array value count past the limit, fold them into a single + // non-empty-list so the result stays bounded without + // going through the lossier optimizeConstantArrays generalization. + // Skip when every list variant shares one key signature — those collapse + // losslessly via the stage 1 same-key-set merge in optimizeConstantArrays + // (each position keeps its own value union), which is strictly more + // precise than this flat fold. + if ($preserveTaggedUnions && count($arraysToProcess) > 1) { + $listVariantIndices = []; + $listValueTypes = []; + $listVariants = []; + $listVariantSignatures = []; + foreach ($arraysToProcess as $idx => $arr) { + if (!$arr->isList()->yes() || !$arr->isIterableAtLeastOnce()->yes()) { + continue; + } + $listVariantIndices[] = $idx; + $listValueTypes[] = $arr->getIterableValueType(); + $listVariants[] = $arr; + $signatureParts = []; + foreach ($arr->getKeyTypes() as $i => $keyType) { + $signatureParts[] = ($arr->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue(); + } + $listVariantSignatures[implode(',', $signatureParts)] = true; + } + if ( + count($listVariantIndices) >= 2 + && count($listVariantSignatures) >= 2 + && self::countConstantArrayValueTypes($listVariants) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT + ) { + $mergedValueType = self::union(...$listValueTypes); + $merged = self::intersect( + new ArrayType(new IntegerType(), $mergedValueType), + new NonEmptyArrayType(), + new AccessoryArrayListType(), + ); + $newArrays[] = $merged; + foreach ($listVariantIndices as $idx) { + unset($arraysToProcess[$idx]); + } + } + } + return array_merge($newArrays, $arraysToProcess); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10717.php b/tests/PHPStan/Analyser/nsrt/bug-10717.php index a7752842476..9cced78af76 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10717.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10717.php @@ -1046,7 +1046,7 @@ function test(string $code): void if ($country === 'fo' || $country === 'Faroese' || $country === 'Føroyskt') { // foo } else { - assertType('(bool|(literal-string&non-falsy-string))', $country); + assertType("'al'|'am'|'az'|'ba'|'bd'|'bg'|'br'|'by'|'ca'|'cn'|'cz'|'de'|'dk'|'ee'|'eo'|'er'|'es'|'es-ca'|'es-ga'|'et'|'eus'|'fi'|'fj'|'fr'|'fr-co'|'gb'|'gb-sct'|'gb-wls'|'ge'|'gh'|'gr'|'hmn'|'hr'|'ht'|'hu'|'hw'|'id'|'ie'|'il'|'in'|'iq'|'ir'|'is'|'it'|'jp'|'ke'|'kg'|'kh'|'kr'|'kz'|'la'|'lk'|'lt'|'lu'|'lv'|'mg'|'mk'|'ml'|'mm'|'mn'|'mt'|'mw'|'my'|'ne'|'ng'|'nl'|'no'|'np'|'nz'|'pf'|'ph'|'pk'|'pl'|'pt'|'ro'|'rs'|'ru'|'rw'|'sa'|'sd'|'se'|'si'|'sk'|'so'|'th'|'tj'|'to'|'tr'|'tw'|'ua'|'ug'|'uig'|'uz'|'vn'|'ws'|'za'|'zw'", $country); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13509.php b/tests/PHPStan/Analyser/nsrt/bug-13509.php index 6ff710ae4b8..6536823b4b3 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|null, severity: 100}|array{message: 'Idle', duration: int<1, max>|null, severity: 23}|array{message: 'No Queue', duration: int<1, max>|null, severity: 60}|array{message: 'Not Scheduled', duration: null, severity: 25}|array{message: 'Offline', duration: int<1, max>|null, severity: 99}|array{message: 'On Break'|'On Lunch', duration: int<1, max>|null, severity: 24}|array{message: 'Running W/O Operator', duration: int<1, max>|null, severity: 75}>", $alerts); usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']); diff --git a/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php new file mode 100644 index 00000000000..4d3a9ef9202 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/oversized-array-stages.php @@ -0,0 +1,74 @@ + 'a', 'value' => 1]; + $arr[] = ['kind' => 'b', 'value' => 2]; + $arr[] = ['kind' => 'c', 'value' => 3]; + assertType("array{array{kind: 'a', value: 1}, array{kind: 'b', value: 2}, array{kind: 'c', value: 3}}", $arr); + + return $arr; +} + +/** + * Phase 2: conditional `$items[] = …` pushes leave behind a triangular + * union of list variants of progressively longer length. Together + * they push `countConstantArrayValueTypes` past `ARRAY_COUNT_LIMIT`, + * which triggers the `reduceArrays` final-pass list-collapse: the + * variants fold into `non-empty-list` — the + * `unionValueType` is the union of each variant's iterable value + * type, which preserves the per-record `(kind, value, opts)` + * correlation as a tagged union of the eight original record shapes. + * Without the list-collapse, `optimizeConstantArrays`'s fallback + * generalization would decompose every record into a flat + * `non-empty-array&oversized-array`, losing + * both the per-record correlation and the sealed shape. + */ +function phase2TriangularCollapse(): array +{ + $items = []; + + if (rand()) { + $items[] = ['kind' => 'k1', 'value' => 1, 'opts' => ['a' => 1]]; + } + if (rand()) { + $items[] = ['kind' => 'k2', 'value' => 2, 'opts' => ['a' => 2]]; + } + if (rand()) { + $items[] = ['kind' => 'k3', 'value' => 3, 'opts' => ['a' => 3]]; + } + if (rand()) { + $items[] = ['kind' => 'k4', 'value' => 4, 'opts' => ['a' => 4]]; + } + if (rand()) { + $items[] = ['kind' => 'k5', 'value' => 5, 'opts' => ['a' => 5]]; + } + if (rand()) { + $items[] = ['kind' => 'k6', 'value' => 6, 'opts' => ['a' => 6]]; + } + if (rand()) { + $items[] = ['kind' => 'k7', 'value' => 7, 'opts' => ['a' => 7]]; + } + if (rand()) { + $items[] = ['kind' => 'k8', 'value' => 8, 'opts' => ['a' => 8]]; + } + + if ($items === []) { + return []; + } + + assertType("non-empty-list", $items); + + return $items; +} From 5efea01f87de804bc055d7cce9f6951b38c9e6dd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:41:36 +0200 Subject: [PATCH 2/2] Add regression test for #8636 Closes https://github.com/phpstan/phpstan/issues/8636 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rules/Methods/ReturnTypeRuleTest.php | 5 + tests/PHPStan/Rules/Methods/data/bug-8636.php | 317 ++++++++++++++++++ 2 files changed, 322 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-8636.php diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index efbb967e55f..5930c600443 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -377,6 +377,11 @@ public function testBug2885(): void $this->analyse([__DIR__ . '/data/bug-2885.php'], []); } + public function testBug8636(): void + { + $this->analyse([__DIR__ . '/data/bug-8636.php'], []); + } + public function testMergeInheritedPhpDocs(): void { $this->analyse([__DIR__ . '/data/merge-inherited-return.php'], [ diff --git a/tests/PHPStan/Rules/Methods/data/bug-8636.php b/tests/PHPStan/Rules/Methods/data/bug-8636.php new file mode 100644 index 00000000000..ff166c7db02 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-8636.php @@ -0,0 +1,317 @@ + [ + 'p1' => 'en', + 'p2' => 'Austria', + 'p3' => 'b', + 'p4' => 'de_AT', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + ]; + + public const HUGE_CONST = [ + 'at' => [ + 'p1' => 'en', + 'p2' => 'Austria', + 'p3' => 'b', + 'p4' => 'de_AT', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'au' => [ + 'p1' => 'en', + 'p2' => 'Australia', + 'p3' => 'b', + 'p4' => 'en_AU', + 'p5' => 'AUD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a'], + ], + + 'be' => [ + 'p1' => 'fr', + 'p2' => 'Belgium', + 'p3' => 'b', + 'p4' => 'fr_BE', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'bx' => [ + 'p1' => 'en', + 'p2' => 'Belgium', + 'p3' => 'b', + 'p4' => 'nl_BE', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'ca' => [ + 'p1' => 'en', + 'p2' => 'Canada', + 'p3' => 'b', + 'p4' => 'en_CA', + 'p5' => 'CAD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'xf' => [ + 'p1' => 'fr', + 'p2' => 'Canada', + 'p3' => 'b', + 'p4' => 'fr_CA', + 'p5' => 'CAD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'ch' => [ + 'p1' => 'fr', + 'p2' => 'Switzerland', + 'p3' => 'b', + 'p4' => 'fr_CH', + 'p5' => 'CHF', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a'], + ], + + 'cx' => [ + 'p1' => 'en', + 'p2' => 'Switzerland', + 'p3' => 'b', + 'p4' => 'de_CH', + 'p5' => 'CHF', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'cn' => [ + 'p1' => 'en', + 'p2' => 'China', + 'p3' => 'b', + 'p4' => 'zh_CN', + 'p5' => 'CNY', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a'], + ], + + 'de' => [ + 'p1' => 'en', + 'p2' => 'Germany', + 'p3' => 'b', + 'p4' => 'de_DE', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'es' => [ + 'p1' => 'en', + 'p2' => 'Spain', + 'p3' => 'b', + 'p4' => 'es_ES', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'fr' => [ + 'p1' => 'fr', + 'p2' => 'France', + 'p3' => 'b', + 'p4' => 'fr_FR', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a'], + ], + + 'hk' => [ + 'p1' => 'en', + 'p2' => 'Hong-Kong', + 'p3' => 'b', + 'p4' => 'en_HK', + 'p5' => 'HKD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'hz' => [ + 'p1' => 'en', + 'p2' => 'Hong-Kong', + 'p3' => 'b', + 'p4' => 'zh_HK', + 'p5' => 'HKD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'ie' => [ + 'p1' => 'en', + 'p2' => 'Ireland', + 'p3' => 'b', + 'p4' => 'en_IE', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'it' => [ + 'p1' => 'en', + 'p2' => 'Italy', + 'p3' => 'b', + 'p4' => 'it_IT', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'jp' => [ + 'p1' => 'en', + 'p2' => 'Japan', + 'p3' => 'b', + 'p4' => 'ja_JP', + 'p5' => 'JPY', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'kr' => [ + 'p1' => 'en', + 'p2' => 'South Korea', + 'p3' => 'b', + 'p4' => 'ko_KR', + 'p5' => 'KRW', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'nl' => [ + 'p1' => 'en', + 'p2' => 'Netherlands', + 'p3' => 'b', + 'p4' => 'nl_NL', + 'p5' => 'EUR', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'nz' => [ + 'p1' => 'en', + 'p2' => 'New Zealand', + 'p3' => 'b', + 'p4' => 'en_NZ', + 'p5' => 'NZD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'pl' => [ + 'p1' => 'en', + 'p2' => 'Poland', + 'p3' => 'b', + 'p4' => 'pl_PL', + 'p5' => 'PLN', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'sg' => [ + 'p1' => 'en', + 'p2' => 'Singapore', + 'p3' => 'b', + 'p4' => 'en_SG', + 'p5' => 'SGD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + 'tw' => [ + 'p1' => 'en', + 'p2' => 'Taiwan', + 'p3' => 'b', + 'p4' => 'zh_TW', + 'p5' => 'TWD', + 'p6' => 'https://', + 'p7' => 'https://', + 'p8' => [], + 'p9' => ['a', 'a', 'a'], + ], + + ]; + + public function simple(string $c, string $r): string + { + return str_replace( + 'a', + 'b', + self::SIMPLE_CONST[$c]['p7'], + ); + } + + public function huge(string $c, string $r): string + { + return str_replace( + 'a', + 'b', + self::HUGE_CONST[$c]['p7'], + ); + } +} \ No newline at end of file