From 40ff6dd37c73bc3455a12110496adb25a4329a53 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:27:03 +0000 Subject: [PATCH] Fix deeply nested array offset tracking losing required keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added recursive handling in ArrayType::setExistingOffsetValueType() for non-constant array item types, so nested arrays at any depth correctly propagate key optionality updates - New regression test in tests/PHPStan/Analyser/nsrt/bug-13637.php - Updated bug-7903 expected error count (39→36) as fix resolves 3 false positives - Root cause: when itemType was a non-constant array (e.g. array), setExistingOffsetValueType fell through to TypeCombinator::union which re-merged the old optional keys with the updated required keys Closes https://github.com/phpstan/phpstan/issues/13637 --- src/Type/ArrayType.php | 17 +++++++ .../Analyser/AnalyserIntegrationTest.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13637.php | 45 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13637.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 288caefdc6..a1de092b1d 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -410,6 +410,23 @@ public function setExistingOffsetValueType(Type $offsetType, Type $valueType): T } } + if ( + !$this->itemType->isConstantArray()->yes() + && $this->itemType->isArray()->yes() + && $valueType->isArray()->yes() + ) { + $newItemType = $this->itemType->setExistingOffsetValueType( + $valueType->getIterableKeyType(), + $valueType->getIterableValueType(), + ); + if (!$newItemType->equals($this->itemType)) { + return new self( + $this->keyType, + $newItemType, + ); + } + } + return new self( $this->keyType, TypeCombinator::union($this->itemType, $valueType), diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 820338325d..8e832de756 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -998,7 +998,7 @@ public function testBug7581(): void public function testBug7903(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-7903.php'); - $this->assertCount(39, $errors); + $this->assertCount(36, $errors); } public function testBug7901(): void diff --git a/tests/PHPStan/Analyser/nsrt/bug-13637.php b/tests/PHPStan/Analyser/nsrt/bug-13637.php new file mode 100644 index 0000000000..45ce7530cb --- /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; +}