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; +} 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; + } +}