Skip to content

Commit f5e067e

Browse files
staabmphpstan-bot
authored andcommitted
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 phpstan/phpstan#13669
1 parent e50b57c commit f5e067e

File tree

3 files changed

+91
-1
lines changed

3 files changed

+91
-1
lines changed

src/Analyser/NodeScopeResolver.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6808,15 +6808,48 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar
68086808
}
68096809

68106810
$additionalExpressions = [];
6811-
$offsetValueType = $valueToWrite;
68126811
$lastDimKey = array_key_last($dimFetchStack);
6812+
6813+
// Compute improved intermediate types bottom-up using scope types.
6814+
// The top-down derivation from the root type loses constant array
6815+
// precision (e.g. array{-1: 0, 0: 0} becomes array<-1|0, int>).
6816+
// By applying the write to the scope's tracked constant array type,
6817+
// we preserve the constant array structure through loop generalization.
6818+
$improvedTypes = [];
6819+
$childPostWriteType = $originalValueToWrite;
6820+
for ($key = ($lastDimKey ?? 0) - 1; $key >= 0; $key--) {
6821+
$dimFetch = $dimFetchStack[$key];
6822+
if ($dimFetch->dim === null) {
6823+
break;
6824+
}
6825+
6826+
$nextDimFetch = $dimFetchStack[$key + 1];
6827+
if ($nextDimFetch->dim === null || !$scope->hasExpressionType($dimFetch)->yes()) {
6828+
break;
6829+
}
6830+
6831+
$scopeType = $scope->getType($dimFetch);
6832+
$childOffset = $scope->getType($nextDimFetch->dim);
6833+
6834+
if (!$scopeType->hasOffsetValueType($childOffset)->yes()) {
6835+
break;
6836+
}
6837+
6838+
$improvedType = $scopeType->setExistingOffsetValueType($childOffset, $childPostWriteType);
6839+
$improvedTypes[$key] = $improvedType;
6840+
$childPostWriteType = $improvedType;
6841+
}
6842+
6843+
$offsetValueType = $valueToWrite;
68136844
foreach ($dimFetchStack as $key => $dimFetch) {
68146845
if ($dimFetch->dim === null) {
68156846
continue;
68166847
}
68176848

68186849
if ($key === $lastDimKey) {
68196850
$offsetValueType = $originalValueToWrite;
6851+
} elseif (isset($improvedTypes[$key])) {
6852+
$offsetValueType = $improvedTypes[$key];
68206853
} else {
68216854
$offsetType = $scope->getType($dimFetch->dim);
68226855
$offsetValueType = $offsetValueType->getOffsetValueType($offsetType);

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,4 +1145,11 @@ public function testBug11276(): void
11451145
$this->analyse([__DIR__ . '/data/bug-11276.php'], []);
11461146
}
11471147

1148+
public function testBug13669(): void
1149+
{
1150+
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
1151+
1152+
$this->analyse([__DIR__ . '/data/bug-13669.php'], []);
1153+
}
1154+
11481155
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13669;
4+
5+
final class Foo
6+
{
7+
/**
8+
* @var array<int, array<MailStatus::CODE_*, int>>
9+
*/
10+
private array $mailCounts;
11+
12+
/** @var array<int, array<MailStatus::CODE_*>> */
13+
private array $sources;
14+
15+
/** @param array<int, array<MailStatus::CODE_*>> $sources */
16+
private function __construct(array $sources)
17+
{
18+
$this->mailCounts = [];
19+
$this->sources = $sources;
20+
}
21+
22+
23+
public function countMailStates(): void
24+
{
25+
foreach ($this->sources as $templateId => $mails) {
26+
$this->mailCounts[$templateId] = [
27+
MailStatus::CODE_DELETED => 0,
28+
MailStatus::CODE_NOT_ACTIVE => 0,
29+
MailStatus::CODE_ACTIVE => 0,
30+
MailStatus::CODE_SIMULATION => 0,
31+
];
32+
33+
foreach ($mails as $mail) {
34+
++$this->mailCounts[$templateId][$mail];
35+
}
36+
}
37+
}
38+
39+
}
40+
41+
final class MailStatus
42+
{
43+
public const CODE_DELETED = -1;
44+
45+
public const CODE_NOT_ACTIVE = 0;
46+
47+
public const CODE_SIMULATION = 1;
48+
49+
public const CODE_ACTIVE = 2;
50+
}

0 commit comments

Comments
 (0)