Skip to content

Commit fcc8034

Browse files
ondrejmirtesclaude
andcommitted
Skip finite unsealed-tail keys that overlap explicit array shape keys
Explicit keys own their slot — the unsealed extras describe entries at keys NOT in the explicit set. When the tail key type has finite values (e.g. int<0, 5>), expansion via setOffsetValueType used to merge the tail value into matching explicit keys, making infinite-range tails (handled via makeUnsealed) and finite-range tails behave inconsistently. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent c698d4b commit fcc8034

2 files changed

Lines changed: 16 additions & 1 deletion

File tree

src/PhpDoc/TypeNodeResolver.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,12 +1099,16 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name
10991099
$builder = ConstantArrayTypeBuilder::createEmpty();
11001100
$builder->disableArrayDegradation();
11011101

1102+
$explicitKeyValues = [];
11021103
foreach ($typeNode->items as $itemNode) {
11031104
if ($itemNode->valueType instanceof CallableTypeNode) {
11041105
$builder->disableClosureDegradation();
11051106
}
11061107

11071108
$offsetType = $this->resolveArrayShapeOffsetType($itemNode, $nameScope);
1109+
if ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) {
1110+
$explicitKeyValues[] = $offsetType->getValue();
1111+
}
11081112
$builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional);
11091113
}
11101114

@@ -1138,6 +1142,14 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name
11381142
$unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope);
11391143
if (count($unsealedKeyFiniteTypes) > 0) {
11401144
foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) {
1145+
// Explicit keys own their slot — the unsealed extras
1146+
// describe entries at keys NOT in the explicit set.
1147+
if (
1148+
($unsealedKeyFiniteType instanceof ConstantIntegerType || $unsealedKeyFiniteType instanceof ConstantStringType)
1149+
&& in_array($unsealedKeyFiniteType->getValue(), $explicitKeyValues, true)
1150+
) {
1151+
continue;
1152+
}
11411153
$builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true);
11421154
}
11431155
} else {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public function edgeCases(array $a, array $b, array $c): void
8080
{
8181
assertType('array<string, UnsealedArrayShapes\Foo>', $a);
8282
assertType('array{a: int, b?: string, c?: string}', $b);
83-
assertType('array{a: int, b: float|string, c?: string}', $c);
83+
assertType('array{a: int, b: float, c?: string}', $c);
8484
}
8585

8686
/**
@@ -454,6 +454,7 @@ class ConsistentKeyFetchWhenOverlappingWithUnsealedExtraKeys
454454
*/
455455
public function startDay(array $array): void
456456
{
457+
assertType('array{1: string, 2: string, ...<int, int>}', $array);
457458
assertType('string', $array[1]);
458459
}
459460

@@ -462,6 +463,7 @@ public function startDay(array $array): void
462463
*/
463464
public function startDay2(array $array): void
464465
{
466+
assertType('array{1: string, 2: string, ...<int<0, max>, int>}', $array);
465467
assertType('string', $array[1]);
466468
}
467469

@@ -470,6 +472,7 @@ public function startDay2(array $array): void
470472
*/
471473
public function startDay3(array $array): void
472474
{
475+
assertType('array{1: string, 2: string, 0?: int, 3?: int, 4?: int, 5?: int}', $array);
473476
assertType('string', $array[1]);
474477
}
475478

0 commit comments

Comments
 (0)