Skip to content

Commit 1065a0f

Browse files
ondrejmirtesclaude
andcommitted
Preserve integer range bounds for sealed array values in loop generalization
When `generalizeType` merges two sealed constant-array shapes on the fall-through ArrayType path, it kept the literal key union (so loop-bounded counter keys stayed within range) but still recursed into `generalizeType` for the values — which applies the loop-widening heuristic and widened e.g. `int<0, 5>` to `int<0, max>`. Keep the literal value union too (with a fallback to generalization when it outgrows the shape limit), so values like loop-built `array{int<0, 5>, int<1, 6>}` keep their bounds. Scoped to sealed shapes so the general `generalize()` widening contract for legacy arrays (ScopeTest::testGeneralize) is unaffected. Refs phpstan/phpstan#13647 Refs phpstan/phpstan#13637 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f7fb170 commit 1065a0f

4 files changed

Lines changed: 86 additions & 10 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4264,14 +4264,15 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
42644264

42654265
$resultTypes[] = $resultArrayBuilder->getArray();
42664266
} else {
4267-
// Both inputs are sealed constant array shapes — their
4268-
// key sets are finite by construction. When taking the
4269-
// fall-through ArrayType path we still recurse into
4270-
// `generalizeType` for the iterable key, which would
4271-
// widen e.g. `0|1` to `int<0, max>` and lose the loop's
4272-
// per-iteration precision. Instead, keep the literal
4273-
// union of constant keys so the loop's bound stays
4274-
// visible.
4267+
// Both inputs are sealed constant array shapes — their key
4268+
// sets are finite by construction. On the fall-through
4269+
// ArrayType path, recursing into `generalizeType` would
4270+
// widen e.g. `0|1` to `int<0, max>` — for both the keys and
4271+
// the values — losing the loop's per-iteration precision.
4272+
// Keep the literal union instead so the loop's bounds stay
4273+
// visible. (Scoped to sealed shapes so the general
4274+
// `generalize()` widening contract for legacy arrays — see
4275+
// ScopeTest::testGeneralize — is unaffected.)
42754276
$bothSealed = true;
42764277
foreach ([...$constantArrays['a'], ...$constantArrays['b']] as $constantArrayCheck) {
42774278
foreach ($constantArrayCheck->getConstantArrays() as $constantArrayInstance) {
@@ -4283,12 +4284,21 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
42834284
}
42844285
if ($bothSealed) {
42854286
$resultKeyType = TypeCombinator::union($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType());
4287+
$resultValueType = TypeCombinator::union($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType());
4288+
if ($resultValueType->isOversizedArray()->yes()) {
4289+
// The literal value union outgrew the shape limit (a
4290+
// deeply/widely nested value): fall back to generalizing
4291+
// it into a bounded range-keyed array rather than
4292+
// keeping an oversized literal shape.
4293+
$resultValueType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1));
4294+
}
42864295
} else {
42874296
$resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1));
4297+
$resultValueType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1));
42884298
}
42894299
$resultType = new ArrayType(
42904300
$resultKeyType,
4291-
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)),
4301+
$resultValueType,
42924302
);
42934303
$accessories = [];
42944304
if (
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13637;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function doesNotWork(): void
8+
{
9+
$final = [];
10+
11+
for ($i = 0; $i < 5; $i++) {
12+
$j = $i * 2;
13+
$k = $j + 1;
14+
$final[$i][$j][$k]['abc'] = $i;
15+
$final[$i][$j][$k]['def'] = $i;
16+
$final[$i][$j][$k]['ghi'] = $i;
17+
}
18+
19+
// The reported regression (innermost values widening to `int<0, max>`) is
20+
// fixed: they stay `int<0, 4>`. The middle key degenerates to `int` rather
21+
// than the ideal `int<0, 8>` — a minor key-precision residual in 3-level
22+
// nesting, not the value-widening bug from the issue.
23+
assertType('non-empty-array<int<0, 4>, non-empty-array<int, non-empty-array<int<1, 9>, array{abc: int<0, 4>, def?: int<0, 4>, ghi?: int<0, 4>}>>>', $final);
24+
}
25+
26+
function thisWorks(): void
27+
{
28+
$final = [];
29+
30+
for ($i = 0; $i < 5; $i++) {
31+
$j = $i * 2;
32+
$final[$i][$j]['abc'] = $i;
33+
$final[$i][$j]['def'] = $i;
34+
$final[$i][$j]['ghi'] = $i;
35+
}
36+
37+
assertType('non-empty-array<int<0, 4>, non-empty-array<int<0, 8>, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>', $final);
38+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13647;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function foo(): void
8+
{
9+
$a = [];
10+
$a[0] = [0, 1];
11+
12+
for ($i = 1; $i < 6; $i++) {
13+
$a[$i] = [$i, $i + 1];
14+
}
15+
16+
assertType('non-empty-array<int<0, 5>, array{int<0, 5>, int<1, 6>}>', $a);
17+
18+
for ($i = 1; $i < 6; $i++) {
19+
$b = $a;
20+
21+
$b[$i][0] = $a[$i - 1][0];
22+
$b[$i][1] = $a[$i - 1][1];
23+
24+
$a = $b;
25+
}
26+
27+
assertType('non-empty-array<int<0, 5>, array{int<0, 5>, int<1, 6>}>', $a);
28+
}

tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ public function loopWithExistingSealedKey(): void
174174
for ($i = 0; $i < 5; $i++) {
175175
$arr[$i] = $i;
176176
}
177-
assertType("non-empty-array<'x'|int<0, 4>, int<0, max>>", $arr);
177+
assertType("non-empty-array<'x'|int<0, 4>, int<0, 4>>", $arr);
178178
}
179179

180180
/**

0 commit comments

Comments
 (0)