From f5e067e28048f5a7570b7294495d3461f3464e46 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:04:51 +0000 Subject: [PATCH 1/2] Fix false positive "offset might not exist" on array with all constant keys set in loop When a constant array with all expected keys (e.g. array{-1: 0, 0: 0, 1: 0, 2: 0}) was modified inside a foreach loop via a union offset type covering all keys, the intermediate expression type was derived top-down from the root property type, producing a general array (array<-1|0|1|2, int>) that lost the constant structure. During loop generalization, this general array couldn't confirm offset existence, causing a false "Offset ... might not exist" error. The fix computes improved intermediate types bottom-up using the scope's tracked constant array types and setExistingOffsetValueType, preserving the constant array structure through loop generalization. Closes https://github.com/phpstan/phpstan/issues/13669 --- src/Analyser/NodeScopeResolver.php | 35 ++++++++++++- ...nexistentOffsetInArrayDimFetchRuleTest.php | 7 +++ tests/PHPStan/Rules/Arrays/data/bug-13669.php | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-13669.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0c708a4068..b1826cffd5 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6808,8 +6808,39 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } $additionalExpressions = []; - $offsetValueType = $valueToWrite; $lastDimKey = array_key_last($dimFetchStack); + + // Compute improved intermediate types bottom-up using scope types. + // The top-down derivation from the root type loses constant array + // precision (e.g. array{-1: 0, 0: 0} becomes array<-1|0, int>). + // By applying the write to the scope's tracked constant array type, + // we preserve the constant array structure through loop generalization. + $improvedTypes = []; + $childPostWriteType = $originalValueToWrite; + for ($key = ($lastDimKey ?? 0) - 1; $key >= 0; $key--) { + $dimFetch = $dimFetchStack[$key]; + if ($dimFetch->dim === null) { + break; + } + + $nextDimFetch = $dimFetchStack[$key + 1]; + if ($nextDimFetch->dim === null || !$scope->hasExpressionType($dimFetch)->yes()) { + break; + } + + $scopeType = $scope->getType($dimFetch); + $childOffset = $scope->getType($nextDimFetch->dim); + + if (!$scopeType->hasOffsetValueType($childOffset)->yes()) { + break; + } + + $improvedType = $scopeType->setExistingOffsetValueType($childOffset, $childPostWriteType); + $improvedTypes[$key] = $improvedType; + $childPostWriteType = $improvedType; + } + + $offsetValueType = $valueToWrite; foreach ($dimFetchStack as $key => $dimFetch) { if ($dimFetch->dim === null) { continue; @@ -6817,6 +6848,8 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar if ($key === $lastDimKey) { $offsetValueType = $originalValueToWrite; + } elseif (isset($improvedTypes[$key])) { + $offsetValueType = $improvedTypes[$key]; } else { $offsetType = $scope->getType($dimFetch->dim); $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 73d4f028e0..a45ba6b00c 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1145,4 +1145,11 @@ public function testBug11276(): void $this->analyse([__DIR__ . '/data/bug-11276.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..a24c31b2d8 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-13669.php @@ -0,0 +1,50 @@ +> + */ + 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; +} From ef871e925521171e13f6e1f07d1f2837a8b31542 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 22 Feb 2026 22:13:41 +0000 Subject: [PATCH 2/2] Add regression test for #10349 Closes https://github.com/phpstan/phpstan/issues/10349 --- .../InvalidBinaryOperationRuleTest.php | 18 +++++ .../Rules/Operators/data/bug-10349.php | 68 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/PHPStan/Rules/Operators/data/bug-10349.php diff --git a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php index fb1b119745..3cb6904e0e 100644 --- a/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php +++ b/tests/PHPStan/Rules/Operators/InvalidBinaryOperationRuleTest.php @@ -821,4 +821,22 @@ public function testBug10595(): void $this->analyse([__DIR__ . '/data/bug-10595.php'], []); } + public function testBug10349(): void + { + $this->analyse([__DIR__ . '/data/bug-10349.php'], [ + [ + 'Binary operation "+=" between bool|float|int|string and int results in an error.', + 17, + ], + [ + 'Binary operation "+=" between bool|float|int|string and int results in an error.', + 42, + ], + [ + 'Binary operation "+=" between bool|float|int|string and int results in an error.', + 59, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Operators/data/bug-10349.php b/tests/PHPStan/Rules/Operators/data/bug-10349.php new file mode 100644 index 0000000000..4ce3b96535 --- /dev/null +++ b/tests/PHPStan/Rules/Operators/data/bug-10349.php @@ -0,0 +1,68 @@ +> $expected + * + * @return array> + */ + private function issue_1_A($ptr, $expected) + { + foreach ($expected as $key => $param) { + if ($param['number-1'] !== false) { + $expected[$key]['number-1'] += $ptr; + } + + if ($param['number-2'] !== false) { + $expected[$key]['number-2'] += $ptr; + } + } + + return $expected; + } + + /** + * @param int $ptr + * @param array> $expected + * + * @return array> + */ + private function issue_1_B($ptr, $expected) + { + foreach ($expected as $key => $param) { + if (is_int($expected[$key]['number-1'])) { + $expected[$key]['number-1'] += $ptr; + } + + if ($param['number-2'] !== false) { + $expected[$key]['number-2'] += $ptr; + } + } + + return $expected; + } + + /** + * @param int $ptr + * @param array> $expected + * + * @return array> + */ + private function issue_2($ptr, $expected) + { + foreach ($expected as $key => $param) { + if (is_int($param['number-1'])) { + $expected[$key]['number-1'] += $ptr; + } + if (is_int($param['number-2'])) { + $expected[$key]['number-2'] += $ptr; + } + } + + return $expected; + } +}