Skip to content

Commit 3f97b5a

Browse files
phpstan-botclaude
authored andcommitted
Stop tracking the next implicit array key once it reaches PHP_INT_MAX
- `DuplicateKeysInLiteralArraysRule`: give up on `$autoGeneratedIndex` when it is already `PHP_INT_MAX` instead of `new ConstantIntegerType(++$autoGeneratedIndex)`, which overflowed to float and threw a `TypeError`. - `OversizedArrayBuilder`: same overflow in the >256-item array literal path; `$nextAutoIndex` is now nullable and unkeyed items after the overflow get a plain `IntegerType` key. - `ArrayFillFunctionReturnTypeExtension`: `array_fill()` whose start index plus count crosses `PHP_INT_MAX` no longer overflows the loop counter; it falls back to the general `array<int, T>` result. - `AssignHandler::processArrayByRefItems()`: the same `+ 1` overflowed and was passed to `PhpParser\Node\Scalar\Int_`; `$implicitIndex` now becomes `null` (the existing "unpredictable" state). - While tracking the same variable: unpacked items (`[...$b]`) contribute an unknown number of renumbered integer keys, so they now reset implicit-key tracking in `DuplicateKeysInLiteralArraysRule` and in `AssignHandler::processArrayByRefItems()`. This removes false `array.duplicateKey` reports for `['a', ...$b, 1 => 'x']`. - `ConstantArrayTypeBuilder`, `TypeCombinator` and `ConstantArrayType` were probed for the same pattern and already guard against the overflow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 857a5f4 commit 3f97b5a

7 files changed

Lines changed: 171 additions & 6 deletions

File tree

‎src/Analyser/ExprHandler/AssignHandler.php‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
use function is_int;
9393
use function is_nan;
9494
use function is_string;
95+
use const PHP_INT_MAX;
9596

9697
/**
9798
* @implements ExprHandler<Assign|AssignRef>
@@ -1932,6 +1933,12 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19321933
{
19331934
$implicitIndex = 0;
19341935
foreach ($arrayExpr->items as $arrayItem) {
1936+
if ($arrayItem->unpack) {
1937+
// An unpacked array contributes an unknown number of renumbered
1938+
// integer keys, so subsequent implicit indices are unpredictable
1939+
$implicitIndex = null;
1940+
}
1941+
19351942
if ($arrayItem->key !== null) {
19361943
$keyType = $scope->getType($arrayItem->key)->toArrayKey();
19371944

@@ -1940,7 +1947,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19401947
if (count($keyValues) === 1) {
19411948
$keyValue = $keyValues[0];
19421949
if (is_int($keyValue) && $keyValue >= $implicitIndex) {
1943-
$implicitIndex = $keyValue + 1;
1950+
$implicitIndex = $this->advanceImplicitIndex($keyValue);
19441951
}
19451952
} elseif (!$keyType->isInteger()->no()) {
19461953
// Key could be an integer, but we don't know which one,
@@ -1952,7 +1959,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19521959
$dimExpr = $arrayItem->key;
19531960
} elseif ($implicitIndex !== null) {
19541961
$dimExpr = new Node\Scalar\Int_($implicitIndex);
1955-
$implicitIndex++;
1962+
$implicitIndex = $this->advanceImplicitIndex($implicitIndex);
19561963
} else {
19571964
$dimExpr = new TypeExpr(new IntegerType());
19581965
}
@@ -1991,6 +1998,16 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19911998

19921999
private const ARRAY_DIM_FETCH_WRITE_DEPTH_LIMIT = 5;
19932000

2001+
/**
2002+
* Null means the next implicit key is unpredictable: PHP throws
2003+
* "Cannot add element to the array as the next element is already occupied"
2004+
* instead of wrapping the auto-index around to a float.
2005+
*/
2006+
private function advanceImplicitIndex(int $index): ?int
2007+
{
2008+
return $index === PHP_INT_MAX ? null : $index + 1;
2009+
}
2010+
19942011
/**
19952012
* @param non-empty-list<ArrayDimFetch> $dimFetchStack
19962013
* @param non-empty-list<array{Type|null, ArrayDimFetch}> $offsetTypes

‎src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
use function max;
2222
use function sprintf;
2323
use function var_export;
24+
use const PHP_INT_MAX;
2425

2526
/**
2627
* @implements Rule<LiteralArrayNode>
@@ -63,6 +64,13 @@ public function processNode(Node $node, Scope $scope): array
6364
continue;
6465
}
6566

67+
if ($item->unpack) {
68+
// An unpacked array contributes an unknown number of renumbered
69+
// integer keys, so the following implicit keys are unpredictable.
70+
$autoGeneratedIndex = false;
71+
continue;
72+
}
73+
6674
$key = $item->key;
6775
if ($key === null) {
6876
if ($autoGeneratedIndex === false) {
@@ -72,6 +80,11 @@ public function processNode(Node $node, Scope $scope): array
7280
if ($autoGeneratedIndex === null) {
7381
$autoGeneratedIndex = 0;
7482
$keyType = new ConstantIntegerType(0);
83+
} elseif ($autoGeneratedIndex === PHP_INT_MAX) {
84+
// PHP throws "Cannot add element to the array as the next
85+
// element is already occupied" instead of wrapping around.
86+
$autoGeneratedIndex = false;
87+
continue;
7588
} else {
7689
$keyType = new ConstantIntegerType(++$autoGeneratedIndex);
7790
}

‎src/Type/Constant/OversizedArrayBuilder.php‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@
1313
use PHPStan\Type\Accessory\OversizedArrayType;
1414
use PHPStan\Type\ArrayType;
1515
use PHPStan\Type\GeneralizePrecision;
16+
use PHPStan\Type\IntegerType;
1617
use PHPStan\Type\Type;
1718
use PHPStan\Type\TypeCombinator;
1819
use PHPStan\Type\VerbosityLevel;
1920
use function array_splice;
2021
use function array_values;
2122
use function count;
23+
use const PHP_INT_MAX;
2224

2325
#[AutowiredService]
2426
final class OversizedArrayBuilder
@@ -72,13 +74,16 @@ public function build(Array_ $expr, callable $getTypeCallback): Type
7274
$isList = false;
7375
} elseif ($itemKeyType->getValue() !== $nextAutoIndex) {
7476
$isList = false;
75-
$nextAutoIndex = $itemKeyType->getValue() + 1;
77+
$nextAutoIndex = $this->advanceAutoIndex($itemKeyType->getValue());
7678
} else {
77-
$nextAutoIndex++;
79+
$nextAutoIndex = $this->advanceAutoIndex($nextAutoIndex);
7880
}
81+
} elseif ($nextAutoIndex === null) {
82+
$isList = false;
83+
$itemKeyType = new IntegerType();
7984
} else {
8085
$itemKeyType = new ConstantIntegerType($nextAutoIndex);
81-
$nextAutoIndex++;
86+
$nextAutoIndex = $this->advanceAutoIndex($nextAutoIndex);
8287
}
8388

8489
$generalizedKeyType = $itemKeyType->generalize(GeneralizePrecision::moreSpecific());
@@ -101,4 +106,14 @@ public function build(Array_ $expr, callable $getTypeCallback): Type
101106
return TypeCombinator::intersect($arrayType, new NonEmptyArrayType(), new OversizedArrayType(), ...$accessories);
102107
}
103108

109+
/**
110+
* Null means the next implicit key is unpredictable: PHP throws
111+
* "Cannot add element to the array as the next element is already occupied"
112+
* instead of wrapping the auto-index around to a float.
113+
*/
114+
private function advanceAutoIndex(int $index): ?int
115+
{
116+
return $index === PHP_INT_MAX ? null : $index + 1;
117+
}
118+
104119
}

‎src/Type/Php/ArrayFillFunctionReturnTypeExtension.php‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use PHPStan\Type\Type;
2121
use PHPStan\Type\TypeCombinator;
2222
use function count;
23+
use const PHP_INT_MAX;
2324

2425
#[AutowiredService]
2526
final class ArrayFillFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
@@ -64,19 +65,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6465
) {
6566
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
6667
$nextIndex = $startIndexType->getValue();
68+
$overflows = false;
6769
for ($i = 0; $i < $numberType->getValue(); $i++) {
6870
$arrayBuilder->setOffsetValueType(
6971
new ConstantIntegerType($nextIndex),
7072
$valueType,
7173
);
7274
if ($nextIndex < 0) {
7375
$nextIndex = 0;
76+
} elseif ($nextIndex === PHP_INT_MAX) {
77+
// PHP throws "Cannot add element to the array as the next
78+
// element is already occupied" instead of wrapping around.
79+
$overflows = $i + 1 < $numberType->getValue();
80+
break;
7481
} else {
7582
$nextIndex++;
7683
}
7784
}
7885

79-
return $arrayBuilder->getArray();
86+
if (!$overflows) {
87+
return $arrayBuilder->getArray();
88+
}
8089
}
8190

8291
$resultType = new ArrayType(new IntegerType(), $valueType);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace Bug15244;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class Foo
8+
{
9+
10+
public function doArrayFill(): void
11+
{
12+
assertType("array{9223372036854775806: 'x', 9223372036854775807: 'x'}", array_fill(9223372036854775806, 2, 'x'));
13+
assertType("non-empty-array<int, 'x'>", array_fill(9223372036854775806, 3, 'x'));
14+
assertType("array{9223372036854775807: 'x'}", array_fill(9223372036854775807, 1, 'x'));
15+
assertType("non-empty-array<int, 'x'>", array_fill(9223372036854775807, 2, 'x'));
16+
}
17+
18+
public function doArrayByRef(): void
19+
{
20+
$b = 1;
21+
$a = [9223372036854775807 => 1, &$b];
22+
assertType('array{9223372036854775807: 1}', $a);
23+
}
24+
25+
public function doOversizedArray(): void
26+
{
27+
$a = [
28+
9223372036854775806 => 'a', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
29+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
30+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
31+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
32+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
33+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
34+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
35+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
36+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
37+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
38+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
39+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
40+
'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b', 'b',
41+
];
42+
assertType('non-empty-array<int, literal-string&lowercase-string&non-falsy-string>&oversized-array', $a);
43+
}
44+
45+
}

‎tests/PHPStan/Rules/Arrays/DuplicateKeysInLiteralArraysRuleTest.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,18 @@ public function testBug14041(): void
115115
$this->analyse([__DIR__ . '/data/bug-14041.php'], []);
116116
}
117117

118+
public function testBug15244(): void
119+
{
120+
$this->analyse([__DIR__ . '/data/bug-15244.php'], [
121+
[
122+
'Array has 2 duplicate keys with value 9223372036854775807 (9223372036854775807, 9223372036854775807).',
123+
38,
124+
],
125+
[
126+
'Array has 2 duplicate keys with value 0 (0, 0).',
127+
49,
128+
],
129+
]);
130+
}
131+
118132
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
namespace Bug15244;
4+
5+
class Foo
6+
{
7+
8+
/**
9+
* @return array<int, int>
10+
*/
11+
public function doFoo(): array
12+
{
13+
return [9223372036854775807 => 1, 2];
14+
}
15+
16+
/**
17+
* @return array<int, int>
18+
*/
19+
public function doBar(): array
20+
{
21+
return ['9223372036854775807' => 1, 2];
22+
}
23+
24+
/**
25+
* @return array<int, int>
26+
*/
27+
public function doBaz(): array
28+
{
29+
return [9223372036854775806 => 1, 2, 3];
30+
}
31+
32+
/**
33+
* @return array<int, int>
34+
*/
35+
public function doLorem(): array
36+
{
37+
// the implicit key of `2` is PHP_INT_MAX
38+
return [9223372036854775806 => 1, 2, 9223372036854775807 => 3];
39+
}
40+
41+
/**
42+
* @param array<int, string> $b
43+
*/
44+
public function doUnpack(array $b): void
45+
{
46+
$a = ['a', ...$b, 1 => 'x'];
47+
$c = ['a', ...[], 1 => 'x'];
48+
$d = ['a', ...['k' => 1], 1 => 'x'];
49+
$e = ['a', ...$b, 0 => 'x'];
50+
}
51+
52+
}

0 commit comments

Comments
 (0)