Skip to content

Commit deac626

Browse files
committed
Correctly generalize unsealed array shapes
1 parent cab38fc commit deac626

2 files changed

Lines changed: 116 additions & 1 deletion

File tree

src/Analyser/MutatingScope.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4187,8 +4187,30 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
41874187

41884188
$resultTypes[] = $resultArrayBuilder->getArray();
41894189
} else {
4190+
// Both inputs are sealed constant array shapes — their
4191+
// key sets are finite by construction. When taking the
4192+
// fall-through ArrayType path we still recurse into
4193+
// `generalizeType` for the iterable key, which would
4194+
// widen e.g. `0|1` to `int<0, max>` and lose the loop's
4195+
// per-iteration precision. Instead, keep the literal
4196+
// union of constant keys so the loop's bound stays
4197+
// visible.
4198+
$bothSealed = true;
4199+
foreach ([...$constantArrays['a'], ...$constantArrays['b']] as $constantArrayCheck) {
4200+
foreach ($constantArrayCheck->getConstantArrays() as $constantArrayInstance) {
4201+
if (!$constantArrayInstance->isSealed()->yes()) {
4202+
$bothSealed = false;
4203+
break 2;
4204+
}
4205+
}
4206+
}
4207+
if ($bothSealed) {
4208+
$resultKeyType = TypeCombinator::union($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType());
4209+
} else {
4210+
$resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1));
4211+
}
41904212
$resultType = new ArrayType(
4191-
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)),
4213+
$resultKeyType,
41924214
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)),
41934215
);
41944216
$accessories = [];

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,99 @@ public function sealedBecomesUnsealed(string $s, int $i): void
136136
assertType('non-empty-array<string, \'foo\'>', $e);
137137
}
138138

139+
/**
140+
* Loop iteration's `generalizeType` previously widened the integer key
141+
* of a constant array shape to `int<0, max>` whenever the prev/current
142+
* iterations had different (but finite) key sets. With the fix that
143+
* keeps the constant-array key union when both shapes are sealed,
144+
* loop-bounded counters stay within their actual range.
145+
*/
146+
public function loopBoundedCounter(): void
147+
{
148+
$arr = [];
149+
for ($i = 0; $i < 5; $i++) {
150+
$arr[$i] = 'v';
151+
}
152+
assertType("non-empty-array<int<0, 4>, 'v'>", $arr);
153+
}
154+
155+
public function loopBoundedCounterWithCondition(): void
156+
{
157+
$arr = [];
158+
for ($i = 0; $i < 5; $i++) {
159+
if (rand()) {
160+
$arr[$i] = 'v';
161+
}
162+
}
163+
assertType("array<int<0, 4>, 'v'>", $arr);
164+
}
165+
166+
/**
167+
* The existing `'x'` key keeps its sealed slot through all iterations
168+
* while the int counter grows; generalize merges the two sealed shapes
169+
* via key union (no widening to `int<0, max>`).
170+
*/
171+
public function loopWithExistingSealedKey(): void
172+
{
173+
$arr = ['x' => 0];
174+
for ($i = 0; $i < 5; $i++) {
175+
$arr[$i] = $i;
176+
}
177+
assertType("non-empty-array<'x'|int<0, 4>, int<0, max>>", $arr);
178+
}
179+
180+
/**
181+
* Each iteration the body assigns a sealed constant key, then a
182+
* non-constant offset — that second assignment promotes the array
183+
* from sealed to unsealed (folding the unknown offset/value into the
184+
* unsealed extras). The iteration's converged shape stays bounded by
185+
* the loop's cond instead of widening to `int<0, max>`.
186+
*/
187+
public function loopSealedBecomesUnsealedEachIteration(string $s): void
188+
{
189+
$arr = [];
190+
for ($i = 0; $i < 3; $i++) {
191+
$arr[$i] = 'sealed';
192+
$arr[$s . '_' . $i] = 'unsealed';
193+
}
194+
assertType("non-empty-array<int<0, 2>|non-falsy-string, literal-string&lowercase-string&non-falsy-string>", $arr);
195+
}
196+
197+
/**
198+
* Starting from a PHPDoc-declared unsealed shape, a loop adds further
199+
* non-constant entries. The sealed prefix (`a`) survives, the existing
200+
* unsealed extras get unioned with the loop's per-iteration extras.
201+
*/
202+
public function loopMergesUnsealedExtras(string $key): void
203+
{
204+
/** @var array{a: int, ...<string, int>} $arr */
205+
$arr = ['a' => 1];
206+
for ($i = 0; $i < 3; $i++) {
207+
$arr[$key . $i] = $i;
208+
}
209+
assertType("array{a: int, ...<string, int>}", $arr);
210+
}
211+
212+
/**
213+
* Joining two unsealed shapes with disjoint sealed prefixes via
214+
* scope merging collapses the result to a general array of
215+
* `string => int` — neither sealed prefix survives because each is
216+
* optional from the other branch's perspective and the unsealed
217+
* extras of both sides cover the same key/value space.
218+
*
219+
* @param array{a: int, ...<string, int>} $u1
220+
* @param array{b: int, ...<string, int>} $u2
221+
*/
222+
public function twoUnsealedJoined(array $u1, array $u2, bool $cond): void
223+
{
224+
if ($cond) {
225+
$arr = $u1;
226+
} else {
227+
$arr = $u2;
228+
}
229+
assertType("non-empty-array<string, int>", $arr);
230+
}
231+
139232
}
140233

141234
class Generics

0 commit comments

Comments
 (0)