From 5901a55eca4c966156497f472cddf23411fc3dfb Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:52:59 +0000 Subject: [PATCH 1/6] Fix array_splice not resetting integer keys of the input array - Generalize specific integer key types (e.g. 10|20|30) to int in ArrayType::spliceArray() - Uses TypeTraverser to only affect integer parts of union key types, preserving string keys - New regression test in tests/PHPStan/Analyser/nsrt/bug-14037.php - The root cause was that ArrayType::spliceArray() preserved the original key type verbatim, but PHP's array_splice re-indexes integer keys to 0, 1, 2, ... --- src/Type/ArrayType.php | 14 +++++++++++++- tests/PHPStan/Analyser/nsrt/bug-14037.php | 13 +++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14037.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index f6550a5845e..040edecd6e9 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -521,8 +521,20 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ConstantArrayType([], []); } + $keyType = TypeTraverser::map($this->getIterableKeyType(), static function (Type $type, callable $traverse): Type { + if ($type instanceof UnionType) { + return $traverse($type); + } + + if ($type->isInteger()->yes()) { + return new IntegerType(); + } + + return $type; + }); + $arrayType = new self( - TypeCombinator::union($this->getIterableKeyType(), $replacementArrayType->getKeysArray()->getIterableKeyType()), + TypeCombinator::union($keyType, $replacementArrayType->getKeysArray()->getIterableKeyType()), TypeCombinator::union($this->getIterableValueType(), $replacementArrayType->getIterableValueType()), ); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14037.php b/tests/PHPStan/Analyser/nsrt/bug-14037.php new file mode 100644 index 00000000000..9b7db85670a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14037.php @@ -0,0 +1,13 @@ + $a + */ +function splice(array $a): void { + array_splice($a, 0, 0); + assertType("array<'a'|int, mixed>", $a); +} From d8246247b580477319306ec80d171e920d80d600 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 7 Mar 2026 12:34:32 +0100 Subject: [PATCH 2/6] Add test --- src/Type/ArrayType.php | 16 +++- tests/PHPStan/Analyser/nsrt/array_splice.php | 37 +++++---- tests/PHPStan/Analyser/nsrt/bug-14037.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-4743.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5017.php | 4 +- tests/PHPStan/Type/ArrayTypeTest.php | 82 ++++++++++++++++++++ 6 files changed, 122 insertions(+), 21 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 040edecd6e9..288caefdc63 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -521,13 +521,14 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen return new ConstantArrayType([], []); } - $keyType = TypeTraverser::map($this->getIterableKeyType(), static function (Type $type, callable $traverse): Type { + $existingArrayKeyType = $this->getIterableKeyType(); + $keyType = TypeTraverser::map($existingArrayKeyType, static function (Type $type, callable $traverse): Type { if ($type instanceof UnionType) { return $traverse($type); } if ($type->isInteger()->yes()) { - return new IntegerType(); + return IntegerRangeType::createAllGreaterThanOrEqualTo(0); } return $type; @@ -538,8 +539,17 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen TypeCombinator::union($this->getIterableValueType(), $replacementArrayType->getIterableValueType()), ); + $accessories = []; if ($replacementArrayTypeIsIterableAtLeastOnce->yes()) { - $arrayType = new IntersectionType([$arrayType, new NonEmptyArrayType()]); + $accessories[] = new NonEmptyArrayType(); + } + if ($existingArrayKeyType->isInteger()->yes()) { + $accessories[] = new AccessoryArrayListType(); + } + if (count($accessories) > 0) { + $accessories[] = $arrayType; + + return new IntersectionType($accessories); } return $arrayType; diff --git a/tests/PHPStan/Analyser/nsrt/array_splice.php b/tests/PHPStan/Analyser/nsrt/array_splice.php index 92385d0786f..9ecc6a0e620 100644 --- a/tests/PHPStan/Analyser/nsrt/array_splice.php +++ b/tests/PHPStan/Analyser/nsrt/array_splice.php @@ -21,52 +21,52 @@ function insertViaArraySplice(array $arr): void { $brr = $arr; $extract = array_splice($brr, 0, 0, 1); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, [1]); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, ''); - assertType('non-empty-array', $brr); + assertType('non-empty-list<\'\'|int>', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, ['']); - assertType('non-empty-array', $brr); + assertType('non-empty-list<\'\'|int>', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, null); - assertType('array', $brr); + assertType('list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, [null]); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, new Foo()); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, [new \stdClass()]); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, false); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; $extract = array_splice($brr, 0, 0, [false]); - assertType('non-empty-array', $brr); + assertType('non-empty-list', $brr); assertType('array{}', $extract); $brr = $arr; @@ -323,25 +323,25 @@ function offsets(array $arr): void { if (array_key_exists(1, $arr)) { $extract = array_splice($arr, 0, 1, 'hello'); - assertType('non-empty-array', $arr); + assertType('non-empty-array<(int<0, max>|string), mixed>', $arr); assertType('array', $extract); } if (array_key_exists(1, $arr)) { $extract = array_splice($arr, 0, 0, 'hello'); - assertType('non-empty-array&hasOffset(1)', $arr); + assertType('non-empty-array<(int<0, max>|string), mixed>&hasOffset(1)', $arr); assertType('array{}', $extract); } if (array_key_exists(1, $arr) && $arr[1] === 'foo') { $extract = array_splice($arr, 0, 1, 'hello'); - assertType('non-empty-array', $arr); + assertType('non-empty-array<(int<0, max>|string), mixed>', $arr); assertType('array', $extract); } if (array_key_exists(1, $arr) && $arr[1] === 'foo') { $extract = array_splice($arr, 0, 0, 'hello'); - assertType('non-empty-array&hasOffsetValue(1, \'foo\')', $arr); + assertType('non-empty-array<(int<0, max>|string), mixed>&hasOffsetValue(1, \'foo\')', $arr); assertType('array{}', $extract); } } @@ -384,3 +384,12 @@ function lists(array $arr): void assertType('list', $arr); assertType('list', $extract); } + +function mort(array $arr): void +{ + /** @var array $arr */ + $arr; + $extract = array_splice($arr, 0, 1, [17 => 'foo', 18 => 'bar']); + assertType('non-empty-array<0|1|string, string>', $arr); + assertType('array', $extract); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14037.php b/tests/PHPStan/Analyser/nsrt/bug-14037.php index 9b7db85670a..c0d1bc30236 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14037.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14037.php @@ -9,5 +9,5 @@ */ function splice(array $a): void { array_splice($a, 0, 0); - assertType("array<'a'|int, mixed>", $a); + assertType("array<'a'|int<0, max, mixed>", $a); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-4743.php b/tests/PHPStan/Analyser/nsrt/bug-4743.php index a5aeab22237..3546a12d8c3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-4743.php +++ b/tests/PHPStan/Analyser/nsrt/bug-4743.php @@ -28,7 +28,7 @@ public function splice(int $offset, int $length): void { $newNodes = array_splice($this->nodes, $offset, $length); - assertType('array', $this->nodes); + assertType('array<(int<0, max>|string), T of Bug4743\\Node (class Bug4743\\NodeList, argument)>', $this->nodes); assertType('array', $newNodes); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5017.php b/tests/PHPStan/Analyser/nsrt/bug-5017.php index c4e7cfebaa6..aa3abb30359 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5017.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5017.php @@ -27,7 +27,7 @@ public function doBar($items) while ($items) { assertType('non-empty-array', $items); $batch = array_splice($items, 0, 2); - assertType('array', $items); + assertType('array<(int<0, max>|string), int>', $items); assertType('array', $batch); } } @@ -49,7 +49,7 @@ public function doBar3(array $ints, array $strings) { $removed = array_splice($ints, 0, 2, $strings); assertType('array', $removed); - assertType('array', $ints); + assertType('array<(int<0, max>|string), int|string>', $ints); assertType('array', $strings); } diff --git a/tests/PHPStan/Type/ArrayTypeTest.php b/tests/PHPStan/Type/ArrayTypeTest.php index 59ed255a09e..9879a90509d 100644 --- a/tests/PHPStan/Type/ArrayTypeTest.php +++ b/tests/PHPStan/Type/ArrayTypeTest.php @@ -301,4 +301,86 @@ public function testHasOffsetValueType( ); } + public static function dataSpliceArray(): array + { + return [ + [ + new ArrayType(new UnionType([ + new ConstantIntegerType(10), + new ConstantIntegerType(20), + new ConstantIntegerType(30), + new ConstantStringType('a'), + ]), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + "array<'a'|int<0, max>, mixed>", + ], + [ + new ArrayType(new UnionType([ + new IntegerType(), + new ConstantStringType('a'), + ]), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + "array<'a'|int<0, max>, mixed>", + ], + [ + new ArrayType(new IntegerType(), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + 'list', + ], + [ + new ArrayType(new StringType(), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + 'array', + ], + [ + new ArrayType(new MixedType(), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType([], []), + 'array|string, mixed>', + ], + [ + new ArrayType(new StringType(), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ConstantArrayType( + [new ConstantStringType('key')], + [new ConstantStringType('value')], + ), + 'non-empty-array<0|string, mixed>', + ], + [ + new ArrayType(new StringType(), new MixedType()), + new ConstantIntegerType(0), + new ConstantIntegerType(0), + new ArrayType(new MixedType(), new MixedType()), + 'array|string, mixed>', + ], + ]; + } + + #[DataProvider('dataSpliceArray')] + public function testSpliceArray( + ArrayType $type, + Type $offsetType, + Type $lengthType, + Type $replacementType, + string $expectedType, + ): void + { + $actualResult = $type->spliceArray($offsetType, $lengthType, $replacementType); + $this->assertSame( + $expectedType, + $actualResult->describe(VerbosityLevel::precise()), + ); + } + } From 20a1e2578ae19c39a66f954ab0945035c4b55f98 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 7 Mar 2026 12:39:54 +0100 Subject: [PATCH 3/6] Fix phpstan build --- src/Type/TypeCombinator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 522c587fbdb..019aa62eeb3 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1499,7 +1499,7 @@ public static function intersect(Type ...$types): Type return $types[0]; } - return new IntersectionType(array_values($types)); + return new IntersectionType($types); } public static function removeFalsey(Type $type): Type From 11d1bbb6c6b90fa1f7c0812e385fa5d5bd9a29e8 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 7 Mar 2026 12:44:58 +0100 Subject: [PATCH 4/6] Fix --- tests/PHPStan/Analyser/nsrt/bug-14037.php | 2 +- tests/PHPStan/Type/ArrayTypeTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14037.php b/tests/PHPStan/Analyser/nsrt/bug-14037.php index c0d1bc30236..dec3b683c61 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14037.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14037.php @@ -9,5 +9,5 @@ */ function splice(array $a): void { array_splice($a, 0, 0); - assertType("array<'a'|int<0, max, mixed>", $a); + assertType("array<'a'|int<0, max>, mixed>", $a); } diff --git a/tests/PHPStan/Type/ArrayTypeTest.php b/tests/PHPStan/Type/ArrayTypeTest.php index 9879a90509d..62c5e06bd85 100644 --- a/tests/PHPStan/Type/ArrayTypeTest.php +++ b/tests/PHPStan/Type/ArrayTypeTest.php @@ -362,7 +362,7 @@ public static function dataSpliceArray(): array new ConstantIntegerType(0), new ConstantIntegerType(0), new ArrayType(new MixedType(), new MixedType()), - 'array|string, mixed>', + 'array<(int<0, max>|string), mixed>', ], ]; } From 0032dc3098de46c4eb466539999b1437c7775da5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 7 Mar 2026 12:51:34 +0100 Subject: [PATCH 5/6] Fix --- tests/PHPStan/Type/ArrayTypeTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Type/ArrayTypeTest.php b/tests/PHPStan/Type/ArrayTypeTest.php index 62c5e06bd85..1e2b616b9ac 100644 --- a/tests/PHPStan/Type/ArrayTypeTest.php +++ b/tests/PHPStan/Type/ArrayTypeTest.php @@ -345,7 +345,7 @@ public static function dataSpliceArray(): array new ConstantIntegerType(0), new ConstantIntegerType(0), new ConstantArrayType([], []), - 'array|string, mixed>', + 'array<(int<0, max>|string), mixed>', ], [ new ArrayType(new StringType(), new MixedType()), @@ -362,7 +362,7 @@ public static function dataSpliceArray(): array new ConstantIntegerType(0), new ConstantIntegerType(0), new ArrayType(new MixedType(), new MixedType()), - 'array<(int<0, max>|string), mixed>', + 'array|string, mixed>', ], ]; } From 6a27820cec8ad289ceae8d9e90a27531feeb6c46 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 8 Mar 2026 11:08:25 +0100 Subject: [PATCH 6/6] Review --- tests/PHPStan/Analyser/nsrt/array_splice.php | 5 ++--- tests/PHPStan/Analyser/nsrt/bug-14037.php | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array_splice.php b/tests/PHPStan/Analyser/nsrt/array_splice.php index 9ecc6a0e620..9e524d32125 100644 --- a/tests/PHPStan/Analyser/nsrt/array_splice.php +++ b/tests/PHPStan/Analyser/nsrt/array_splice.php @@ -385,10 +385,9 @@ function lists(array $arr): void assertType('list', $extract); } -function mort(array $arr): void +/** @param array $arr */ +function more(array $arr): void { - /** @var array $arr */ - $arr; $extract = array_splice($arr, 0, 1, [17 => 'foo', 18 => 'bar']); assertType('non-empty-array<0|1|string, string>', $arr); assertType('array', $extract); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14037.php b/tests/PHPStan/Analyser/nsrt/bug-14037.php index dec3b683c61..c64d1cc7e06 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14037.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14037.php @@ -11,3 +11,11 @@ function splice(array $a): void { array_splice($a, 0, 0); assertType("array<'a'|int<0, max>, mixed>", $a); } + +/** + * @param array|'a', mixed> $a + */ +function splice2(array $a): void { + array_splice($a, 0, 0); + assertType("array<'a'|int<0, max>, mixed>", $a); +}