From 0878124fe0846cabbd05cf71629cccccd8e1a0cb Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:41:57 +0000 Subject: [PATCH] Correctly mark all unpacked constant array items as optional in `array_merge`/`array_replace` - Fix `$optionalArgTypesOffset` calculation in both `ArrayMergeFunctionDynamicReturnTypeExtension` and `ArrayReplaceFunctionReturnTypeExtension`: track start index before adding items to `$argTypes` instead of computing a wrong offset from `count($argTypes) - 1` after adding. The old formula only marked the last item as optional when unpacking a multi-element constant array union with an empty variant, leaving earlier items incorrectly marked as required. - In the allConstant code path, use `$optionalArgTypes` to mark keys from optional args as optional in `ConstantArrayTypeBuilder`, so the result correctly allows an empty array. - In the non-constant code path, skip optional args when building `$offsetTypes` so that `HasOffsetType`/`HasOffsetValueType` accessory types are not added for keys that may not exist. - Same fix applied to `ArrayReplaceFunctionReturnTypeExtension` which had identical code. --- ...ergeFunctionDynamicReturnTypeExtension.php | 21 ++++-- ...rrayReplaceFunctionReturnTypeExtension.php | 21 ++++-- tests/PHPStan/Analyser/nsrt/bug-14526.php | 73 +++++++++++++++++++ 3 files changed, 99 insertions(+), 16 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14526.php diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 1283fa610a2..5ae8cbc48b9 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -23,7 +23,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; -use function array_keys; use function count; use function in_array; use function is_int; @@ -51,6 +50,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $argType = $scope->getType($arg->value); if ($arg->unpack) { + $startIndex = count($argTypes); + if ($argType->isConstantArray()->yes()) { foreach ($argType->getConstantArrays() as $constantArray) { foreach ($constantArray->getValueTypes() as $valueType) { @@ -62,10 +63,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if (!$argType->isIterableAtLeastOnce()->yes()) { - // unpacked params can be empty, making them optional - $optionalArgTypesOffset = count($argTypes) - 1; - foreach (array_keys($argTypes) as $key) { - $optionalArgTypes[] = $optionalArgTypesOffset + $key; + for ($i = $startIndex; $i < count($argTypes); $i++) { + $optionalArgTypes[] = $i; } } } else { @@ -80,7 +79,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($argTypes as $argType) { + foreach ($argTypes as $argIndex => $argType) { + $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); + /** @var array $keyTypes */ $keyTypes = []; foreach ($argType->getConstantArrays() as $constantArray) { @@ -93,7 +94,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $newArrayBuilder->setOffsetValueType( $keyType instanceof ConstantIntegerType ? null : $keyType, $argType->getOffsetValueType($keyType), - !$argType->hasOffsetValueType($keyType)->yes(), + $isOptionalArg || !$argType->hasOffsetValueType($keyType)->yes(), ); } } @@ -102,7 +103,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetTypes = []; - foreach ($argTypes as $argType) { + foreach ($argTypes as $argIndex => $argType) { + if (in_array($argIndex, $optionalArgTypes, true)) { + continue; + } + $constArrays = $argType->getConstantArrays(); if ($constArrays !== []) { foreach ($constArrays as $constantArray) { diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 422aa9e2d73..c53f0929d5d 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -22,7 +22,6 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; -use function array_keys; use function count; use function in_array; use function is_string; @@ -51,6 +50,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $argType = $scope->getType($arg->value); if ($arg->unpack) { + $startIndex = count($argTypes); + if ($argType->isConstantArray()->yes()) { foreach ($argType->getConstantArrays() as $constantArray) { foreach ($constantArray->getValueTypes() as $valueType) { @@ -62,10 +63,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if (!$argType->isIterableAtLeastOnce()->yes()) { - // unpacked params can be empty, making them optional - $optionalArgTypesOffset = count($argTypes) - 1; - foreach (array_keys($argTypes) as $key) { - $optionalArgTypes[] = $optionalArgTypesOffset + $key; + for ($i = $startIndex; $i < count($argTypes); $i++) { + $optionalArgTypes[] = $i; } } } else { @@ -81,7 +80,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($argTypes as $argType) { + foreach ($argTypes as $argIndex => $argType) { + $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); + /** @var array $keyTypes */ $keyTypes = []; foreach ($argType->getConstantArrays() as $constantArray) { @@ -94,7 +95,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $newArrayBuilder->setOffsetValueType( $keyType, $argType->getOffsetValueType($keyType), - !$argType->hasOffsetValueType($keyType)->yes(), + $isOptionalArg || !$argType->hasOffsetValueType($keyType)->yes(), ); } } @@ -103,7 +104,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetTypes = []; - foreach ($argTypes as $argType) { + foreach ($argTypes as $argIndex => $argType) { + if (in_array($argIndex, $optionalArgTypes, true)) { + continue; + } + $constArrays = $argType->getConstantArrays(); if ($constArrays !== []) { foreach ($constArrays as $constantArray) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14526.php b/tests/PHPStan/Analyser/nsrt/bug-14526.php new file mode 100644 index 00000000000..baeddf265b4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14526.php @@ -0,0 +1,73 @@ +}|array{} $values + */ +function testMergeUnpackUnionWithEmpty(array $values): void +{ + $result = array_merge(...$values); + assertType('array', $result); +} + +/** + * @param array{non-empty-array, array}|array{} $values + */ +function testMergeUnpackUnionNonEmptyFirstWithEmpty(array $values): void +{ + $result = array_merge(...$values); + assertType('array', $result); +} + +/** + * @param array{non-empty-array}|array{} $values + */ +function testMergeUnpackUnionSingleWithEmpty(array $values): void +{ + $result = array_merge(...$values); + assertType('array', $result); +} + +function testMergeUnpackConstantUnionWithEmpty(): void +{ + $values = rand(0, 1) ? [['a' => 1], ['b' => 2]] : []; + $result = array_merge(...$values); + assertType('array{a?: 1, b?: 2}', $result); +} + +function testMergeUnpackConstantUnionWithEmptyThreeElements(): void +{ + $values = rand(0, 1) ? [['a' => 1], ['b' => 2], ['c' => 3]] : []; + $result = array_merge(...$values); + assertType('array{a?: 1, b?: 2, c?: 3}', $result); +} + +/** + * @param array{array{foo: int}, array}|array{} $values + */ +function testReplaceUnpackUnionWithEmpty(array $values): void +{ + $result = array_replace(...$values); + assertType('array', $result); +} + +/** + * @param array{non-empty-array, array}|array{} $values + */ +function testReplaceUnpackUnionNonEmptyFirstWithEmpty(array $values): void +{ + $result = array_replace(...$values); + assertType('array', $result); +} + +function testReplaceUnpackConstantUnionWithEmpty(): void +{ + $values = rand(0, 1) ? [['a' => 1], ['b' => 2]] : []; + $result = array_replace(...$values); + assertType('array{a?: 1, b?: 2}', $result); +}