Skip to content

Commit 85d5e63

Browse files
github-actions[bot]phpstan-bot
authored andcommitted
Preserve constant scalar types in same-key constant array generalization during loop widening
- In `MutatingScope::generalizeType()`, when generalizing constant arrays with matching keys, skip recursive `generalizeType` for non-integer, non-array value types when one side's type already encompasses the other (convergence check via `isSuperTypeOf`) - This prevents constant string and float values from being over-generalized to `literal-string&non-falsy-string` or `float` when they come from a bounded set of class constants - Integer values still use the full `generalizeType` path for proper range-based widening (e.g. counters → `int<min, max>`) - Array values still use the full path for proper structure generalization (e.g. growing lists) - Updated one existing test assertion that now correctly infers more precise constant string types instead of the previous over-generalized `literal-string` type
1 parent 04a99c1 commit 85d5e63

4 files changed

Lines changed: 171 additions & 6 deletions

File tree

src/Analyser/MutatingScope.php

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4092,13 +4092,25 @@ private function generalizeType(Type $a, Type $b, int $depth): Type
40924092
) {
40934093
$resultArrayBuilder = ConstantArrayTypeBuilder::createEmpty();
40944094
foreach (TypeUtils::flattenTypes($constantArraysA->getIterableKeyType()) as $keyType) {
4095+
$aValueType = $constantArraysA->getOffsetValueType($keyType);
4096+
$bValueType = $constantArraysB->getOffsetValueType($keyType);
4097+
4098+
$canPreserve = $aValueType->isInteger()->no()
4099+
&& $bValueType->isInteger()->no()
4100+
&& $aValueType->isArray()->no()
4101+
&& $bValueType->isArray()->no();
4102+
4103+
if ($canPreserve && $aValueType->isSuperTypeOf($bValueType)->yes()) {
4104+
$generalizedValue = $aValueType;
4105+
} elseif ($canPreserve && $bValueType->isSuperTypeOf($aValueType)->yes()) {
4106+
$generalizedValue = $bValueType;
4107+
} else {
4108+
$generalizedValue = $this->generalizeType($aValueType, $bValueType, $depth + 1);
4109+
}
4110+
40954111
$resultArrayBuilder->setOffsetValueType(
40964112
$keyType,
4097-
$this->generalizeType(
4098-
$constantArraysA->getOffsetValueType($keyType),
4099-
$constantArraysB->getOffsetValueType($keyType),
4100-
$depth + 1,
4101-
),
4113+
$generalizedValue,
41024114
!$constantArraysA->hasOffsetValueType($keyType)->and($constantArraysB->hasOffsetValueType($keyType))->negate()->no(),
41034115
);
41044116
}

tests/PHPStan/Analyser/nsrt/array-keys-branches.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function (array $generalArray) {
5858
assertType('mixed~null', $generalArray['key']);
5959
assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf);
6060
assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach);
61-
assertType('non-empty-array<int<0, max>, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach);
61+
assertType("non-empty-array<int<0, max>, 'bar'|'baz'|'foo'>", $anotherArrayAppendedInForeach);
6262
assertType('\'str\'', $array['n']);
6363
assertType('int<0, max>', $incremented);
6464
assertType('0|1', $setFromZeroToOne);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug12653;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Reproduction
8+
{
9+
const TYPE_XXX = 'xxx';
10+
const TYPE_YYY = 'yyy';
11+
const TYPE_ZZZ = 'zzz';
12+
13+
/**
14+
* @return array<'a'|'b'|'c'|'d',Reproduction::TYPE_*>
15+
*/
16+
public function main()
17+
{
18+
$list = [
19+
'a' => Reproduction::TYPE_XXX,
20+
'b' => Reproduction::TYPE_YYY,
21+
'c' => Reproduction::TYPE_ZZZ,
22+
'd' => Reproduction::TYPE_XXX,
23+
];
24+
25+
$keys = ['a', 'b', 'c', 'd'];
26+
$found = false;
27+
foreach ($keys as $key) {
28+
if ($list[$key] === Reproduction::TYPE_XXX) {
29+
// The first matched key is kept and subsequent matched keys are rewritten.
30+
if (!$found) {
31+
$found = true;
32+
} else {
33+
$list[$key] = Reproduction::TYPE_ZZZ;
34+
}
35+
}
36+
}
37+
38+
assertType("array{a: 'xxx'|'zzz', b: 'yyy'|'zzz', c: 'zzz', d: 'xxx'|'zzz'}", $list);
39+
40+
return $list;
41+
}
42+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug12653b;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class WhileLoopTest
8+
{
9+
const TYPE_XXX = 'xxx';
10+
const TYPE_YYY = 'yyy';
11+
const TYPE_ZZZ = 'zzz';
12+
13+
/**
14+
* @return array<'a'|'b'|'c'|'d', self::TYPE_*>
15+
*/
16+
public function whileLoop(): array
17+
{
18+
$list = [
19+
'a' => self::TYPE_XXX,
20+
'b' => self::TYPE_YYY,
21+
'c' => self::TYPE_ZZZ,
22+
'd' => self::TYPE_XXX,
23+
];
24+
25+
$keys = ['a', 'b', 'c', 'd'];
26+
$found = false;
27+
$i = 0;
28+
while ($i < count($keys)) {
29+
$key = $keys[$i];
30+
if ($list[$key] === self::TYPE_XXX) {
31+
if (!$found) {
32+
$found = true;
33+
} else {
34+
$list[$key] = self::TYPE_ZZZ;
35+
}
36+
}
37+
$i++;
38+
}
39+
assertType("array{a: 'xxx'|'zzz', b: 'yyy'|'zzz', c: 'zzz', d: 'xxx'|'zzz'}", $list);
40+
41+
return $list;
42+
}
43+
}
44+
45+
class ForLoopTest
46+
{
47+
const TYPE_XXX = 'xxx';
48+
const TYPE_YYY = 'yyy';
49+
const TYPE_ZZZ = 'zzz';
50+
51+
/**
52+
* @return array<'a'|'b'|'c'|'d', self::TYPE_*>
53+
*/
54+
public function forLoop(): array
55+
{
56+
$list = [
57+
'a' => self::TYPE_XXX,
58+
'b' => self::TYPE_YYY,
59+
'c' => self::TYPE_ZZZ,
60+
'd' => self::TYPE_XXX,
61+
];
62+
63+
$keys = ['a', 'b', 'c', 'd'];
64+
$found = false;
65+
for ($i = 0; $i < count($keys); $i++) {
66+
$key = $keys[$i];
67+
if ($list[$key] === self::TYPE_XXX) {
68+
if (!$found) {
69+
$found = true;
70+
} else {
71+
$list[$key] = self::TYPE_ZZZ;
72+
}
73+
}
74+
}
75+
assertType("array{a: 'xxx'|'zzz', b: 'yyy'|'zzz', c: 'zzz', d: 'xxx'|'zzz'}", $list);
76+
77+
return $list;
78+
}
79+
}
80+
81+
class FloatConstantArrayTest
82+
{
83+
const RATE_LOW = 0.5;
84+
const RATE_MED = 1.0;
85+
const RATE_HIGH = 1.5;
86+
87+
/**
88+
* @param list<'x'|'y'|'z'> $keys
89+
*/
90+
public function floatConstantsInArray(array $keys): void
91+
{
92+
$rates = [
93+
'x' => self::RATE_LOW,
94+
'y' => self::RATE_MED,
95+
'z' => self::RATE_HIGH,
96+
];
97+
98+
$found = false;
99+
foreach ($keys as $key) {
100+
if ($rates[$key] === self::RATE_LOW) {
101+
if (!$found) {
102+
$found = true;
103+
} else {
104+
$rates[$key] = self::RATE_HIGH;
105+
}
106+
}
107+
}
108+
109+
assertType("array{x: 0.5|1.5, y: 1.0|1.5, z: 1.5}", $rates);
110+
}
111+
}

0 commit comments

Comments
 (0)