Skip to content

Commit 5e30e5d

Browse files
github-actions[bot]phpstan-bot
authored andcommitted
Fix phpstan/phpstan#9691: Preserve per-key array types during loop generalization when values mix arrays and scalars
When an array has different keys holding structurally different value types (e.g., one key holds an int, another holds an array), loop type generalization would collapse all per-key type information into a general array<K, V>, causing false "Cannot access offset" errors when accessing keys that are known to hold array values. Add a new branch in MutatingScope::generalizeType() that detects when the wider array (B) has keys that are a superset of the narrower (A) and the value types span both array and non-array types. In this case, perform per-key value type merging to preserve structural type info.
1 parent 04a99c1 commit 5e30e5d

File tree

2 files changed

+76
-3
lines changed

2 files changed

+76
-3
lines changed

src/Analyser/MutatingScope.php

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4086,12 +4086,14 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
40864086
} else {
40874087
$constantArraysA = TypeCombinator::union(...$constantArrays['a']);
40884088
$constantArraysB = TypeCombinator::union(...$constantArrays['b']);
4089+
$aKeyType = $constantArraysA->getIterableKeyType();
4090+
$bKeyType = $constantArraysB->getIterableKeyType();
40894091
if (
4090-
$constantArraysA->getIterableKeyType()->equals($constantArraysB->getIterableKeyType())
4092+
$aKeyType->equals($bKeyType)
40914093
&& $constantArraysA->getArraySize()->getGreaterOrEqualType($this->phpVersion)->isSuperTypeOf($constantArraysB->getArraySize())->yes()
40924094
) {
40934095
$resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
4094-
foreach (TypeUtils::flattenTypes($constantArraysA->getIterableKeyType()) as $keyType) {
4096+
foreach (TypeUtils::flattenTypes($aKeyType) as $keyType) {
40954097
$resultArrayBuilder->setOffsetValueType(
40964098
$keyType,
40974099
$this->generalizeType(
@@ -4103,10 +4105,40 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
41034105
);
41044106
}
41054107

4108+
$resultTypes[] = $resultArrayBuilder->getArray();
4109+
} elseif (
4110+
$bKeyType->isSuperTypeOf($aKeyType)->yes()
4111+
&& !$aKeyType->equals($bKeyType)
4112+
&& $this->hasStructurallyMixedValueTypes($constantArraysB)
4113+
) {
4114+
$resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
4115+
foreach (TypeUtils::flattenTypes($bKeyType) as $keyType) {
4116+
$hasInA = $constantArraysA->hasOffsetValueType($keyType);
4117+
$hasInB = $constantArraysB->hasOffsetValueType($keyType);
4118+
4119+
if ($hasInA->no()) {
4120+
$valueType = $constantArraysB->getOffsetValueType($keyType);
4121+
} elseif ($hasInB->no()) {
4122+
$valueType = $constantArraysA->getOffsetValueType($keyType);
4123+
} else {
4124+
$valueType = $this->generalizeType(
4125+
$constantArraysA->getOffsetValueType($keyType),
4126+
$constantArraysB->getOffsetValueType($keyType),
4127+
$depth + 1,
4128+
);
4129+
}
4130+
4131+
$resultArrayBuilder->setOffsetValueType(
4132+
$keyType,
4133+
$valueType,
4134+
!$hasInA->and($hasInB)->negate()->no(),
4135+
);
4136+
}
4137+
41064138
$resultTypes[] = $resultArrayBuilder->getArray();
41074139
} else {
41084140
$resultType = new ArrayType(
4109-
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)),
4141+
TypeCombinator::union($this->generalizeType($aKeyType, $bKeyType, $depth + 1)),
41104142
TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)),
41114143
);
41124144
$accessories = [];
@@ -4349,6 +4381,24 @@ private static function getArrayDepth(Type $type): int
43494381
return $depth;
43504382
}
43514383

4384+
private function hasStructurallyMixedValueTypes(Type $arrayType): bool
4385+
{
4386+
$hasArrayValuePart = false;
4387+
$hasNonArrayValuePart = false;
4388+
foreach (TypeUtils::flattenTypes($arrayType->getIterableValueType()) as $innerType) {
4389+
if ($innerType->isArray()->yes()) {
4390+
$hasArrayValuePart = true;
4391+
}
4392+
if (!$innerType->isArray()->no()) {
4393+
continue;
4394+
}
4395+
4396+
$hasNonArrayValuePart = true;
4397+
}
4398+
4399+
return $hasArrayValuePart && $hasNonArrayValuePart;
4400+
}
4401+
43524402
public function equals(self $otherScope): bool
43534403
{
43544404
if (!$this->context->equals($otherScope->context)) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug9691;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
function (): void {
10+
$issues = [];
11+
$previousValue = 1;
12+
13+
for ($i = 0; $i < 2; $i++) {
14+
if ($previousValue === $i) {
15+
$issues[0] = 0;
16+
}
17+
$issues[1]['abc'] = 'def';
18+
19+
assertType("array{abc: 'def'}", $issues[1]);
20+
21+
$previousValue = $i;
22+
}
23+
};

0 commit comments

Comments
 (0)