From 701869c8911bbde8fe11f35e399276b3e6149061 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:31:42 +0000 Subject: [PATCH 1/2] 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 --- CLAUDE.md | 6 +++ .../InitializerExprTypeResolver.php | 19 +++++++- tests/PHPStan/Analyser/nsrt/bug-13805.php | 46 +++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13805.php diff --git a/CLAUDE.md b/CLAUDE.md index c2df251460..9074d92c20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`), 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: diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 499f504a7a..937775031f 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -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; @@ -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) { @@ -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)); } @@ -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(); @@ -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; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13805.php b/tests/PHPStan/Analyser/nsrt/bug-13805.php new file mode 100644 index 0000000000..ae524e0b83 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13805.php @@ -0,0 +1,46 @@ +} $defaultItems + * @param MinimalRowDefinition $row + */ + public function sayHello(array $row, array $defaultItems): void + { + $result = [ + ...($defaultItems['test'] ?? []), + ...$row, + ]; + + assertType('non-empty-array&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 $a + * @param array{x: string, y: int} $b + */ + public function testSpreadOrder(array $a, array $b): void + { + $result = [...$a, ...$b]; + assertType('non-empty-array&hasOffsetValue(\'x\', string)&hasOffsetValue(\'y\', int)', $result); + } +} From 8b8f62ded18d10a24968c1d0a716aec44e82151f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 12 Feb 2026 14:41:47 +0000 Subject: [PATCH 2/2] Add regression test for #13805 Closes https://github.com/phpstan/phpstan/issues/13805 Co-Authored-By: Claude Opus 4.6 --- .../Rules/Methods/CallMethodsRuleTest.php | 8 +++++ .../PHPStan/Rules/Methods/data/bug-13805.php | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-13805.php diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 845667ef40..0063f5ef0d 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -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'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-13805.php b/tests/PHPStan/Rules/Methods/data/bug-13805.php new file mode 100644 index 0000000000..5d4bfed65b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-13805.php @@ -0,0 +1,31 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug13805; + +/** + * @phpstan-type MinimalRowDefinition array{foo: string, muh: string, ...} + */ +class HelloWorld +{ + /** + * @param array{test?: array, ...} $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 + { + + } +}