Skip to content

Commit e1acb89

Browse files
ondrejmirtesclaude
andcommitted
Two-stage collapse for oversized constant arrays
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<unionValueType>` 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) <noreply@anthropic.com>
1 parent f15b7a2 commit e1acb89

5 files changed

Lines changed: 171 additions & 3 deletions

File tree

phpstan-baseline.neon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1698,7 +1698,7 @@ parameters:
16981698
-
16991699
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.'
17001700
identifier: phpstanApi.instanceofType
1701-
count: 18
1701+
count: 19
17021702
path: src/Type/TypeCombinator.php
17031703

17041704
-

src/Type/TypeCombinator.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use function array_values;
3636
use function count;
3737
use function get_class;
38+
use function implode;
3839
use function in_array;
3940
use function is_int;
4041
use function sprintf;
@@ -976,6 +977,53 @@ private static function optimizeConstantArrays(array $types): array
976977
return $types;
977978
}
978979

980+
// Stage 1: collapse same-key-set ConstantArrayType variants per-position
981+
// before the (lossy) generalization below kicks in. Variants with the
982+
// same key signature mergeWith losslessly into a single shape whose
983+
// values at each position are the union of the variants' values, which
984+
// drops the count while keeping the per-position structure. Without
985+
// this, a list of N similarly-shaped records (e.g. bug-7963) hits the
986+
// limit and the generalization decomposes every nested constant array
987+
// into a flat `non-empty-list<unionOfAllPositionValues>`, losing the
988+
// shape entirely.
989+
$signatureGroups = [];
990+
$nonConstantTypes = [];
991+
foreach ($types as $idx => $type) {
992+
if (!$type instanceof ConstantArrayType) {
993+
$nonConstantTypes[$idx] = $type;
994+
continue;
995+
}
996+
$signatureParts = [];
997+
$signatureParts[] = $type->isList()->yes() ? 'L' : 'A';
998+
foreach ($type->getKeyTypes() as $i => $keyType) {
999+
$signatureParts[] = ($type->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
1000+
}
1001+
$signatureGroups[implode(',', $signatureParts)][] = $type;
1002+
}
1003+
if ($signatureGroups !== []) {
1004+
$collapsed = $nonConstantTypes;
1005+
$anyMerged = false;
1006+
foreach ($signatureGroups as $group) {
1007+
if (count($group) === 1) {
1008+
$collapsed[] = $group[0];
1009+
continue;
1010+
}
1011+
$merged = $group[0];
1012+
for ($i = 1, $count = count($group); $i < $count; $i++) {
1013+
$merged = $merged->mergeWith($group[$i]);
1014+
}
1015+
$collapsed[] = $merged;
1016+
$anyMerged = true;
1017+
}
1018+
if ($anyMerged) {
1019+
$types = array_values($collapsed);
1020+
$constantArrayValuesCount = self::countConstantArrayValueTypes($types);
1021+
if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) {
1022+
return $types;
1023+
}
1024+
}
1025+
}
1026+
9791027
$results = [];
9801028
$eachIsOversized = true;
9811029
foreach ($types as $type) {
@@ -1208,6 +1256,52 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged
12081256
}
12091257
}
12101258

1259+
// Final pass: collapse the loop-accumulator pattern where each iteration
1260+
// produced a longer non-empty list variant. When several non-empty list
1261+
// ConstantArrayTypes survive earlier merging and together push the
1262+
// constant-array value count past the limit, fold them into a single
1263+
// non-empty-list<unionValueType> so the result stays bounded without
1264+
// going through the lossier optimizeConstantArrays generalization.
1265+
// Skip when every list variant shares one key signature — those collapse
1266+
// losslessly via the stage 1 same-key-set merge in optimizeConstantArrays
1267+
// (each position keeps its own value union), which is strictly more
1268+
// precise than this flat fold.
1269+
if ($preserveTaggedUnions && count($arraysToProcess) > 1) {
1270+
$listVariantIndices = [];
1271+
$listValueTypes = [];
1272+
$listVariants = [];
1273+
$listVariantSignatures = [];
1274+
foreach ($arraysToProcess as $idx => $arr) {
1275+
if (!$arr->isList()->yes() || !$arr->isIterableAtLeastOnce()->yes()) {
1276+
continue;
1277+
}
1278+
$listVariantIndices[] = $idx;
1279+
$listValueTypes[] = $arr->getIterableValueType();
1280+
$listVariants[] = $arr;
1281+
$signatureParts = [];
1282+
foreach ($arr->getKeyTypes() as $i => $keyType) {
1283+
$signatureParts[] = ($arr->isOptionalKey($i) ? '?' : '!') . ($keyType instanceof ConstantIntegerType ? 'i' : 's') . $keyType->getValue();
1284+
}
1285+
$listVariantSignatures[implode(',', $signatureParts)] = true;
1286+
}
1287+
if (
1288+
count($listVariantIndices) >= 2
1289+
&& count($listVariantSignatures) >= 2
1290+
&& self::countConstantArrayValueTypes($listVariants) > ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT
1291+
) {
1292+
$mergedValueType = self::union(...$listValueTypes);
1293+
$merged = self::intersect(
1294+
new ArrayType(new IntegerType(), $mergedValueType),
1295+
new NonEmptyArrayType(),
1296+
new AccessoryArrayListType(),
1297+
);
1298+
$newArrays[] = $merged;
1299+
foreach ($listVariantIndices as $idx) {
1300+
unset($arraysToProcess[$idx]);
1301+
}
1302+
}
1303+
}
1304+
12111305
return array_merge($newArrays, $arraysToProcess);
12121306
}
12131307

tests/PHPStan/Analyser/nsrt/bug-10717.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1046,7 +1046,7 @@ function test(string $code): void
10461046
if ($country === 'fo' || $country === 'Faroese' || $country === 'Føroyskt') {
10471047
// foo
10481048
} else {
1049-
assertType('(bool|(literal-string&non-falsy-string))', $country);
1049+
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);
10501050
}
10511051
}
10521052

tests/PHPStan/Analyser/nsrt/bug-13509.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ function alert(): ?array
8080
return null;
8181
}
8282

83-
assertType('non-empty-list<non-empty-array<literal-string&lowercase-string&non-falsy-string, int|(literal-string&non-falsy-string)|null>&oversized-array>&oversized-array', $alerts);
83+
assertType("non-empty-list<array{message: 'Foo', details: 'bar', duration: int<1, max>|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);
8484

8585
usort($alerts, fn ($a, $b) => $b['severity'] <=> $a['severity']);
8686

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
namespace OversizedArrayStages;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* Phase 1: small enough that no generalization is needed. The
9+
* cumulative `countConstantArrayValueTypes` stays under
10+
* `ARRAY_COUNT_LIMIT`, so `optimizeConstantArrays` short-circuits and
11+
* each variant is preserved literally.
12+
*/
13+
function phase1Small(): array
14+
{
15+
$arr = [];
16+
$arr[] = ['kind' => 'a', 'value' => 1];
17+
$arr[] = ['kind' => 'b', 'value' => 2];
18+
$arr[] = ['kind' => 'c', 'value' => 3];
19+
assertType("array{array{kind: 'a', value: 1}, array{kind: 'b', value: 2}, array{kind: 'c', value: 3}}", $arr);
20+
21+
return $arr;
22+
}
23+
24+
/**
25+
* Phase 2: conditional `$items[] = …` pushes leave behind a triangular
26+
* union of list variants of progressively longer length. Together
27+
* they push `countConstantArrayValueTypes` past `ARRAY_COUNT_LIMIT`,
28+
* which triggers the `reduceArrays` final-pass list-collapse: the
29+
* variants fold into `non-empty-list<unionValueType>` — the
30+
* `unionValueType` is the union of each variant's iterable value
31+
* type, which preserves the per-record `(kind, value, opts)`
32+
* correlation as a tagged union of the eight original record shapes.
33+
* Without the list-collapse, `optimizeConstantArrays`'s fallback
34+
* generalization would decompose every record into a flat
35+
* `non-empty-array<keyUnion, valueUnion>&oversized-array`, losing
36+
* both the per-record correlation and the sealed shape.
37+
*/
38+
function phase2TriangularCollapse(): array
39+
{
40+
$items = [];
41+
42+
if (rand()) {
43+
$items[] = ['kind' => 'k1', 'value' => 1, 'opts' => ['a' => 1]];
44+
}
45+
if (rand()) {
46+
$items[] = ['kind' => 'k2', 'value' => 2, 'opts' => ['a' => 2]];
47+
}
48+
if (rand()) {
49+
$items[] = ['kind' => 'k3', 'value' => 3, 'opts' => ['a' => 3]];
50+
}
51+
if (rand()) {
52+
$items[] = ['kind' => 'k4', 'value' => 4, 'opts' => ['a' => 4]];
53+
}
54+
if (rand()) {
55+
$items[] = ['kind' => 'k5', 'value' => 5, 'opts' => ['a' => 5]];
56+
}
57+
if (rand()) {
58+
$items[] = ['kind' => 'k6', 'value' => 6, 'opts' => ['a' => 6]];
59+
}
60+
if (rand()) {
61+
$items[] = ['kind' => 'k7', 'value' => 7, 'opts' => ['a' => 7]];
62+
}
63+
if (rand()) {
64+
$items[] = ['kind' => 'k8', 'value' => 8, 'opts' => ['a' => 8]];
65+
}
66+
67+
if ($items === []) {
68+
return [];
69+
}
70+
71+
assertType("non-empty-list<array{kind: 'k1', value: 1, opts: array{a: 1}}|array{kind: 'k2', value: 2, opts: array{a: 2}}|array{kind: 'k3', value: 3, opts: array{a: 3}}|array{kind: 'k4', value: 4, opts: array{a: 4}}|array{kind: 'k5', value: 5, opts: array{a: 5}}|array{kind: 'k6', value: 6, opts: array{a: 6}}|array{kind: 'k7', value: 7, opts: array{a: 7}}|array{kind: 'k8', value: 8, opts: array{a: 8}}>", $items);
72+
73+
return $items;
74+
}

0 commit comments

Comments
 (0)