From 73870d54d127f75d3ac29325186c4ccf272a15fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:21:35 +0000 Subject: [PATCH 1/7] Fix deep nested array dim assignment marking keys as optional - Added recursive handling in ArrayType::setExistingOffsetValueType() for arrays whose item type is itself an array with constant array values (3+ nesting levels) - New regression test in tests/PHPStan/Analyser/nsrt/bug-13637.php - The root cause was that setExistingOffsetValueType() only handled the case where the item type was directly a constant array, falling through to a naive union for deeper nesting which re-introduced intermediate states with optional keys Closes https://github.com/phpstan/phpstan/issues/13637 --- src/Type/ArrayType.php | 18 +++++++++ tests/PHPStan/Analyser/nsrt/bug-13637.php | 45 +++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13637.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc6..2976a325b5 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -410,6 +410,24 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T } } + if ( + $this->itemType->isArray()->yes() + && $valueType->isArray()->yes() + && $this->itemType->getIterableValueType()->isConstantArray()->yes() + && $valueType->getIterableValueType()->isConstantArray()->yes() + ) { + $newItemType = $this->itemType->setExistingOffsetValueType( + $valueType->getIterableKeyType(), + $valueType->getIterableValueType(), + ); + if ($newItemType !== $this->itemType) { + return new self( + $this->keyType, + $newItemType, + ); + } + } + return new self( $this->keyType, TypeCombinator::union($this->itemType, $valueType), diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php new file mode 100644 index 0000000000..7c2bdd3cf7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -0,0 +1,45 @@ +>> +*/ +function doesNotWork() : array { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j +1; + $l = $i * 3; + $final[$i][$j][$k]['abc'] = $i; + $final[$i][$j][$k]['def'] = $i; + $final[$i][$j][$k]['ghi'] = $i; + + assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k]); + } + + return $final; +} + +/** +* @return array> +*/ +function thisWorks() : array { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j +1; + $l = $i * 3; + $final[$i][$j]['abc'] = $i; + $final[$i][$j]['def'] = $i; + $final[$i][$j]['ghi'] = $i; + + assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j]); + } + + return $final; +} From 72c1e75206297632fa7355bd68d4ee6cc517b310 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 22:37:20 +0000 Subject: [PATCH 2/7] Generalize deep nested array setExistingOffsetValueType for arbitrary depth The previous fix only handled 3-level nesting by checking that inner value types were constant arrays. For 4+ levels, the intermediate value types are general ArrayTypes, so the condition failed. Generalize the condition to recurse through any depth of array nesting: - Check both types are non-constant arrays (constant arrays are handled by the existing block above) - Check both types' value types are arrays (ensuring there's deeper nesting structure to recurse into, excluding generic arrays like non-empty-array whose value type is mixed) The recursion naturally bottoms out when it reaches constant arrays at the deepest level. Co-Authored-By: Claude Opus 4.6 --- src/Type/ArrayType.php | 6 ++++-- tests/PHPStan/Analyser/nsrt/bug-13637.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 2976a325b5..12f7bed263 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -412,9 +412,11 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T if ( $this->itemType->isArray()->yes() + && !$this->itemType->isConstantArray()->yes() && $valueType->isArray()->yes() - && $this->itemType->getIterableValueType()->isConstantArray()->yes() - && $valueType->getIterableValueType()->isConstantArray()->yes() + && !$valueType->isConstantArray()->yes() + && $this->itemType->getIterableValueType()->isArray()->yes() + && $valueType->getIterableValueType()->isArray()->yes() ) { $newItemType = $this->itemType->setExistingOffsetValueType( $valueType->getIterableKeyType(), diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php index 7c2bdd3cf7..efe7b9dfcc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13637.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -24,6 +24,26 @@ function doesNotWork() : array { return $final; } +/** +* @return array>>> +*/ +function fourLevelsDeep() : array { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j + 1; + $l = $i * 3; + $final[$i][$j][$k][$l]['abc'] = $i; + $final[$i][$j][$k][$l]['def'] = $i; + $final[$i][$j][$k][$l]['ghi'] = $i; + + assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l]); + } + + return $final; +} + /** * @return array> */ From d3189c7c444e07d55c5ef4ea8924fa6f2aa3820d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 18 Mar 2026 22:46:35 +0000 Subject: [PATCH 3/7] Add tests for 5 and 6 level deep nested array assignments Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13637.php | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php index efe7b9dfcc..ff6ce69e63 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13637.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -44,6 +44,49 @@ function fourLevelsDeep() : array { return $final; } +/** +* @return array>>>> +*/ +function fiveLevelsDeep() : array { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j + 1; + $l = $i * 3; + $m = $i + 10; + $final[$i][$j][$k][$l][$m]['abc'] = $i; + $final[$i][$j][$k][$l][$m]['def'] = $i; + $final[$i][$j][$k][$l][$m]['ghi'] = $i; + + assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l][$m]); + } + + return $final; +} + +/** +* @return array>>>>> +*/ +function sixLevelsDeep() : array { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j + 1; + $l = $i * 3; + $m = $i + 10; + $n = $i + 20; + $final[$i][$j][$k][$l][$m][$n]['abc'] = $i; + $final[$i][$j][$k][$l][$m][$n]['def'] = $i; + $final[$i][$j][$k][$l][$m][$n]['ghi'] = $i; + + assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l][$m][$n]); + } + + return $final; +} + /** * @return array> */ From d77e08d9e0a6fd82c5e4368552f3ae212d6159df Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 08:20:48 +0000 Subject: [PATCH 4/7] Add assertType on $final inside and outside for loops in bug-13637 test Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13637.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php index ff6ce69e63..a2d783fdcc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13637.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -19,8 +19,10 @@ function doesNotWork() : array { $final[$i][$j][$k]['ghi'] = $i; assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k]); + assertType("non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>", $final); } + assertType("non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>", $final); return $final; } @@ -39,8 +41,10 @@ function fourLevelsDeep() : array { $final[$i][$j][$k][$l]['ghi'] = $i; assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l]); + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>", $final); } + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>", $final); return $final; } @@ -60,8 +64,10 @@ function fiveLevelsDeep() : array { $final[$i][$j][$k][$l][$m]['ghi'] = $i; assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l][$m]); + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>>", $final); } + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>>", $final); return $final; } @@ -82,8 +88,10 @@ function sixLevelsDeep() : array { $final[$i][$j][$k][$l][$m][$n]['ghi'] = $i; assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j][$k][$l][$m][$n]); + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>>>", $final); } + assertType("non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>>>>>", $final); return $final; } @@ -102,7 +110,9 @@ function thisWorks() : array { $final[$i][$j]['ghi'] = $i; assertType("array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}", $final[$i][$j]); + assertType("non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>", $final); } + assertType("non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>, ghi: int<0, 4>}>>", $final); return $final; } From 263c20ad61894d5a5fc033fb0a334c88e63e604a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 08:32:51 +0000 Subject: [PATCH 5/7] Use elseif for mutually exclusive constant array check The two branches in setExistingOffsetValueType() are mutually exclusive (first checks isConstantArray()->yes(), second checks !isConstantArray()->yes()), so use elseif to make this explicit and save unnecessary condition evaluation. Co-Authored-By: Claude Opus 4.6 --- src/Type/ArrayType.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 12f7bed263..d0c85461ed 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -408,9 +408,7 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T $newItemType, ); } - } - - if ( + } elseif ( $this->itemType->isArray()->yes() && !$this->itemType->isConstantArray()->yes() && $valueType->isArray()->yes() From 1c9704b710a34ca00ed90bfe8772cd21404e1fe2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 09:09:52 +0000 Subject: [PATCH 6/7] Add unit tests for setExistingOffsetValueType with maybe-array item type Tests that when the item type is a union of array and non-array (isArray() returns maybe), the recursive elseif branch is correctly skipped and the fallback TypeCombinator::union path is used instead. Also tests the definite array case where the recursive path produces correct merged results. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Type/ArrayTypeTest.php | 80 ++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/tests/PHPStan/Type/ArrayTypeTest.php b/tests/PHPStan/Type/ArrayTypeTest.php index 1e2b616b9a..3d77254bcc 100644 --- a/tests/PHPStan/Type/ArrayTypeTest.php +++ b/tests/PHPStan/Type/ArrayTypeTest.php @@ -301,6 +301,86 @@ public function testHasOffsetValueType( ); } + public static function dataSetExistingOffsetValueType(): array + { + return [ + 'maybe array item type falls through to union' => [ + // array>> + new ArrayType( + new IntegerType(), + new UnionType([ + new IntegerType(), + new ArrayType( + new IntegerType(), + new ArrayType( + new IntegerType(), + new ConstantArrayType( + [new ConstantStringType('abc')], + [new IntegerType()], + ), + ), + ), + ]), + ), + new IntegerType(), + new ArrayType( + new IntegerType(), + new ArrayType( + new IntegerType(), + new ConstantArrayType( + [new ConstantStringType('def')], + [new IntegerType()], + ), + ), + ), + 'array>|int>', + ], + 'definite array item type uses recursive path' => [ + // array>> + new ArrayType( + new IntegerType(), + new ArrayType( + new IntegerType(), + new ArrayType( + new IntegerType(), + new ConstantArrayType( + [new ConstantStringType('abc')], + [new IntegerType()], + ), + ), + ), + ), + new IntegerType(), + new ArrayType( + new IntegerType(), + new ArrayType( + new IntegerType(), + new ConstantArrayType( + [new ConstantStringType('def')], + [new IntegerType()], + ), + ), + ), + 'array>>', + ], + ]; + } + + #[DataProvider('dataSetExistingOffsetValueType')] + public function testSetExistingOffsetValueType( + ArrayType $type, + Type $offsetType, + Type $valueType, + string $expectedType, + ): void + { + $actualResult = $type->setExistingOffsetValueType($offsetType, $valueType); + $this->assertSame( + $expectedType, + $actualResult->describe(VerbosityLevel::precise()), + ); + } + public static function dataSpliceArray(): array { return [ From 3c21170a473f8601326512204e18a8b7af5976f8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Mar 2026 10:31:51 +0000 Subject: [PATCH 7/7] Convert setExistingOffsetValueType unit tests to NSRT tests Replace unit tests in ArrayTypeTest with an NSRT test in bug-13637.php that covers the maybe-array item type scenario (union with non-array skips recursive path) alongside the existing deep nesting tests. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13637.php | 18 +++++ tests/PHPStan/Type/ArrayTypeTest.php | 80 ----------------------- 2 files changed, 18 insertions(+), 80 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php index a2d783fdcc..fe374bb1af 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13637.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13637.php @@ -95,6 +95,24 @@ function sixLevelsDeep() : array { return $final; } +/** Tests that maybe-array item type (union with non-array) skips the recursive path */ +function maybeArrayItemType(bool $flag): void { + $final = []; + + for ($i = 0; $i < 5; $i++) { + $j = $i * 2; + $k = $j + 1; + if ($flag) { + $final[$i][$j][$k]['abc'] = $i; + $final[$i][$j][$k]['def'] = $i; + } else { + $final[$i] = $i; + } + } + + assertType("non-empty-array, non-empty-array, non-empty-array, array{abc: int<0, 4>, def: int<0, 4>}>>|int<0, 4>>", $final); +} + /** * @return array> */ diff --git a/tests/PHPStan/Type/ArrayTypeTest.php b/tests/PHPStan/Type/ArrayTypeTest.php index 3d77254bcc..1e2b616b9a 100644 --- a/tests/PHPStan/Type/ArrayTypeTest.php +++ b/tests/PHPStan/Type/ArrayTypeTest.php @@ -301,86 +301,6 @@ public function testHasOffsetValueType( ); } - public static function dataSetExistingOffsetValueType(): array - { - return [ - 'maybe array item type falls through to union' => [ - // array>> - new ArrayType( - new IntegerType(), - new UnionType([ - new IntegerType(), - new ArrayType( - new IntegerType(), - new ArrayType( - new IntegerType(), - new ConstantArrayType( - [new ConstantStringType('abc')], - [new IntegerType()], - ), - ), - ), - ]), - ), - new IntegerType(), - new ArrayType( - new IntegerType(), - new ArrayType( - new IntegerType(), - new ConstantArrayType( - [new ConstantStringType('def')], - [new IntegerType()], - ), - ), - ), - 'array>|int>', - ], - 'definite array item type uses recursive path' => [ - // array>> - new ArrayType( - new IntegerType(), - new ArrayType( - new IntegerType(), - new ArrayType( - new IntegerType(), - new ConstantArrayType( - [new ConstantStringType('abc')], - [new IntegerType()], - ), - ), - ), - ), - new IntegerType(), - new ArrayType( - new IntegerType(), - new ArrayType( - new IntegerType(), - new ConstantArrayType( - [new ConstantStringType('def')], - [new IntegerType()], - ), - ), - ), - 'array>>', - ], - ]; - } - - #[DataProvider('dataSetExistingOffsetValueType')] - public function testSetExistingOffsetValueType( - ArrayType $type, - Type $offsetType, - Type $valueType, - string $expectedType, - ): void - { - $actualResult = $type->setExistingOffsetValueType($offsetType, $valueType); - $this->assertSame( - $expectedType, - $actualResult->describe(VerbosityLevel::precise()), - ); - } - public static function dataSpliceArray(): array { return [