Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,12 @@ Many bugs involve `ConstantArrayType` (array shapes with known keys). Common iss

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

### Array literal spread operator and ConstantArrayTypeBuilder degradation

`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.

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.

### Loop analysis: foreach, for, while

Loops are a frequent source of false positives because PHPStan must reason about types across iterations:
Expand Down
19 changes: 18 additions & 1 deletion src/Reflection/InitializerExprTypeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
use function array_keys;
use function array_map;
use function array_merge;
use function array_values;
use function assert;
use function ceil;
use function count;
Expand Down Expand Up @@ -637,6 +638,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type

$arrayBuilder = ConstantArrayTypeBuilder::createEmpty();
$isList = null;
$hasOffsetValueTypes = [];
foreach ($expr->items as $arrayItem) {
$valueType = $getTypeCallback($arrayItem->value);
if ($arrayItem->unpack) {
Expand All @@ -657,6 +659,9 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) {
if ($hasStringKey) {
$arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i));
if (!$constantArrayType->isOptionalKey($i)) {
$hasOffsetValueTypes[$constantArrayType->getKeyTypes()[$i]->getValue()] = new HasOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType);
}
} else {
$arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i));
}
Expand All @@ -667,6 +672,14 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
if ($this->phpVersion->supportsArrayUnpackingWithStringKeys() && !$valueType->getIterableKeyType()->isString()->no()) {
$isList = false;
$offsetType = $valueType->getIterableKeyType();

foreach ($hasOffsetValueTypes as $key => $hasOffsetValueType) {
if (!$offsetType->isSuperTypeOf($hasOffsetValueType->getOffsetType())->yes()) {
continue;
}

unset($hasOffsetValueTypes[$key]);
}
} else {
$isList ??= $arrayBuilder->isList();
$offsetType = new IntegerType();
Expand All @@ -684,7 +697,11 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type

$arrayType = $arrayBuilder->getArray();
if ($isList === true) {
return TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
$arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType());
}

if (count($hasOffsetValueTypes) > 0 && !$arrayType->isConstantArray()->yes()) {
$arrayType = TypeCombinator::intersect($arrayType, ...array_values($hasOffsetValueTypes));
}

return $arrayType;
Expand Down
46 changes: 46 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13805.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php // onlyForPhpVersions: 80100

declare(strict_types = 1);

namespace Bug13805;

use function PHPStan\Testing\assertType;

/**
* @phpstan-type MinimalRowDefinition array{foo: string, muh: string}
*/
class HelloWorld
{
/**
* @param array{test?: array<string, mixed>} $defaultItems
* @param MinimalRowDefinition $row
*/
public function sayHello(array $row, array $defaultItems): void
{
$result = [
...($defaultItems['test'] ?? []),
...$row,
];

assertType('non-empty-array<string, mixed>&hasOffsetValue(\'foo\', string)&hasOffsetValue(\'muh\', string)', $result);

// $result will always contain the keys from MinimalRowDefinition, therefore also the needed muh
$this->testStuff($result);
}

/** @param array{muh: string} $data */
private function testStuff($data): void
{

}

/**
* @param array<string, int> $a
* @param array{x: string, y: int} $b
*/
public function testSpreadOrder(array $a, array $b): void
{
$result = [...$a, ...$b];
assertType('non-empty-array<string, int|string>&hasOffsetValue(\'x\', string)&hasOffsetValue(\'y\', int)', $result);
}
}
8 changes: 8 additions & 0 deletions tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3844,4 +3844,12 @@ public function testBug12875(): void
$this->analyse([__DIR__ . '/data/bug-12875.php'], []);
}

public function testBug13805(): void
{
$this->checkThisOnly = false;
$this->checkNullables = true;
$this->checkUnionTypes = true;
$this->analyse([__DIR__ . '/data/bug-13805.php'], []);
}

}
31 changes: 31 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-13805.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php // lint >= 8.1

declare(strict_types = 1);

namespace Bug13805;

/**
* @phpstan-type MinimalRowDefinition array{foo: string, muh: string, ...}
*/
class HelloWorld
{
/**
* @param array{test?: array<string, mixed>, ...} $defaultItems
* @param MinimalRowDefinition $row
*/
public function sayHello(array $row, array $defaultItems): void
{
$result = [
...($defaultItems['test'] ?? []),
...$row,
];

$this->testStuff($result);
}

/** @param array{muh: string, ...} $data */
private function testStuff($data): void
{

}
}
Loading