Skip to content

Commit 4390ece

Browse files
ondrejmirtesclaude
andcommitted
Bail early in TypeUtils::flattenTypes() to avoid 2^N getAllArrays() expansion
Estimate the total power-set variant count before calling `getAllArrays()`. When a `ConstantArrayType` has more than ~14 optional keys (16384+ variants), return the type as-is instead of expanding. Also apply pairwise `TypeCombinator::intersect` folding in the combination loop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 03c33d2 commit 4390ece

File tree

2 files changed

+62
-10
lines changed

2 files changed

+62
-10
lines changed

src/Type/TypeUtils.php

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
use PHPStan\Type\Generic\TemplateType;
1111
use PHPStan\Type\Generic\TemplateUnionType;
1212
use PHPStan\Type\Traverser\LateResolvableTraverser;
13-
use function array_filter;
14-
use function array_map;
1513
use function array_merge;
16-
use function iterator_to_array;
14+
use function count;
15+
use function max;
16+
use const PHP_INT_MAX;
1717

1818
/**
1919
* @api
@@ -147,18 +147,46 @@ public static function flattenTypes(Type $type): array
147147

148148
$constantArrays = $type->getConstantArrays();
149149
if ($constantArrays !== []) {
150+
// Estimate the total number of power-set variants before expanding.
151+
// Each ConstantArrayType with N optional keys produces 2^N variants
152+
// from getAllArrays(). The cartesian product across multiple constant
153+
// arrays multiplies these counts. Bail out to avoid O(2^N) allocation
154+
// when the total would be large.
155+
$estimatedCount = 1;
156+
$bail = false;
157+
foreach ($constantArrays as $constantArray) {
158+
$optionalCount = count($constantArray->getOptionalKeys());
159+
$arrayCount = $optionalCount <= 20 ? (1 << $optionalCount) : PHP_INT_MAX;
160+
if ($arrayCount > 16384 || $estimatedCount > 16384 / max($arrayCount, 1)) {
161+
$bail = true;
162+
break;
163+
}
164+
$estimatedCount *= $arrayCount;
165+
}
166+
167+
if ($bail) {
168+
return [$type];
169+
}
170+
150171
$newTypes = [];
151172
foreach ($constantArrays as $constantArray) {
152173
$newTypes[] = $constantArray->getAllArrays();
153174
}
154175

155-
return array_filter(
156-
array_map(
157-
static fn (array $types): Type => TypeCombinator::intersect(...$types),
158-
iterator_to_array(CombinationsHelper::combinations($newTypes)),
159-
),
160-
static fn (Type $type): bool => !$type instanceof NeverType,
161-
);
176+
$result = [];
177+
foreach (CombinationsHelper::combinations($newTypes) as $combination) {
178+
$intersected = $combination[0];
179+
for ($i = 1, $count = count($combination); $i < $count; $i++) {
180+
$intersected = TypeCombinator::intersect($intersected, $combination[$i]);
181+
}
182+
if ($intersected instanceof NeverType) {
183+
continue;
184+
}
185+
186+
$result[] = $intersected;
187+
}
188+
189+
return $result;
162190
}
163191

164192
return [$type];
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BenchFlattenTypesOptionalKeysBlowup;
4+
5+
/**
6+
* Regression test for TypeUtils::flattenTypes() calling getAllArrays() on arrays with many optional keys.
7+
* getAllArrays() generates 2^N ConstantArrayType objects for N optional keys.
8+
* The fix adds a bail-out check and also applies pairwise intersect folding.
9+
*
10+
* @param array{
11+
* a?: int, b?: int, c?: int, d?: int, e?: int,
12+
* f?: int, g?: int, h?: int, i?: int, j?: int,
13+
* k?: int, l?: int, m?: int, n?: int, o?: int,
14+
* p?: int, q?: int, r?: int
15+
* } $data
16+
*/
17+
function checkOffset(array $data): void
18+
{
19+
echo $data['a'];
20+
echo $data['b'];
21+
echo $data['c'];
22+
echo $data['d'];
23+
echo $data['e'];
24+
}

0 commit comments

Comments
 (0)