From e8ee20460fc891b53f1dbec589fa36ed390b2175 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Mon, 6 Apr 2026 17:40:15 +0000 Subject: [PATCH 01/16] Fix array auto-index for negative keys on PHP 8.3+ - PHP 8.3 fixed an incomplete RFC implementation where $a[] after negative keys should use max_key+1, not 0 - Updated ConstantArrayTypeBuilder to account for this when computing nextAutoIndexes - Added regression test in tests/PHPStan/Analyser/nsrt/bug-10862.php Closes https://github.com/phpstan/phpstan/issues/10862 --- .../Constant/ConstantArrayTypeBuilder.php | 6 ++- tests/PHPStan/Analyser/nsrt/bug-10862.php | 45 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10862.php diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index aab3b8ad449..1df98a88cbb 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Constant; +use PHPStan\Reflection\PhpVersionStaticAccessor; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -209,7 +210,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->isList = TrinaryLogic::createNo(); } - if ($offsetValue >= $max) { + if ( + $offsetValue >= $max + || ($offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->getVersionId() >= 80300) + ) { /** @var int|float $newAutoIndex */ $newAutoIndex = $offsetValue + 1; if (is_float($newAutoIndex)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862.php b/tests/PHPStan/Analyser/nsrt/bug-10862.php new file mode 100644 index 00000000000..a9246a4a2c6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862.php @@ -0,0 +1,45 @@ + Date: Mon, 6 Apr 2026 18:01:23 +0000 Subject: [PATCH 02/16] Add PhpVersion::updatesAutoIncrementKeyForNegativeValues() method Replace inline version check with a dedicated PhpVersion method for the PHP 8.3+ negative array auto-index behavior. Co-Authored-By: Claude Opus 4.6 --- src/Php/PhpVersion.php | 5 +++++ src/Type/Constant/ConstantArrayTypeBuilder.php | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 9b198d567f5..6f96ce5555c 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -511,4 +511,9 @@ public function throwsOnStringCast(): bool return $this->versionId >= 70400; } + public function updatesAutoIncrementKeyForNegativeValues(): bool + { + return $this->versionId >= 80300; + } + } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 1df98a88cbb..a394b719464 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -212,7 +212,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt if ( $offsetValue >= $max - || ($offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->getVersionId() >= 80300) + || ($offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->updatesAutoIncrementKeyForNegativeValues()) ) { /** @var int|float $newAutoIndex */ $newAutoIndex = $offsetValue + 1; From 47110f1fa727e48320f484edc5f73ad9cf8f9cb4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 6 Apr 2026 18:01:29 +0000 Subject: [PATCH 03/16] Split bug-10862 test into PHP 8.2 and PHP 8.3 variants Separate version-specific assertions into two test files with lint version constraints instead of a single file. Co-Authored-By: Claude Opus 4.6 --- .../PHPStan/Analyser/nsrt/bug-10862-php82.php | 45 +++++++++++++++++++ .../{bug-10862.php => bug-10862-php83.php} | 4 +- 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10862-php82.php rename tests/PHPStan/Analyser/nsrt/{bug-10862.php => bug-10862-php83.php} (94%) diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php new file mode 100644 index 00000000000..dc7565003f6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php @@ -0,0 +1,45 @@ += 8.3 declare(strict_types = 1); -namespace Bug10862; +namespace Bug10862Php83; use function PHPStan\Testing\assertType; From 369c2a18a5013c786a8c69a476804c4af2974818 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 06:08:32 +0000 Subject: [PATCH 04/16] Extract shouldUpdateAutoIndex() private method for readability Co-Authored-By: Claude Opus 4.6 --- src/Type/Constant/ConstantArrayTypeBuilder.php | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index a394b719464..469a9983778 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -210,10 +210,7 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt $this->isList = TrinaryLogic::createNo(); } - if ( - $offsetValue >= $max - || ($offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->updatesAutoIncrementKeyForNegativeValues()) - ) { + if ($this->shouldUpdateAutoIndex($offsetValue, $max)) { /** @var int|float $newAutoIndex */ $newAutoIndex = $offsetValue + 1; if (is_float($newAutoIndex)) { @@ -423,4 +420,13 @@ public function isList(): bool return $this->isList->yes(); } + private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool + { + if ($offsetValue >= $max) { + return true; + } + + return $offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->updatesAutoIncrementKeyForNegativeValues(); + } + } From e168a1141e145710db8e464d9f3a02b8c960ea1a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 06:19:15 +0000 Subject: [PATCH 05/16] Extract auto-index condition into private method and handle PHP 8.0+ array literal behavior PHP 8.0 changed array literals to update the auto-increment key for negative values, while imperative assignment only got this fix in PHP 8.3. Extract the condition into shouldUpdateAutoIndexForOffset() for readability and add isLiteralArray flag to ConstantArrayTypeBuilder to distinguish the two cases. Co-Authored-By: Claude Opus 4.6 --- src/Php/PhpVersion.php | 5 +++++ .../InitializerExprTypeResolver.php | 1 + .../Constant/ConstantArrayTypeBuilder.php | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6f96ce5555c..709860a0b06 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -516,4 +516,9 @@ public function updatesAutoIncrementKeyForNegativeValues(): bool return $this->versionId >= 80300; } + public function updatesAutoIncrementKeyForNegativeValuesInArrayLiteral(): bool + { + return $this->versionId >= 80000; + } + } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index b4578930acf..4761e6bf1ea 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -639,6 +639,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type } $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $arrayBuilder->setLiteralArray(); $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $arrayItem) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 469a9983778..3d88745f43f 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,6 +42,8 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; + private bool $isLiteralArray = false; + /** * @param list $keyTypes * @param array $valueTypes @@ -63,6 +65,11 @@ public static function createEmpty(): self return new self([], [], [0], [], TrinaryLogic::createYes()); } + public function setLiteralArray(): void + { + $this->isLiteralArray = true; + } + public static function createFromConstantArray(ConstantArrayType $startArrayType): self { $builder = new self( @@ -426,7 +433,17 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool return true; } - return $offsetValue < 0 && $max === 0 && PhpVersionStaticAccessor::getInstance()->updatesAutoIncrementKeyForNegativeValues(); + if ($offsetValue >= 0 || $max !== 0) { + return false; + } + + $phpVersion = PhpVersionStaticAccessor::getInstance(); + + if ($phpVersion->updatesAutoIncrementKeyForNegativeValues()) { + return true; + } + + return $this->isLiteralArray && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInArrayLiteral(); } } From 9ac8db333f5d047c0059dcacc90ea4b150173182 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 06:19:22 +0000 Subject: [PATCH 06/16] Add pre-PHP 8 and PHP 8.0+ array literal tests for bug-10862 Split tests into four files covering all three PHP version behaviors: - php74: pre-PHP 8.0, negative keys never affect auto-index - php80: PHP 8.0+, array literals with negative keys update auto-index - php82: PHP <= 8.2, imperative assignment does not update for negative keys - php83: PHP 8.3+, negative keys always update auto-index Co-Authored-By: Claude Opus 4.6 --- .../PHPStan/Analyser/nsrt/bug-10862-php74.php | 26 ++++++++++++++++ .../PHPStan/Analyser/nsrt/bug-10862-php80.php | 23 ++++++++++++++ .../PHPStan/Analyser/nsrt/bug-10862-php82.php | 14 ++++----- .../PHPStan/Analyser/nsrt/bug-10862-php83.php | 30 ++++++++++++++----- 4 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10862-php74.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-10862-php80.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php new file mode 100644 index 00000000000..192e00b99b1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php74.php @@ -0,0 +1,26 @@ + 1]; + $a[] = 2; + + assertType('array{-4: 1, 0: 2}', $a); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php new file mode 100644 index 00000000000..b3f6b3143f2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php @@ -0,0 +1,23 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug10862Php80; + +use function PHPStan\Testing\assertType; + +// PHP 8.0+: array literal with negative keys updates auto-index + +function () { + $a = [-4 => 1]; + $a[] = 2; + + assertType('array{-4: 1, -3: 2}', $a); +}; + +function () { + $a = [-10 => 'a', -5 => 'b']; + $a[] = 'c'; + + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); +}; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php index dc7565003f6..b2f41e41502 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php82.php @@ -6,13 +6,14 @@ use function PHPStan\Testing\assertType; +// PHP <= 8.2: imperative assignment with negative keys does not affect auto-index + function () { $a = []; $a[-4] = 1; $a[] = 2; - assertType('array{-4: 1, 0: 2}', $a); // PHP <=8.2: next key after -4 is 0 - assertType('array{-4, 0}', array_keys($a)); + assertType('array{-4: 1, 0: 2}', $a); }; function () { @@ -20,8 +21,7 @@ function () { $a[-1] = 'x'; $a[] = 'y'; - assertType("array{-1: 'x', 0: 'y'}", $a); // PHP <=8.2: next key after -1 is 0 - assertType('array{-1, 0}', array_keys($a)); + assertType("array{-1: 'x', 0: 'y'}", $a); }; function () { @@ -30,8 +30,7 @@ function () { $a[-5] = 'b'; $a[] = 'c'; - assertType("array{-10: 'a', -5: 'b', 0: 'c'}", $a); // PHP <=8.2: next key is 0 - assertType('array{-10, -5, 0}', array_keys($a)); + assertType("array{-10: 'a', -5: 'b', 0: 'c'}", $a); }; function () { @@ -40,6 +39,5 @@ function () { $a[5] = 'b'; $a[] = 'c'; - assertType("array{-3: 'a', 5: 'b', 6: 'c'}", $a); // positive key dominates - assertType('array{-3, 5, 6}', array_keys($a)); + assertType("array{-3: 'a', 5: 'b', 6: 'c'}", $a); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php83.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php83.php index 1ee0a621b54..3f15a8ea960 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10862-php83.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php83.php @@ -6,13 +6,15 @@ use function PHPStan\Testing\assertType; +// PHP 8.3+: negative keys always affect auto-index (both imperative and literal) + +// Imperative assignment function () { $a = []; $a[-4] = 1; $a[] = 2; - assertType('array{-4: 1, -3: 2}', $a); // PHP 8.3+: next key after -4 is -3 - assertType('array{-4, -3}', array_keys($a)); + assertType('array{-4: 1, -3: 2}', $a); }; function () { @@ -20,8 +22,7 @@ function () { $a[-1] = 'x'; $a[] = 'y'; - assertType("array{-1: 'x', 0: 'y'}", $a); // PHP 8.3+: next key after -1 is 0 - assertType('array{-1, 0}', array_keys($a)); + assertType("array{-1: 'x', 0: 'y'}", $a); }; function () { @@ -30,8 +31,7 @@ function () { $a[-5] = 'b'; $a[] = 'c'; - assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); // PHP 8.3+: next key after max(-10,-5)=-5 is -4 - assertType('array{-10, -5, -4}', array_keys($a)); + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); }; function () { @@ -40,6 +40,20 @@ function () { $a[5] = 'b'; $a[] = 'c'; - assertType("array{-3: 'a', 5: 'b', 6: 'c'}", $a); // positive key dominates - assertType('array{-3, 5, 6}', array_keys($a)); + assertType("array{-3: 'a', 5: 'b', 6: 'c'}", $a); +}; + +// Array literal +function () { + $a = [-4 => 1]; + $a[] = 2; + + assertType('array{-4: 1, -3: 2}', $a); +}; + +function () { + $a = [-10 => 'a', -5 => 'b']; + $a[] = 'c'; + + assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); }; From 8e30d388447d405debb6d63ecbed9df0edb78ff9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 09:46:37 +0000 Subject: [PATCH 07/16] Rename isLiteralArray to isArrayExpression for clarity The flag marks builders processing Expr\Array_ nodes (PHP array expression syntax), which have different auto-index semantics for negative keys in PHP 8.0-8.2 vs imperative assignment. The term "array expression" better matches the PHP parser terminology. Co-Authored-By: Claude Opus 4.6 --- src/Php/PhpVersion.php | 2 +- src/Reflection/InitializerExprTypeResolver.php | 2 +- src/Type/Constant/ConstantArrayTypeBuilder.php | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 709860a0b06..7681068903d 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -516,7 +516,7 @@ public function updatesAutoIncrementKeyForNegativeValues(): bool return $this->versionId >= 80300; } - public function updatesAutoIncrementKeyForNegativeValuesInArrayLiteral(): bool + public function updatesAutoIncrementKeyForNegativeValuesInArrayExpression(): bool { return $this->versionId >= 80000; } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 4761e6bf1ea..feb05e6f324 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -639,7 +639,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type } $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - $arrayBuilder->setLiteralArray(); + $arrayBuilder->setArrayExpression(); $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $arrayItem) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 3d88745f43f..e239dc541a8 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,7 +42,7 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; - private bool $isLiteralArray = false; + private bool $isArrayExpression = false; /** * @param list $keyTypes @@ -65,9 +65,9 @@ public static function createEmpty(): self return new self([], [], [0], [], TrinaryLogic::createYes()); } - public function setLiteralArray(): void + public function setArrayExpression(): void { - $this->isLiteralArray = true; + $this->isArrayExpression = true; } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -443,7 +443,7 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool return true; } - return $this->isLiteralArray && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInArrayLiteral(); + return $this->isArrayExpression && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInArrayExpression(); } } From 557536d498dcf8fad77d03486c29393dce333861 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 7 Apr 2026 10:01:00 +0000 Subject: [PATCH 08/16] Rename isArrayExpression to initializedNonEmpty to express non-empty array initialization The distinction is whether the array was initialized with values (e.g. [-4 => 1]) vs initialized empty ([]). This matches the actual PHP behavior: - Pre PHP 8.0: negative keys never update auto-increment - PHP 8.0-8.2: updates only when array initialized non-empty - PHP 8.3+: always updates Co-Authored-By: Claude Opus 4.6 --- src/Php/PhpVersion.php | 2 +- src/Reflection/InitializerExprTypeResolver.php | 4 +++- src/Type/Constant/ConstantArrayTypeBuilder.php | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 7681068903d..fa083e630ea 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -516,7 +516,7 @@ public function updatesAutoIncrementKeyForNegativeValues(): bool return $this->versionId >= 80300; } - public function updatesAutoIncrementKeyForNegativeValuesInArrayExpression(): bool + public function updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(): bool { return $this->versionId >= 80000; } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index feb05e6f324..3e275d15305 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -639,7 +639,9 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type } $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - $arrayBuilder->setArrayExpression(); + if (count($expr->items) > 0) { + $arrayBuilder->setInitializedNonEmpty(); + } $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $arrayItem) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index e239dc541a8..5789e42b641 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,7 +42,7 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; - private bool $isArrayExpression = false; + private bool $initializedNonEmpty = false; /** * @param list $keyTypes @@ -65,9 +65,9 @@ public static function createEmpty(): self return new self([], [], [0], [], TrinaryLogic::createYes()); } - public function setArrayExpression(): void + public function setInitializedNonEmpty(): void { - $this->isArrayExpression = true; + $this->initializedNonEmpty = true; } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -443,7 +443,7 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool return true; } - return $this->isArrayExpression && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInArrayExpression(); + return $this->initializedNonEmpty && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(); } } From 694ddc91f6889fffbc260d37960ffdac849fa7ef Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 14:29:54 +0000 Subject: [PATCH 09/16] Replace setInitializedNonEmpty() with optional param in createEmpty() Co-Authored-By: Claude Opus 4.6 --- src/Reflection/InitializerExprTypeResolver.php | 5 +---- src/Type/Constant/ConstantArrayTypeBuilder.php | 10 ++++------ 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 3e275d15305..4d51e87f8fe 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -638,10 +638,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type return $this->oversizedArrayBuilder->build($expr, $getTypeCallback); } - $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - if (count($expr->items) > 0) { - $arrayBuilder->setInitializedNonEmpty(); - } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(count($expr->items) > 0); $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $arrayItem) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 5789e42b641..913ac468805 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -60,14 +60,12 @@ private function __construct( { } - public static function createEmpty(): self + public static function createEmpty(bool $initializedNonEmpty = false): self { - return new self([], [], [0], [], TrinaryLogic::createYes()); - } + $self = new self([], [], [0], [], TrinaryLogic::createYes()); + $self->initializedNonEmpty = $initializedNonEmpty; - public function setInitializedNonEmpty(): void - { - $this->initializedNonEmpty = true; + return $self; } public static function createFromConstantArray(ConstantArrayType $startArrayType): self From 40956dfc040d8a44b1ec96df16561a75f407e219 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 14:58:13 +0000 Subject: [PATCH 10/16] Always set initializedNonEmpty=true in createEmpty() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createEmpty() is only used to build arrays from scratch (array expressions, type operations, function return types). The imperative case ($a = []; $a[-4] = 1;) goes through createFromConstantArray(), which correctly defaults initializedNonEmpty to false. So createEmpty() can always assume the array is initialized non-empty — the parameter is unnecessary. Co-Authored-By: Claude Opus 4.6 --- src/Reflection/InitializerExprTypeResolver.php | 2 +- src/Type/Constant/ConstantArrayTypeBuilder.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 4d51e87f8fe..b4578930acf 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -638,7 +638,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type return $this->oversizedArrayBuilder->build($expr, $getTypeCallback); } - $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(count($expr->items) > 0); + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $arrayItem) { diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 913ac468805..dc964fba49a 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -60,10 +60,10 @@ private function __construct( { } - public static function createEmpty(bool $initializedNonEmpty = false): self + public static function createEmpty(): self { $self = new self([], [], [0], [], TrinaryLogic::createYes()); - $self->initializedNonEmpty = $initializedNonEmpty; + $self->initializedNonEmpty = true; return $self; } From 2ad030624e1c83cb5b5e7bcf1fb27c5aea3f0ce5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 15:50:41 +0000 Subject: [PATCH 11/16] Replace initializedNonEmpty with initializedEmpty set in createFromConstantArray Instead of setting initializedNonEmpty=true in createEmpty(), use the inverse property initializedEmpty and set it in createFromConstantArray() when the source array has no keys. This is cleaner because createEmpty() is always used to build arrays that will have items added, while the only truly "initialized empty" case is createFromConstantArray with an empty ConstantArrayType (representing $a = [] imperative assignment). Co-Authored-By: Claude Opus 4.6 --- src/Type/Constant/ConstantArrayTypeBuilder.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index dc964fba49a..e531fcdec3b 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,7 +42,7 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; - private bool $initializedNonEmpty = false; + private bool $initializedEmpty = false; /** * @param list $keyTypes @@ -62,10 +62,7 @@ private function __construct( public static function createEmpty(): self { - $self = new self([], [], [0], [], TrinaryLogic::createYes()); - $self->initializedNonEmpty = true; - - return $self; + return new self([], [], [0], [], TrinaryLogic::createYes()); } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -78,6 +75,10 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->isList(), ); + if (count($startArrayType->getKeyTypes()) === 0) { + $builder->initializedEmpty = true; + } + if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { $builder->degradeToGeneralArray(true); } @@ -441,7 +442,7 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool return true; } - return $this->initializedNonEmpty && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(); + return !$this->initializedEmpty && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(); } } From 94e96570b7c70a1c232852a916349fda9bfb14b1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 16:33:20 +0000 Subject: [PATCH 12/16] Fix initializedEmpty not propagating through createFromConstantArray round-trips When processing `$a = []; $a[-10] = 'a'; $a[-5] = 'b';`, the second assignment creates a new builder via createFromConstantArray with a non-empty array, losing the initializedEmpty flag. This caused shouldUpdateAutoIndex to incorrectly return true on PHP 8.0-8.2. Fix: in createFromConstantArray, also infer initializedEmpty when the source array has negative int keys but max(nextAutoIndexes) === 0, since a non-empty initializer on PHP 8.0+ would have already bumped the auto-index. Co-Authored-By: Claude Opus 4.6 --- src/Type/Constant/ConstantArrayTypeBuilder.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index e531fcdec3b..12ba62ee36d 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -77,6 +77,13 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType if (count($startArrayType->getKeyTypes()) === 0) { $builder->initializedEmpty = true; + } elseif (max($startArrayType->getNextAutoIndexes()) === 0) { + foreach ($startArrayType->getKeyTypes() as $keyType) { + if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { + $builder->initializedEmpty = true; + break; + } + } } if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { From 15a85d8c6632fa07da98abdcc2c19256a096e7f4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 18:09:20 +0000 Subject: [PATCH 13/16] Add test for non-empty string-key array to guard initializedEmpty condition This test would fail on PHP 8.0-8.2 if initializedEmpty was set unconditionally in createFromConstantArray. With only string keys, max(nextAutoIndexes) is 0 but there are no negative int keys, so initializedEmpty should stay false, allowing negative keys to update the auto-index. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-10862-php80.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php index b3f6b3143f2..6fc33a396d3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php +++ b/tests/PHPStan/Analyser/nsrt/bug-10862-php80.php @@ -21,3 +21,12 @@ function () { assertType("array{-10: 'a', -5: 'b', -4: 'c'}", $a); }; + +// Non-empty string-key array: negative key should update auto-index +function () { + $a = ['foo' => 'bar']; + $a[-5] = 'x'; + $a[] = 'y'; + + assertType("array{foo: 'bar', -5: 'x', -4: 'y'}", $a); +}; From ee2c7a371ee6dd696333eafc83b531f56aadc640 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 8 Apr 2026 18:34:25 +0000 Subject: [PATCH 14/16] Gate initializedEmpty computation on PHP 8.0-8.2 version range The initializedEmpty flag is only consulted in shouldUpdateAutoIndex on PHP 8.0-8.2. Computing it on other versions is both semantically incorrect (on PHP < 8.0, the nextAutoIndexes heuristic can't distinguish empty-init from literal-init) and wasteful (the flag is never used). Co-Authored-By: Claude Opus 4.6 --- .../Constant/ConstantArrayTypeBuilder.php | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 12ba62ee36d..00de59767d6 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -75,13 +75,19 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->isList(), ); - if (count($startArrayType->getKeyTypes()) === 0) { - $builder->initializedEmpty = true; - } elseif (max($startArrayType->getNextAutoIndexes()) === 0) { - foreach ($startArrayType->getKeyTypes() as $keyType) { - if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { - $builder->initializedEmpty = true; - break; + $phpVersion = PhpVersionStaticAccessor::getInstance(); + if ( + !$phpVersion->updatesAutoIncrementKeyForNegativeValues() + && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer() + ) { + if (count($startArrayType->getKeyTypes()) === 0) { + $builder->initializedEmpty = true; + } elseif (max($startArrayType->getNextAutoIndexes()) === 0) { + foreach ($startArrayType->getKeyTypes() as $keyType) { + if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { + $builder->initializedEmpty = true; + break; + } } } } From 4a7fe14276078bd27dcac6d04fdb4b73d0d8ced2 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 8 Apr 2026 21:24:27 +0200 Subject: [PATCH 15/16] Rework --- src/Php/PhpVersion.php | 4 +- .../Constant/ConstantArrayTypeBuilder.php | 38 ++++++++++--------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index fa083e630ea..a1e4919842d 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -516,9 +516,9 @@ public function updatesAutoIncrementKeyForNegativeValues(): bool return $this->versionId >= 80300; } - public function updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(): bool + public function updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer(): bool { - return $this->versionId >= 80000; + return $this->versionId >= 80000 && $this->versionId < 80300; } } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 00de59767d6..21a6918f4ea 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,7 +42,7 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; - private bool $initializedEmpty = false; + private bool $forceUpdatesAutoIncrementKeyForNegativeValues = false; /** * @param list $keyTypes @@ -76,20 +76,8 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType ); $phpVersion = PhpVersionStaticAccessor::getInstance(); - if ( - !$phpVersion->updatesAutoIncrementKeyForNegativeValues() - && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer() - ) { - if (count($startArrayType->getKeyTypes()) === 0) { - $builder->initializedEmpty = true; - } elseif (max($startArrayType->getNextAutoIndexes()) === 0) { - foreach ($startArrayType->getKeyTypes() as $keyType) { - if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { - $builder->initializedEmpty = true; - break; - } - } - } + if ($phpVersion->updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer()) { + $builder->forceUpdatesAutoIncrementKeyForNegativeValues = !self::wasInitializedEmpty($startArrayType); } if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { @@ -450,12 +438,28 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool } $phpVersion = PhpVersionStaticAccessor::getInstance(); - if ($phpVersion->updatesAutoIncrementKeyForNegativeValues()) { return true; } - return !$this->initializedEmpty && $phpVersion->updatesAutoIncrementKeyForNegativeValuesInNonEmptyInitializer(); + return !$this->forceUpdatesAutoIncrementKeyForNegativeValues; + } + + private static function wasInitializedEmpty(ConstantArrayType $startArrayType): bool + { + if (count($startArrayType->getKeyTypes()) === 0) { + return true; + } + + if (max($startArrayType->getNextAutoIndexes()) === 0) { + foreach ($startArrayType->getKeyTypes() as $keyType) { + if ($keyType instanceof ConstantIntegerType && $keyType->getValue() < 0) { + return true; + } + } + } + + return false; } } From e8fe43aa5c2a4fca5dcb9c88c7ba49bba1f141dc Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 8 Apr 2026 21:50:46 +0200 Subject: [PATCH 16/16] Rework --- src/Type/Constant/ConstantArrayTypeBuilder.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 21a6918f4ea..9e474f7b413 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -42,7 +42,7 @@ final class ConstantArrayTypeBuilder private bool $oversized = false; - private bool $forceUpdatesAutoIncrementKeyForNegativeValues = false; + private bool $initializedEmpty = false; /** * @param list $keyTypes @@ -75,10 +75,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->isList(), ); - $phpVersion = PhpVersionStaticAccessor::getInstance(); - if ($phpVersion->updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer()) { - $builder->forceUpdatesAutoIncrementKeyForNegativeValues = !self::wasInitializedEmpty($startArrayType); - } + $builder->initializedEmpty = self::wasInitializedEmpty($startArrayType); if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) { $builder->degradeToGeneralArray(true); @@ -442,7 +439,11 @@ private function shouldUpdateAutoIndex(int $offsetValue, int $max): bool return true; } - return !$this->forceUpdatesAutoIncrementKeyForNegativeValues; + if ($phpVersion->updatesAutoIncrementKeyForNegativeValuesOnlyInNonEmptyInitializer()) { + return !$this->initializedEmpty; + } + + return false; } private static function wasInitializedEmpty(ConstantArrayType $startArrayType): bool