From c4a3c75c9c6112c259ca9f8b03c50b087201dc34 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 21 Mar 2026 08:36:15 +0000 Subject: [PATCH] Fix incorrect narrowing of nested array dim fetch after assignment in loop When a constant array shape (e.g. array{-1: 0, 0: 0, 2: 0, 1: 0}) was assigned to a nested property dim fetch like $this->prop[$key], and then an inner loop modified it via ++$this->prop[$key][$offset], the constant shape was lost during loop processing. This caused false positive "Offset might not exist" errors even though all offsets were explicitly set. Two changes fix this: 1. In AssignHandler's descending phase, when the scope tracks a more precise type for an intermediate dim fetch and that type has all the offsets needed by the next level, use the scope's tracked type instead of recomputing from the parent's general array type. 2. In MutatingScope::generalizeVariableTypeHolders, when a parent expression is generalized and both scopes track a child expression as constant arrays with matching keys, generalize the child instead of invalidating it. This preserves the constant array shape through loop generalization. Fixes https://github.com/phpstan/phpstan/issues/13669 --- src/Analyser/ExprHandler/AssignHandler.php | 11 ++++- src/Analyser/MutatingScope.php | 12 +++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 7 +++ tests/PHPStan/Rules/Arrays/data/bug-13669.php | 48 +++++++++++++++++++ 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-13669.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1a1ed14bae..b64b82936b 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -948,7 +948,8 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $offsetValueTypeStack = [$offsetValueType]; $generalizeOnWrite = $offsetTypes[array_key_last($offsetTypes)][0] !== null; - foreach (array_slice($offsetTypes, 0, -1) as [$offsetType, $dimFetch]) { + $slicedOffsets = array_slice($offsetTypes, 0, -1); + foreach ($slicedOffsets as $k => [$offsetType, $dimFetch]) { if ($offsetType === null) { $offsetValueType = new ConstantArrayType([], []); $generalizeOnWrite = false; @@ -959,7 +960,13 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); + $scopeType = $scope->getType($dimFetch); + $nextOffsetType = $offsetTypes[$k + 1][0] ?? null; + if ($nextOffsetType !== null && $scopeType->hasOffsetValueType($nextOffsetType)->yes()) { + $offsetValueType = $scopeType; + } else { + $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); + } } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 42fe96957d..778aef896c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3852,6 +3852,18 @@ private function generalizeVariableTypeHolders( continue; } + if (isset($otherVariableTypeHolders[$variableExprString])) { + $thisType = $variableTypeHolder->getType(); + $otherType = $otherVariableTypeHolders[$variableExprString]->getType(); + if ( + $thisType->isConstantArray()->yes() + && $otherType->isConstantArray()->yes() + && $thisType->getIterableKeyType()->equals($otherType->getIterableKeyType()) + ) { + break; + } + } + continue 2; } if (!isset($otherVariableTypeHolders[$variableExprString])) { diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index e239384855..f202fb6583 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1263,4 +1263,11 @@ public function testBug14308(): void $this->analyse([__DIR__ . '/data/bug-14308.php'], []); } + public function testBug13669(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-13669.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13669.php b/tests/PHPStan/Rules/Arrays/data/bug-13669.php new file mode 100644 index 0000000000..4b2b674c06 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-13669.php @@ -0,0 +1,48 @@ +> + */ + private array $mailCounts; + + /** @var array> */ + private array $sources; + + /** @param array> $sources */ + private function __construct(array $sources) + { + $this->mailCounts = []; + $this->sources = $sources; + } + + public function countMailStates(): void + { + foreach ($this->sources as $templateId => $mails) { + $this->mailCounts[$templateId] = [ + MailStatus::CODE_DELETED => 0, + MailStatus::CODE_NOT_ACTIVE => 0, + MailStatus::CODE_ACTIVE => 0, + MailStatus::CODE_SIMULATION => 0, + ]; + + foreach ($mails as $mail) { + ++$this->mailCounts[$templateId][$mail]; + } + } + } +} + +final class MailStatus +{ + public const CODE_DELETED = -1; + + public const CODE_NOT_ACTIVE = 0; + + public const CODE_SIMULATION = 1; + + public const CODE_ACTIVE = 2; +}