Skip to content

Commit 2380433

Browse files
phpstan-botclaude
authored andcommitted
Track implicit indices past unpacked constant arrays and do not append past PHP_INT_MAX
* `AssignHandler::processArrayByRefItems()` no longer gives up on implicit indices at every unpacked item. `advanceImplicitIndexByUnpackedArray()` advances the index by the number of renumbered integer keys when the unpacked array is a single sealed constant array without optional keys (string keys are preserved by unpacking and don't consume an index), and makes it unpredictable otherwise. `&$x` in `$a = [...[10, 20], &$x]` is now linked to `$a[2]`, so `$x = 7` gives `array{10, 20, 7}` instead of `array{7|10, 7|20, int, ...<int<min, -1>|int<3, max>, 7>}`. * Unpacked items are no longer walked as if they were a keyless item holding a nested array literal. `[...[&$x], &$y]` used to link `$x` to `$a[int][0]`, and writing to `$x` or `$y` turned the array's values into `*ERROR*`. * `ArrayType::setOffsetValueType()` no longer unions a float key into the key type of `array<9223372036854775807, string>` when appending, because there is no offset past `PHP_INT_MAX`. Closes phpstan/phpstan#15247 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2faa295 commit 2380433

7 files changed

Lines changed: 209 additions & 5 deletions

File tree

‎src/Analyser/ExprHandler/AssignHandler.php‎

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1938,9 +1938,13 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam
19381938
$implicitIndex = 0;
19391939
foreach ($arrayExpr->items as $arrayItem) {
19401940
if ($arrayItem->unpack) {
1941-
// An unpacked array contributes an unknown number of renumbered
1942-
// integer keys, so subsequent implicit indices are unpredictable
1943-
$implicitIndex = null;
1941+
// An unpacked item adds an unknown number of elements, so the
1942+
// following implicit indices are only predictable when the
1943+
// unpacked array has a known shape.
1944+
if ($implicitIndex !== null) {
1945+
$implicitIndex = $this->advanceImplicitIndexByUnpackedArray($implicitIndex, $scope->getType($arrayItem->value));
1946+
}
1947+
continue;
19441948
}
19451949

19461950
if ($arrayItem->key !== null) {
@@ -2012,6 +2016,39 @@ private function advanceImplicitIndex(int $index): ?int
20122016
return $index === PHP_INT_MAX ? null : $index + 1;
20132017
}
20142018

2019+
private function advanceImplicitIndexByUnpackedArray(int $implicitIndex, Type $unpackedType): ?int
2020+
{
2021+
$constantArrays = $unpackedType->getConstantArrays();
2022+
if (count($constantArrays) !== 1) {
2023+
return null;
2024+
}
2025+
2026+
$constantArray = $constantArrays[0];
2027+
if (count($constantArray->getOptionalKeys()) > 0 || !$constantArray->isUnsealed()->no()) {
2028+
return null;
2029+
}
2030+
2031+
foreach ($constantArray->getKeyTypes() as $keyType) {
2032+
// String keys are preserved by unpacking, only integer keys are renumbered
2033+
if ($keyType->isString()->yes()) {
2034+
continue;
2035+
}
2036+
2037+
if (!$keyType->isInteger()->yes()) {
2038+
return null;
2039+
}
2040+
2041+
$nextIndex = $this->advanceImplicitIndex($implicitIndex);
2042+
if ($nextIndex === null) {
2043+
return null;
2044+
}
2045+
2046+
$implicitIndex = $nextIndex;
2047+
}
2048+
2049+
return $implicitIndex;
2050+
}
2051+
20152052
/**
20162053
* @param non-empty-list<ArrayDimFetch> $dimFetchStack
20172054
* @param non-empty-list<array{Type|null, ArrayDimFetch}> $offsetTypes

‎src/Type/ArrayType.php‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
use function strtoupper;
4747
use const CASE_LOWER;
4848
use const CASE_UPPER;
49+
use const PHP_INT_MAX;
4950

5051
/** @api */
5152
#[InstanceofDeprecated(insteadUse: 'Type::isArray() or Type::getArrays()')]
@@ -365,11 +366,17 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
365366
/** @var list<ConstantIntegerType> $constantScalars */
366367
$constantScalars = $this->keyType->getConstantScalarTypes();
367368
if (count($constantScalars) > 0) {
369+
$offsetTypes = $constantScalars;
368370
foreach ($constantScalars as $constantScalar) {
369-
$constantScalars[] = ConstantTypeHelper::getTypeFromValue($constantScalar->getValue() + 1);
371+
// an offset past PHP_INT_MAX cannot be assigned, so it's not a possible key
372+
if ($constantScalar->getValue() === PHP_INT_MAX) {
373+
continue;
374+
}
375+
376+
$offsetTypes[] = new ConstantIntegerType($constantScalar->getValue() + 1);
370377
}
371378

372-
$offsetType = TypeCombinator::union(...$constantScalars);
379+
$offsetType = TypeCombinator::union(...$offsetTypes);
373380
} else {
374381
$offsetType = $this->keyType;
375382
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15248StringKeyUnpacking;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @param array<string, int> $rest
11+
* @return array<int|string, int>
12+
*/
13+
function foo(string $key, array $rest): array
14+
{
15+
$a = [9223372036854775807 => 1, $key => 2, ...$rest];
16+
17+
assertType('non-empty-array<9223372036854775807|string, int>', $a);
18+
19+
return $a;
20+
}
21+
22+
/**
23+
* @param array<string, int> $rest
24+
*/
25+
function unpackedItemAfterMaxIntKey(array $rest): void
26+
{
27+
$a = [9223372036854775807 => 1, ...$rest];
28+
assertType('non-empty-array<9223372036854775807|string, int>', $a);
29+
}
30+
31+
/**
32+
* @param array<string, int> $rest
33+
*/
34+
function byRefItemAfterUnpackedStringKeyedArray(array $rest, int $x): void
35+
{
36+
$a = [...$rest, &$x];
37+
assertType('non-empty-array<0|string, int>', $a);
38+
}
39+
40+
function byRefItemAfterUnpackedStringKeyedConstantArray(int $x): void
41+
{
42+
$constant = ['a' => 1, 'b' => 2];
43+
$a = [...$constant, &$x];
44+
$x = 9;
45+
assertType('array{a: 1, b: 2, 0: 9}', $a);
46+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug15248;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
function keylessItemAfterMaxIntKey(): void
10+
{
11+
$a = [9223372036854775807 => 1, 2];
12+
assertType('array{9223372036854775807: 1}', $a);
13+
14+
$b = [9223372036854775806 => 1, 2];
15+
assertType('array{9223372036854775806: 1, 9223372036854775807: 2}', $b);
16+
}
17+
18+
function byRefItemAfterMaxIntKey(int $x): void
19+
{
20+
$a = [9223372036854775807 => 1, &$x];
21+
assertType('array{9223372036854775807: 1}', $a);
22+
23+
$b = [9223372036854775806 => 1, &$x];
24+
assertType('array{9223372036854775806: 1, 9223372036854775807: int}', $b);
25+
$x = 5;
26+
assertType('array{9223372036854775806: 1, 9223372036854775807: 5}', $b);
27+
}
28+
29+
/**
30+
* @param list<int> $list
31+
*/
32+
function byRefItemAfterUnpackedList(array $list, int $x): void
33+
{
34+
$a = [...$list, &$x];
35+
$x = 5;
36+
assertType('non-empty-list<int>', $a);
37+
}
38+
39+
function byRefItemAfterUnpackedConstantArray(int $x): void
40+
{
41+
$constant = [10, 20];
42+
$a = [...$constant, &$x];
43+
$x = 7;
44+
assertType('array{10, 20, 7}', $a);
45+
}
46+
47+
function byRefItemsInsideAndAfterUnpackedArrayLiteral(int $x, int $y): void
48+
{
49+
$a = [...[&$x], &$y];
50+
$x = 10;
51+
assertType('array{int, int}', $a);
52+
$y = 20;
53+
assertType('array{int, 20}', $a);
54+
}
55+
56+
/**
57+
* @param array<9223372036854775806|9223372036854775807, string> $maxKeys
58+
*/
59+
function appendToArrayWithMaxIntKeyType(array $maxKeys): void
60+
{
61+
$maxKeys[] = 'x';
62+
assertType('non-empty-array<9223372036854775806|9223372036854775807, string>', $maxKeys);
63+
}
64+
65+
function arrayFillPastMaxInt(): void
66+
{
67+
assertType("array{9223372036854775807: 'a'}", array_fill(9223372036854775807, 1, 'a'));
68+
assertType("array{9223372036854775806: 'a', 9223372036854775807: 'a'}", array_fill(9223372036854775806, 2, 'a'));
69+
assertType("non-empty-array<int, 'a'>", array_fill(9223372036854775807, 2, 'a'));
70+
assertType("non-empty-array<int, 'a'>", array_fill(9223372036854775806, 3, 'a'));
71+
}

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,14 @@ public function testBug15244(): void
129129
]);
130130
}
131131

132+
public function testBug15248(): void
133+
{
134+
$this->analyse([__DIR__ . '/data/bug-15248.php'], [
135+
[
136+
"Array has 2 duplicate keys with value 'a' ('a', 'a').",
137+
20,
138+
],
139+
]);
140+
}
141+
132142
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug15248DuplicateKeys;
4+
5+
/**
6+
* @param array<string, int> $rest
7+
* @param array<int, int> $intKeyed
8+
*/
9+
function foo(string $key, array $rest, array $intKeyed): void
10+
{
11+
$a = [9223372036854775807 => 1, $key => 2, ...$rest];
12+
$b = [9223372036854775807 => 1, 2];
13+
$c = [9223372036854775807 => 1, 2, 3];
14+
$d = [9223372036854775806 => 1, 2, 3];
15+
16+
$e = [...$intKeyed, 0 => 'x'];
17+
$f = [...$intKeyed, 'y'];
18+
$g = [0 => 'x', ...$intKeyed, 1 => 'y'];
19+
20+
$h = ['a' => 1, ...$rest, 'a' => 2];
21+
}

‎tests/PHPStan/Type/Constant/OversizedArrayBuilderTest.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ public static function dataBuild(): iterable
6161
'[1, \'FOO\' => 2, 3]',
6262
'non-empty-array<int|(literal-string&non-falsy-string&uppercase-string), int>&oversized-array',
6363
];
64+
yield [
65+
'[9223372036854775807 => 1, 2]',
66+
'non-empty-array<int, int>&oversized-array',
67+
];
68+
yield [
69+
'[9223372036854775806 => 1, 2, 3]',
70+
'non-empty-array<int, int>&oversized-array',
71+
];
72+
yield [
73+
'[9223372036854775807 => 1, ...[2, 3]]',
74+
'non-empty-array<int, int>&oversized-array',
75+
];
6476
}
6577

6678
#[DataProvider('dataBuild')]

0 commit comments

Comments
 (0)