Skip to content

Commit 701869c

Browse files
ondrejmirtesphpstan-bot
authored andcommitted
Preserve HasOffsetValueType for constant array spreads in degraded array literals
- Track HasOffsetValueType accessories when spreading constant arrays with string keys in InitializerExprTypeResolver::getArrayType() - When the array builder degrades to a general array (e.g. due to a non-constant spread), intersect the result with HasOffsetValueType for keys from constant array spreads that appear later - Properly invalidate tracked offsets when a subsequent non-constant spread could overwrite them - New regression test in tests/PHPStan/Analyser/nsrt/bug-13805.php
1 parent 9f86e84 commit 701869c

3 files changed

Lines changed: 70 additions & 1 deletion

File tree

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,12 @@ Many bugs involve `ConstantArrayType` (array shapes with known keys). Common iss
258258

259259
Fixes typically involve `ConstantArrayType`, `TypeSpecifier` (for narrowing after `array_key_exists`/`isset`), and `MutatingScope` (for tracking assignments).
260260

261+
### Array literal spread operator and ConstantArrayTypeBuilder degradation
262+
263+
`InitializerExprTypeResolver::getArrayType()` computes the type of array literals like `[...$a, ...$b]`. It uses `ConstantArrayTypeBuilder` to build the result type. When a spread item is a single constant array (`getConstantArrays()` returns exactly one), its key/value pairs are added individually. When it's not (e.g., `array<string, mixed>`), the builder is degraded via `degradeToGeneralArray()`, and all subsequent items are merged into a general `ArrayType` with unioned keys and values.
264+
265+
The degradation loses specific key information. To preserve it, `getArrayType()` tracks `HasOffsetValueType` accessories for non-optional keys from constant array spreads with string keys. After building, these are intersected with the degraded result. When a non-constant spread appears later that could overwrite tracked keys (its key type is a supertype of the tracked offsets), those entries are invalidated. This ensures correct handling of PHP's spread ordering semantics where later spreads override earlier ones for same-named string keys.
266+
261267
### Loop analysis: foreach, for, while
262268

263269
Loops are a frequent source of false positives because PHPStan must reason about types across iterations:

src/Reflection/InitializerExprTypeResolver.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@
102102
use function array_keys;
103103
use function array_map;
104104
use function array_merge;
105+
use function array_values;
105106
use function assert;
106107
use function ceil;
107108
use function count;
@@ -637,6 +638,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
637638

638639
$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
639640
$isList = null;
641+
$hasOffsetValueTypes = [];
640642
foreach ($expr->items as $arrayItem) {
641643
$valueType = $getTypeCallback($arrayItem->value);
642644
if ($arrayItem->unpack) {
@@ -657,6 +659,9 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
657659
foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) {
658660
if ($hasStringKey) {
659661
$arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i));
662+
if (!$constantArrayType->isOptionalKey($i)) {
663+
$hasOffsetValueTypes[$constantArrayType->getKeyTypes()[$i]->getValue()] = new HasOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType);
664+
}
660665
} else {
661666
$arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i));
662667
}
@@ -667,6 +672,14 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
667672
if ($this->phpVersion->supportsArrayUnpackingWithStringKeys() && !$valueType->getIterableKeyType()->isString()->no()) {
668673
$isList = false;
669674
$offsetType = $valueType->getIterableKeyType();
675+
676+
foreach ($hasOffsetValueTypes as $key => $hasOffsetValueType) {
677+
if (!$offsetType->isSuperTypeOf($hasOffsetValueType->getOffsetType())->yes()) {
678+
continue;
679+
}
680+
681+
unset($hasOffsetValueTypes[$key]);
682+
}
670683
} else {
671684
$isList ??= $arrayBuilder->isList();
672685
$offsetType = new IntegerType();
@@ -684,7 +697,11 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
684697

685698
$arrayType = $arrayBuilder->getArray();
686699
if ($isList === true) {
687-
return TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
700+
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
701+
}
702+
703+
if (count($hasOffsetValueTypes) > 0 && !$arrayType->isConstantArray()->yes()) {
704+
$arrayType = TypeCombinator::intersect($arrayType, ...array_values($hasOffsetValueTypes));
688705
}
689706

690707
return $arrayType;
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php // onlyForPhpVersions: 80100
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug13805;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/**
10+
* @phpstan-type MinimalRowDefinition array{foo: string, muh: string}
11+
*/
12+
class HelloWorld
13+
{
14+
/**
15+
* @param array{test?: array<string, mixed>} $defaultItems
16+
* @param MinimalRowDefinition $row
17+
*/
18+
public function sayHello(array $row, array $defaultItems): void
19+
{
20+
$result = [
21+
...($defaultItems['test'] ?? []),
22+
...$row,
23+
];
24+
25+
assertType('non-empty-array<string, mixed>&hasOffsetValue(\'foo\', string)&hasOffsetValue(\'muh\', string)', $result);
26+
27+
// $result will always contain the keys from MinimalRowDefinition, therefore also the needed muh
28+
$this->testStuff($result);
29+
}
30+
31+
/** @param array{muh: string} $data */
32+
private function testStuff($data): void
33+
{
34+
35+
}
36+
37+
/**
38+
* @param array<string, int> $a
39+
* @param array{x: string, y: int} $b
40+
*/
41+
public function testSpreadOrder(array $a, array $b): void
42+
{
43+
$result = [...$a, ...$b];
44+
assertType('non-empty-array<string, int|string>&hasOffsetValue(\'x\', string)&hasOffsetValue(\'y\', int)', $result);
45+
}
46+
}

0 commit comments

Comments
 (0)