From 857effa014e578e7f6de5a6eba15d44febd06b92 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 27 May 2026 09:28:37 +0000 Subject: [PATCH 1/4] Preserve constant array shape when spreading a union of constant arrays in array literals - In `InitializerExprTypeResolver::getArrayType()`, change the `count($constantArrays) === 1` check to `count($constantArrays) > 0` to handle unions of constant arrays (e.g. `array{key: T}|array{}`) - For string-key arrays: merge keys across all constant arrays, marking keys not present in all branches as optional, with value types unioned - For integer-key arrays: merge by position across all constant arrays, with positions not present in all branches marked optional - Update `$hasOffsetValueTypes` tracking to correctly handle merged keys that overlap with previously-set keys --- .../InitializerExprTypeResolver.php | 101 +++++++++++++++--- tests/PHPStan/Analyser/nsrt/bug-14708.php | 97 +++++++++++++++++ 2 files changed, 184 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14708.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 7332aab8f36..a701277678f 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -649,27 +649,100 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type $valueType = $getTypeCallback($arrayItem->value); if ($arrayItem->unpack) { $constantArrays = $valueType->getConstantArrays(); - if (count($constantArrays) === 1) { - $constantArrayType = $constantArrays[0]; - + if (count($constantArrays) > 0) { $hasStringKey = false; if ($this->phpVersion->supportsArrayUnpackingWithStringKeys()) { - foreach ($constantArrayType->getKeyTypes() as $keyType) { - if ($keyType->isString()->yes()) { - $hasStringKey = true; - break; + foreach ($constantArrays as $constantArrayType) { + foreach ($constantArrayType->getKeyTypes() as $keyType) { + if ($keyType->isString()->yes()) { + $hasStringKey = true; + break 2; + } } } } - 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); + if ($hasStringKey) { + $totalArrays = count($constantArrays); + /** @var array, keyType: ConstantIntegerType|ConstantStringType, presentCount: int, anyOptional: bool}> $mergedKeys */ + $mergedKeys = []; + $keyOrder = []; + + foreach ($constantArrays as $constantArrayType) { + foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { + $keyValue = $keyType->getValue(); + if (!isset($mergedKeys[$keyValue])) { + $mergedKeys[$keyValue] = [ + 'valueTypes' => [], + 'keyType' => $keyType, + 'presentCount' => 0, + 'anyOptional' => false, + ]; + $keyOrder[] = $keyValue; + } + $mergedKeys[$keyValue]['valueTypes'][] = $constantArrayType->getValueTypes()[$i]; + $mergedKeys[$keyValue]['presentCount']++; + if (!$constantArrayType->isOptionalKey($i)) { + continue; + } + + $mergedKeys[$keyValue]['anyOptional'] = true; } - } else { - $arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i)); + } + + foreach ($keyOrder as $keyValue) { + $info = $mergedKeys[$keyValue]; + $mergedValueType = TypeCombinator::union(...$info['valueTypes']); + $isOptional = $info['anyOptional'] || $info['presentCount'] < $totalArrays; + $arrayBuilder->setOffsetValueType($info['keyType'], $mergedValueType, $isOptional); + + if (isset($hasOffsetValueTypes[$keyValue])) { + if ($isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType( + $info['keyType'], + TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType), + ); + } else { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); + } + } elseif (!$isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); + } + } + } else { + $maxLen = 0; + $totalArrays = count($constantArrays); + foreach ($constantArrays as $constantArrayType) { + $len = count($constantArrayType->getKeyTypes()); + if ($len <= $maxLen) { + continue; + } + + $maxLen = $len; + } + + for ($pos = 0; $pos < $maxLen; $pos++) { + $posValueTypes = []; + $presentCount = 0; + $anyOptional = false; + foreach ($constantArrays as $constantArrayType) { + $keyTypes = $constantArrayType->getKeyTypes(); + if ($pos >= count($keyTypes)) { + continue; + } + + $posValueTypes[] = $constantArrayType->getValueTypes()[$pos]; + $presentCount++; + if (!$constantArrayType->isOptionalKey($pos)) { + continue; + } + + $anyOptional = true; + } + + $mergedValueType = TypeCombinator::union(...$posValueTypes); + $isOptional = $anyOptional || $presentCount < $totalArrays; + $arrayBuilder->setOffsetValueType(null, $mergedValueType, $isOptional); } } } else { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14708.php b/tests/PHPStan/Analyser/nsrt/bug-14708.php new file mode 100644 index 00000000000..410e85715e8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14708.php @@ -0,0 +1,97 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14708; + +use function PHPStan\Testing\assertType; + +/** @return array{test: bool, spread?: true} */ +function test1(bool $spread): array { + $result = [ + 'test' => $spread, + ...($spread ? ['spread' => true] : []), + ]; + assertType('array{test: bool, spread?: true}', $result); + return $result; +} + +/** @return array{test: bool, spread?: true} */ +function test2(bool $spread): array { + $return1 = ['test' => $spread]; + $return2 = $spread ? ['spread' => true] : []; + + $result = [...$return1, ...$return2]; + assertType('array{test: bool, spread?: true}', $result); + return $result; +} + +/** @return array{test: bool, spread?: true} */ +function test3(bool $spread): array { + $return = ['test' => $spread]; + if ($spread) { + $return['spread'] = true; + } + + assertType('array{test: bool, spread?: true}', $return); + return $return; +} + +function testMultipleOptionalKeys(bool $a, bool $b): void { + $result = [ + 'base' => 1, + ...($a ? ['x' => 'hello'] : []), + ...($b ? ['y' => 42] : []), + ]; + assertType("array{base: 1, x?: 'hello', y?: 42}", $result); +} + +function testOverlappingKeys(bool $flag): void { + $result = [ + 'a' => 1, + ...($flag ? ['a' => 2, 'b' => 3] : ['b' => 4]), + ]; + assertType('array{a: 1|2, b: 3|4}', $result); +} + +function testIntegerKeysUnion(bool $flag): void { + $result = [ + 'start' => 0, + ...($flag ? [1, 2] : [3]), + ]; + assertType('array{start: 0, 0: 1|3, 1?: 2}', $result); +} + +function testAllBranchesSameKeys(bool $flag): void { + $result = [ + ...($flag ? ['a' => 1, 'b' => 2] : ['a' => 3, 'b' => 4]), + ]; + assertType('array{a: 1|3, b: 2|4}', $result); +} + +/** @param 'x'|'y'|'z' $variant */ +function testThreeBranchUnion(string $variant): void { + if ($variant === 'x') { + $extra = ['x' => 1]; + } elseif ($variant === 'y') { + $extra = ['y' => 2]; + } else { + $extra = []; + } + $result = ['base' => true, ...$extra]; + assertType('array{base: true, y?: 2, x?: 1}', $result); +} + +function testIntegerOnlyUnion(bool $flag): void { + $result = [ + ...($flag ? [1, 2, 3] : [4, 5]), + ]; + assertType('array{0: 1|4, 1: 2|5, 2?: 3}', $result); +} + +function testEmptyVsNonEmpty(bool $flag): void { + $result = [ + ...($flag ? ['key' => 'value'] : []), + ]; + assertType("array{key?: 'value'}", $result); +} From 760b9658477154077300e90b518da46fc81ecc43 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 10 Aug 2026 17:12:15 +0000 Subject: [PATCH 2/4] Renumber integer keys when unpacking constant arrays with string keys Unpacking merges string keys by name, but integer keys are always renumbered by PHP. The previous code decided per unpacked array: as soon as any string key was present, *all* keys including integer ones were set by their original key, so `[9, ...['a' => 1, 5]]` was inferred as `array{0: 5, a: 1}` instead of `array{0: 9, a: 1, 1: 5}` - the 5 overwrote the leading 9 and one element was lost. Decide per key instead: string keys are merged by name, integer keys by their position among the integer keys of each unpacked array, and are appended so the builder renumbers them. This makes the shape correct for mixed-key arrays, both for a single unpacked array and for a union of them, and collapses the previously separate string-key and integer-key merging paths into one. Co-Authored-By: Claude Opus 5 --- .../InitializerExprTypeResolver.php | 137 +++++++----------- .../nsrt/array-unpacking-string-keys.php | 13 ++ tests/PHPStan/Analyser/nsrt/bug-14708.php | 16 ++ 3 files changed, 83 insertions(+), 83 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index a701277678f..0b2c5270491 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -650,99 +650,70 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type if ($arrayItem->unpack) { $constantArrays = $valueType->getConstantArrays(); if (count($constantArrays) > 0) { - $hasStringKey = false; - if ($this->phpVersion->supportsArrayUnpackingWithStringKeys()) { - foreach ($constantArrays as $constantArrayType) { - foreach ($constantArrayType->getKeyTypes() as $keyType) { - if ($keyType->isString()->yes()) { - $hasStringKey = true; - break 2; - } + $keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys(); + $totalArrays = count($constantArrays); + + // Unpacking merges string keys by name, while integer keys are always + // renumbered, so they're merged by their position among integer keys. + // A slot missing from some of the unpacked arrays becomes optional. + /** @var array, presentCount: int, anyOptional: bool}> $slots */ + $slots = []; + /** @var list $slotOrder */ + $slotOrder = []; + + foreach ($constantArrays as $constantArrayType) { + $nextIntegerSlot = 0; + foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { + if ($keepStringKeys && $keyType->isString()->yes()) { + $slotKey = 's' . $keyType->getValue(); + $slotKeyType = $keyType; + } else { + $slotKey = 'i' . $nextIntegerSlot; + $slotKeyType = null; + $nextIntegerSlot++; } - } - } - if ($hasStringKey) { - $totalArrays = count($constantArrays); - /** @var array, keyType: ConstantIntegerType|ConstantStringType, presentCount: int, anyOptional: bool}> $mergedKeys */ - $mergedKeys = []; - $keyOrder = []; - - foreach ($constantArrays as $constantArrayType) { - foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { - $keyValue = $keyType->getValue(); - if (!isset($mergedKeys[$keyValue])) { - $mergedKeys[$keyValue] = [ - 'valueTypes' => [], - 'keyType' => $keyType, - 'presentCount' => 0, - 'anyOptional' => false, - ]; - $keyOrder[] = $keyValue; - } - $mergedKeys[$keyValue]['valueTypes'][] = $constantArrayType->getValueTypes()[$i]; - $mergedKeys[$keyValue]['presentCount']++; - if (!$constantArrayType->isOptionalKey($i)) { - continue; - } - - $mergedKeys[$keyValue]['anyOptional'] = true; + if (!isset($slots[$slotKey])) { + $slots[$slotKey] = [ + 'keyType' => $slotKeyType, + 'valueTypes' => [], + 'presentCount' => 0, + 'anyOptional' => false, + ]; + $slotOrder[] = $slotKey; } - } - foreach ($keyOrder as $keyValue) { - $info = $mergedKeys[$keyValue]; - $mergedValueType = TypeCombinator::union(...$info['valueTypes']); - $isOptional = $info['anyOptional'] || $info['presentCount'] < $totalArrays; - $arrayBuilder->setOffsetValueType($info['keyType'], $mergedValueType, $isOptional); - - if (isset($hasOffsetValueTypes[$keyValue])) { - if ($isOptional) { - $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType( - $info['keyType'], - TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType), - ); - } else { - $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); - } - } elseif (!$isOptional) { - $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); - } - } - } else { - $maxLen = 0; - $totalArrays = count($constantArrays); - foreach ($constantArrays as $constantArrayType) { - $len = count($constantArrayType->getKeyTypes()); - if ($len <= $maxLen) { + $slots[$slotKey]['valueTypes'][] = $constantArrayType->getValueTypes()[$i]; + $slots[$slotKey]['presentCount']++; + if (!$constantArrayType->isOptionalKey($i)) { continue; } - $maxLen = $len; + $slots[$slotKey]['anyOptional'] = true; } + } - for ($pos = 0; $pos < $maxLen; $pos++) { - $posValueTypes = []; - $presentCount = 0; - $anyOptional = false; - foreach ($constantArrays as $constantArrayType) { - $keyTypes = $constantArrayType->getKeyTypes(); - if ($pos >= count($keyTypes)) { - continue; - } - - $posValueTypes[] = $constantArrayType->getValueTypes()[$pos]; - $presentCount++; - if (!$constantArrayType->isOptionalKey($pos)) { - continue; - } - - $anyOptional = true; - } + foreach ($slotOrder as $slotKey) { + $slot = $slots[$slotKey]; + $mergedValueType = TypeCombinator::union(...$slot['valueTypes']); + $isOptional = $slot['anyOptional'] || $slot['presentCount'] < $totalArrays; + $slotKeyType = $slot['keyType']; + $arrayBuilder->setOffsetValueType($slotKeyType, $mergedValueType, $isOptional); + + if ($slotKeyType === null) { + continue; + } - $mergedValueType = TypeCombinator::union(...$posValueTypes); - $isOptional = $anyOptional || $presentCount < $totalArrays; - $arrayBuilder->setOffsetValueType(null, $mergedValueType, $isOptional); + $keyValue = $slotKeyType->getValue(); + if (isset($hasOffsetValueTypes[$keyValue])) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType( + $slotKeyType, + $isOptional + ? TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType) + : $mergedValueType, + ); + } elseif (!$isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($slotKeyType, $mergedValueType); } } } else { diff --git a/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php b/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php index a03dbb75c9b..1495bb711d2 100644 --- a/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php +++ b/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php @@ -12,6 +12,19 @@ assertType('array{0: 1, a: 1, b: 2}', $bar); +// integer keys are renumbered even when the unpacked array also has string keys +$mixed = [9, ...['a' => 1, 5], ...[6, 'b' => 2]]; + +assertType('array{0: 9, a: 1, 1: 5, 2: 6, b: 2}', $mixed); + +$mixedOnlyIntegerKeys = [9, ...[5, 6]]; + +assertType('array{9, 5, 6}', $mixedOnlyIntegerKeys); + +$mixedIntegerKeysOverwritten = [9, ...[3 => 'x']]; + +assertType("array{9, 'x'}", $mixedIntegerKeysOverwritten); + /** * @param array $a * @param array $b diff --git a/tests/PHPStan/Analyser/nsrt/bug-14708.php b/tests/PHPStan/Analyser/nsrt/bug-14708.php index 410e85715e8..d6a659fe14c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14708.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14708.php @@ -95,3 +95,19 @@ function testEmptyVsNonEmpty(bool $flag): void { ]; assertType("array{key?: 'value'}", $result); } + +function testStringKeyBranchAndIntegerKeyBranch(bool $flag): void { + // integer keys are renumbered, string keys are kept + $result = [ + 9, + ...($flag ? ['a' => 1] : [5]), + ]; + assertType('array{0: 9, a?: 1, 1?: 5}', $result); +} + +function testMixedKeysInBothBranches(bool $flag): void { + $result = [ + ...($flag ? ['a' => 1, 7] : [5, 'a' => 2]), + ]; + assertType('array{a: 1|2, 0: 5|7}', $result); +} From eae0576e39f9fb1c04b254a7a9bf889393ecd936 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 10 Aug 2026 17:17:44 +0000 Subject: [PATCH 3/4] Decide spread key kind on the key value, not on TrinaryLogic `getKeyTypes()` only ever yields ConstantIntegerType|ConstantStringType, so `isString()` there can never return `maybe` and `$keyType->isString()->yes()` was interchangeable with `!$keyType->isString()->no()` - an equivalent mutant that no test can kill. Branch on `is_string($keyType->getValue())` instead, matching how ConstantArrayType itself tells constant keys apart. Co-Authored-By: Claude Opus 5 --- src/Reflection/InitializerExprTypeResolver.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 0b2c5270491..6e61024fe4b 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -664,8 +664,9 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type foreach ($constantArrays as $constantArrayType) { $nextIntegerSlot = 0; foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { - if ($keepStringKeys && $keyType->isString()->yes()) { - $slotKey = 's' . $keyType->getValue(); + $keyValue = $keyType->getValue(); + if ($keepStringKeys && is_string($keyValue)) { + $slotKey = 's' . $keyValue; $slotKeyType = $keyType; } else { $slotKey = 'i' . $nextIntegerSlot; From 254362d14d26ac6486d33b679adf052fddabff19 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 10 Aug 2026 19:17:58 +0200 Subject: [PATCH 4/4] Revert "Decide spread key kind on the key value, not on TrinaryLogic" This reverts commit eae0576e39f9fb1c04b254a7a9bf889393ecd936. --- src/Reflection/InitializerExprTypeResolver.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 6e61024fe4b..0b2c5270491 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -664,9 +664,8 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type foreach ($constantArrays as $constantArrayType) { $nextIntegerSlot = 0; foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { - $keyValue = $keyType->getValue(); - if ($keepStringKeys && is_string($keyValue)) { - $slotKey = 's' . $keyValue; + if ($keepStringKeys && $keyType->isString()->yes()) { + $slotKey = 's' . $keyType->getValue(); $slotKeyType = $keyType; } else { $slotKey = 'i' . $nextIntegerSlot;