From d231b6d81a873c6dc6aa816da7e18f2ac7159e67 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 28 Oct 2026 18:47:39 +0100 Subject: [PATCH 1/3] Add null to array_map() callback parameters of shorter arrays With more than one array, array_map() passes null for the missing indexes of each shorter array. When the size of every array is known, the callback parameter of an array that can be shorter than another one now gets null. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ContextualClosureParameterResolver.php | 26 +++- .../ArrayMapCallbackParameterTypes.php | 70 +++++++++++ src/Reflection/ParametersAcceptorSelector.php | 27 ++++- .../ArrayMapFunctionReturnTypeExtension.php | 12 +- .../ArrayMapParameterClosureTypeExtension.php | 15 ++- tests/PHPStan/Analyser/nsrt/bug-13217.php | 112 ++++++++++++++++++ .../Rules/Variables/NullCoalesceRuleTest.php | 5 + .../Rules/Variables/data/bug-13217.php | 3 + .../ContextualClosureParameterResolver.cpp | 52 +++++++- turbo-ext/src/ParametersAcceptorSelector.cpp | 76 ++++++++---- turbo-ext/src/support.cpp | 1 + turbo-ext/src/support.h | 2 + 12 files changed, 361 insertions(+), 40 deletions(-) create mode 100644 src/Reflection/ArrayMapCallbackParameterTypes.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13217.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-13217.php diff --git a/src/Analyser/ExprHandler/Helper/ContextualClosureParameterResolver.php b/src/Analyser/ExprHandler/Helper/ContextualClosureParameterResolver.php index 504f95b166e..ea8bb455b22 100644 --- a/src/Analyser/ExprHandler/Helper/ContextualClosureParameterResolver.php +++ b/src/Analyser/ExprHandler/Helper/ContextualClosureParameterResolver.php @@ -13,6 +13,7 @@ use PHPStan\Parser\ArrowFunctionArgVisitor; use PHPStan\Parser\ClosureArgVisitor; use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; +use PHPStan\Reflection\ArrayMapCallbackParameterTypes; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; @@ -90,17 +91,38 @@ public function resolve( ); } - $parameters = []; - $nativeParameters = []; + $types = []; + $nativeTypes = []; + $arrayTypes = []; + $nativeArrayTypes = []; + $hasUnpackedArg = false; foreach ($intrinsicArgs as $arg) { + $hasUnpackedArg = $hasUnpackedArg || $arg->unpack; $result = $storage !== null ? $storage->findExpressionResult($arg->value) : null; $type = $result !== null ? $result->getType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope); $nativeType = $result !== null ? $result->getNativeType() : $this->nodeScopeResolver->readScopeStateOrSyntheticType($arg->value, $scope->doNotTreatPhpDocTypesAsCertain()); if ($arrayMapArgs !== null) { + $arrayTypes[] = $type; + $nativeArrayTypes[] = $nativeType; $type = $type->getIterableValueType(); $nativeType = $nativeType->getIterableValueType(); } + $types[] = $type; + $nativeTypes[] = $nativeType; + } + + // an unpacked arg is one parameter here, so its arrays cannot be told apart + if ($arrayMapArgs !== null && !$hasUnpackedArg) { + $types = ArrayMapCallbackParameterTypes::addPaddingNull($types, $arrayTypes); + $nativeTypes = ArrayMapCallbackParameterTypes::addPaddingNull($nativeTypes, $nativeArrayTypes); + } + + $parameters = []; + foreach ($types as $type) { $parameters[] = new DummyParameter('item', $type, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + $nativeParameters = []; + foreach ($nativeTypes as $nativeType) { $nativeParameters[] = new DummyParameter('item', $nativeType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } diff --git a/src/Reflection/ArrayMapCallbackParameterTypes.php b/src/Reflection/ArrayMapCallbackParameterTypes.php new file mode 100644 index 00000000000..4af2907f3a1 --- /dev/null +++ b/src/Reflection/ArrayMapCallbackParameterTypes.php @@ -0,0 +1,70 @@ + $valueTypes + * @param list $arrayTypes the array each value type comes from + * @return list + */ + public static function addPaddingNull(array $valueTypes, array $arrayTypes): array + { + if (count($arrayTypes) < 2) { + return $valueTypes; + } + + $maxSizes = []; + foreach ($arrayTypes as $i => $arrayType) { + $sizes = []; + foreach ($arrayType->getArraySize()->getFiniteTypes() as $sizeType) { + foreach ($sizeType->getConstantScalarValues() as $size) { + if (!is_int($size)) { + continue; + } + + $sizes[] = $size; + } + } + + if ($sizes === []) { + // general arrays can have the same size, but the type system cannot say so + return $valueTypes; + } + + $maxSizes[$i] = max($sizes); + } + + $longestSize = max($maxSizes); + foreach ($valueTypes as $i => $valueType) { + if ($maxSizes[$i] === $longestSize) { + continue; + } + + $valueTypes[$i] = TypeCombinator::addNull($valueType); + } + + return $valueTypes; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 22c137ede82..b4411c63882 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -206,8 +206,10 @@ public static function applyIntrinsicArgOverrides( ) { $arrayMapArgs = $args[0]->value->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); if ($arrayMapArgs !== null) { - $callbackParameters = []; - $nativeCallbackParameters = []; + $callbackValueTypes = []; + $callbackArrayTypes = []; + $nativeCallbackValueTypes = []; + $nativeCallbackArrayTypes = []; foreach ($arrayMapArgs as $arg) { $argType = ($typeGetter)($arg->value); $nativeArgType = ($nativeTypeGetter)($arg->value); @@ -217,7 +219,8 @@ public static function applyIntrinsicArgOverrides( foreach ($constantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackValueTypes[] = ($iterableValueTypeGetter)($valueType); + $callbackArrayTypes[] = $valueType; } } } @@ -226,16 +229,28 @@ public static function applyIntrinsicArgOverrides( foreach ($nativeConstantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($valueType); + $nativeCallbackArrayTypes[] = $valueType; } } } } else { - $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackValueTypes[] = ($iterableValueTypeGetter)($argType); + $callbackArrayTypes[] = $argType; + $nativeCallbackValueTypes[] = ($iterableValueTypeGetter)($nativeArgType); + $nativeCallbackArrayTypes[] = $nativeArgType; } } + $callbackParameters = []; + foreach (ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $callbackArrayTypes) as $valueType) { + $callbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + $nativeCallbackParameters = []; + foreach (ArrayMapCallbackParameterTypes::addPaddingNull($nativeCallbackValueTypes, $nativeCallbackArrayTypes) as $valueType) { + $nativeCallbackParameters[] = new DummyParameter('item', $valueType, optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + $acceptor = $parametersAcceptors[0]; $parameters = $acceptor->getParameters(); if (isset($parameters[0])) { diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index a9766d7ceba..4fcdd30ff37 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -8,6 +8,7 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Parser\ArrayMapArgVisitor; +use PHPStan\Reflection\ArrayMapCallbackParameterTypes; use PHPStan\Reflection\FunctionReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -25,6 +26,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; +use function array_filter; use function array_map; use function array_reduce; use function array_slice; @@ -53,11 +55,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $callableIsNull = $callableType->isNull()->yes(); if ($callableType->isCallable()->yes()) { + $arrayArgs = array_slice($args, 1); + $arrayTypes = array_map(static fn (Node\Arg $arg) => $scope->getType($arg->value), $arrayArgs); + $callbackValueTypes = array_map(static fn (Type $arrayType) => $arrayType->getIterableValueType(), $arrayTypes); + if (count(array_filter($arrayArgs, static fn (Node\Arg $arg) => $arg->unpack)) === 0) { + $callbackValueTypes = ArrayMapCallbackParameterTypes::addPaddingNull($callbackValueTypes, $arrayTypes); + } $valueType = $scope->getType(new FuncCall( $callback, array_map( - static fn (Node\Arg $arg) => new Node\Arg(new TypeExpr($scope->getType($arg->value)->getIterableValueType())), - array_slice($args, 1), + static fn (Type $valueType) => new Node\Arg(new TypeExpr($valueType)), + $callbackValueTypes, ), )); } elseif ($callableIsNull) { diff --git a/src/Type/Php/ArrayMapParameterClosureTypeExtension.php b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php index 6ae1c8198a0..0b492d114ae 100644 --- a/src/Type/Php/ArrayMapParameterClosureTypeExtension.php +++ b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ArrayMapCallbackParameterTypes; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; @@ -31,7 +32,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } - $callbackParameters = []; + $valueTypes = []; + $arrayTypes = []; $argCount = count($args); for ($i = 1; $i < $argCount; $i++) { $arg = $args[$i]; @@ -43,14 +45,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } foreach ($constantArrays as $constantArray) { foreach ($constantArray->getValueTypes() as $valueType) { - $callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType)); + $valueTypes[] = $scope->getIterableValueType($valueType); + $arrayTypes[] = $valueType; } } } else { - $callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType)); + $valueTypes[] = $scope->getIterableValueType($arrayType); + $arrayTypes[] = $arrayType; } } + $callbackParameters = []; + foreach (ArrayMapCallbackParameterTypes::addPaddingNull($valueTypes, $arrayTypes) as $valueType) { + $callbackParameters[] = $this->createParameter($valueType); + } + return new ClosureType($callbackParameters, new MixedType()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-13217.php b/tests/PHPStan/Analyser/nsrt/bug-13217.php new file mode 100644 index 00000000000..f5ab5b3c4ea --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13217.php @@ -0,0 +1,112 @@ + assertType('3|null', $b), [1, 2], [3]); + + assertType('non-empty-list<1|3>', array_map(fn ($a, $b) => $b ?? 1, [1, 2], [3])); + } + + public function shorterFirstArray(): void + { + array_map(function ($a, $b) { + assertType('1|null', $a); + assertType('3|4', $b); + }, [1], [3, 4]); + } + + public function sameSize(): void + { + array_map(function ($a, $b) { + assertType('1|2', $a); + assertType('3|4', $b); + }, [1, 2], [3, 4]); + } + + /** + * @param array{1, 2}|array{1} $maybe + */ + public function sizeRange(array $maybe): void + { + array_map(function ($a, $b) { + assertType('1|2|null', $a); + assertType('3|4|5', $b); + }, $maybe, [3, 4, 5]); + + array_map(function ($a, $b) { + assertType('1|2', $a); + assertType('3|null', $b); + }, $maybe, [3]); + + array_map(function ($a, $b) { + assertType('1|2', $a); + assertType('1|2', $b); + }, $maybe, $maybe); + } + + public function unpacked(): void + { + // the closure gets one parameter per arg here, not one per unpacked array + array_map(function ($a, $b) { + assertType('1|2|3', $a); + assertType('array{4, 5, 6}', $b); + }, [1, 2, 3], ...[[4, 5, 6]]); + } + + public function singleArray(): void + { + array_map(function ($a) { + assertType('1|2', $a); + }, [1, 2]); + } + + /** + * @param list $ints + * @param list $strings + */ + public function generalArrays(array $ints, array $strings): void + { + array_map(function ($a, $b) { + assertType('int', $a); + assertType('string', $b); + }, $ints, $strings); + + array_map(function ($a) { + assertType('int', $a); + }, $ints); + + array_map(function ($a, $b) { + assertType('int', $a); + assertType('3', $b); + }, $ints, [3]); + } + + public function returnType(): void + { + assertType('non-empty-list<3|null>', array_map('Bug13217\\pick', [1, 2], [3])); + } + +} + +/** + * @template T + * @param mixed $a + * @param T $b + * @return T + */ +function pick($a, $b) +{ + return $b; +} diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index c2eb6a030a5..05ba7627ce9 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -706,6 +706,11 @@ public function testInTrait(): void ]); } + public function testBug13217(): void + { + $this->analyse([__DIR__ . '/data/bug-13217.php'], []); + } + public function testBug12780(): void { $this->analyse([__DIR__ . '/data/bug-12780.php'], []); diff --git a/tests/PHPStan/Rules/Variables/data/bug-13217.php b/tests/PHPStan/Rules/Variables/data/bug-13217.php new file mode 100644 index 00000000000..56e9ff422b4 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-13217.php @@ -0,0 +1,3 @@ + $bValue ?? 1, [1, 2], [3]); diff --git a/turbo-ext/src/ContextualClosureParameterResolver.cpp b/turbo-ext/src/ContextualClosureParameterResolver.cpp index c5abcd0eed3..3d22cd29fba 100644 --- a/turbo-ext/src/ContextualClosureParameterResolver.cpp +++ b/turbo-ext/src/ContextualClosureParameterResolver.cpp @@ -48,6 +48,17 @@ void isCallableYesBody(zval *captures, uint32_t argc, zval *argv, zval *return_v ZVAL_BOOL(return_value, isCallable == PT_TRI_YES); } +/* ArrayMapCallbackParameterTypes::addPaddingNull($valueTypes, $arrayTypes) */ +pt_method_site pt_ccpr_add_padding_null_site; +/* $arg->unpack */ +pt_property_site pt_ccpr_arg_unpack_site; + +zv::Val addPaddingNull(zval *valueTypes, zval *arrayTypes) +{ + zv::Args argv{valueTypes, arrayTypes}; + return pt_call_static_cached(pt_ccpr_add_padding_null_site, PT_CLASS_ARRAY_MAP_CALLBACK_PARAMETER_TYPES, PT_LC("addpaddingnull"), 2, argv); +} + } // namespace namespace phpstanturbo { @@ -142,14 +153,22 @@ class ContextualClosureParameterResolver return true; } uint32_t count = zend_hash_num_elements(Z_ARRVAL_P(argsHold.raw())); - zv::Arr parameterList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); - zv::Arr nativeParameterList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); + zv::Arr typeList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); + zv::Arr nativeTypeList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); + zv::Arr arrayTypeList = zv::Arr::empty(); + zv::Arr nativeArrayTypeList = zv::Arr::empty(); zval *nodeScopeResolver = OBJ_PROP_NUM(self, slots::nodeScopeResolver); zend_object *no = pt_passed_by_reference_create_no(); if (UNEXPECTED(no == NULL)) return false; zval noZv; ZVAL_OBJ(&noZv, no); + bool hasUnpackedArg = false; for (zv::ArrayEntry entry : zv::ArrRef(argsHold.raw())) { + if (!hasUnpackedArg) { + zval *unpack = ptclosure::prop(pt_ccpr_arg_unpack_site, entry.value().deref().raw(), PT_LC("unpack")); + if (UNEXPECTED(unpack == NULL)) return false; + hasUnpackedArg = zend_is_true(unpack); + } zval *value = ptclosure::prop(ptclosure::argValueSite, entry.value().deref().raw(), PT_LC("value")); if (UNEXPECTED(value == NULL)) return false; zv::Val valueHold = zv::Val::copyOf(zv::Ref(value)); @@ -174,15 +193,40 @@ class ContextualClosureParameterResolver zend_throw_error(NULL, "Call to a member function getIterableValueType() on %s", zend_zval_value_name(type.ref().isObject() ? nativeType.raw() : type.raw())); return false; } + arrayTypeList.push(type.ref()); + nativeArrayTypeList.push(nativeType.ref()); type = pt_type_op(Z_OBJ_P(type.raw()), PT_OP_GET_ITERABLE_VALUE_TYPE, 0, NULL); if (UNEXPECTED(type.isUndef())) return false; nativeType = pt_type_op(Z_OBJ_P(nativeType.raw()), PT_OP_GET_ITERABLE_VALUE_TYPE, 0, NULL); if (UNEXPECTED(nativeType.isUndef())) return false; } - zv::Val parameter = pt_dummy_parameter_new(pt_ccpr_item, type.raw(), false, &noZv, false, NULL); + typeList.push(std::move(type)); + nativeTypeList.push(std::move(nativeType)); + } + + zv::Val types = zv::Val(std::move(typeList)); + zv::Val nativeTypes = zv::Val(std::move(nativeTypeList)); + // an unpacked arg is one parameter here, so its arrays cannot be told apart + if (arrayMapArgs != NULL && !hasUnpackedArg) { + types = addPaddingNull(types.raw(), arrayTypeList.raw()); + if (UNEXPECTED(types.isUndef())) return false; + nativeTypes = addPaddingNull(nativeTypes.raw(), nativeArrayTypeList.raw()); + if (UNEXPECTED(nativeTypes.isUndef())) return false; + } + if (UNEXPECTED(Z_TYPE_P(types.raw()) != IS_ARRAY || Z_TYPE_P(nativeTypes.raw()) != IS_ARRAY)) { + zend_throw_error(NULL, "ArrayMapCallbackParameterTypes::addPaddingNull() did not return an array"); + return false; + } + + zv::Arr parameterList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); + for (zv::ArrayEntry entry : zv::ArrRef(types.raw())) { + zv::Val parameter = pt_dummy_parameter_new(pt_ccpr_item, entry.value().deref().raw(), false, &noZv, false, NULL); if (UNEXPECTED(parameter.isUndef())) return false; parameterList.push(std::move(parameter)); - zv::Val nativeParameter = pt_dummy_parameter_new(pt_ccpr_item, nativeType.raw(), false, &noZv, false, NULL); + } + zv::Arr nativeParameterList = count > 0 ? zv::Arr::create(count) : zv::Arr::empty(); + for (zv::ArrayEntry entry : zv::ArrRef(nativeTypes.raw())) { + zv::Val nativeParameter = pt_dummy_parameter_new(pt_ccpr_item, entry.value().deref().raw(), false, &noZv, false, NULL); if (UNEXPECTED(nativeParameter.isUndef())) return false; nativeParameterList.push(std::move(nativeParameter)); } diff --git a/turbo-ext/src/ParametersAcceptorSelector.cpp b/turbo-ext/src/ParametersAcceptorSelector.cpp index c1869f628bd..3f2363e0721 100644 --- a/turbo-ext/src/ParametersAcceptorSelector.cpp +++ b/turbo-ext/src/ParametersAcceptorSelector.cpp @@ -340,6 +340,15 @@ zv::Val genericResolve(zval *types, zval *acceptor) return pt_call_static_cached(pt_pas_generic_resolve_site, PT_CLASS_GENERIC_PARAMETERS_ACCEPTOR_RESOLVER, PT_LC("resolve"), 2, argv); } +/* ArrayMapCallbackParameterTypes::addPaddingNull($valueTypes, $arrayTypes) */ +pt_method_site pt_pas_add_padding_null_site; + +zv::Val addPaddingNull(zval *valueTypes, zval *arrayTypes) +{ + zv::Args argv{valueTypes, arrayTypes}; + return pt_call_static_cached(pt_pas_add_padding_null_site, PT_CLASS_ARRAY_MAP_CALLBACK_PARAMETER_TYPES, PT_LC("addpaddingnull"), 2, argv); +} + /* }}} */ } // namespace @@ -1978,14 +1987,15 @@ class ParametersAcceptorSelector return !parameters.isUndef(); } - /* the array_map() callback parameters of one arg type flavour; false = - * pending exception */ - static bool arrayMapParameters(zv::Arr &callbackParameters, zval *argType, bool unpack, const Getters &getters) + /* the array_map() callback value types of one arg type flavour, with the + * array each comes from; false = pending exception */ + static bool arrayMapValueTypes(zv::Arr &valueTypes, zv::Arr &arrayTypes, zval *argType, bool unpack, const Getters &getters) { if (!unpack) { - zv::Val parameter = dummyParameter(pt_pas_item, getters.iterableValueType(argType)); - if (UNEXPECTED(parameter.isUndef())) return false; - callbackParameters.push(std::move(parameter)); + zv::Val valueType = getters.iterableValueType(argType); + if (UNEXPECTED(valueType.isUndef())) return false; + valueTypes.push(std::move(valueType)); + arrayTypes.push(zv::Ref(argType)); return true; } if (UNEXPECTED(!requireObject(argType, "getConstantArrays"))) return false; @@ -1994,22 +2004,41 @@ class ParametersAcceptorSelector for (zv::ArrayEntry entry : zv::ArrRef(constantArrays.raw())) { zval *constantArray = entry.value().deref().raw(); if (UNEXPECTED(!requireObject(constantArray, "getValueTypes"))) return false; - zv::Val valueTypes = pt_type_op(Z_OBJ_P(constantArray), PT_OP_GET_VALUE_TYPES, 0, NULL); - if (UNEXPECTED(valueTypes.isUndef() || !requireArray(valueTypes.raw(), "foreach() argument"))) return false; - for (zv::ArrayEntry valueEntry : zv::ArrRef(valueTypes.raw())) { - zv::Val parameter = dummyParameter(pt_pas_item, getters.iterableValueType(valueEntry.value().deref().raw())); - if (UNEXPECTED(parameter.isUndef())) return false; - callbackParameters.push(std::move(parameter)); + zv::Val constantValueTypes = pt_type_op(Z_OBJ_P(constantArray), PT_OP_GET_VALUE_TYPES, 0, NULL); + if (UNEXPECTED(constantValueTypes.isUndef() || !requireArray(constantValueTypes.raw(), "foreach() argument"))) return false; + for (zv::ArrayEntry valueEntry : zv::ArrRef(constantValueTypes.raw())) { + zval *arrayType = valueEntry.value().deref().raw(); + zv::Val valueType = getters.iterableValueType(arrayType); + if (UNEXPECTED(valueType.isUndef())) return false; + valueTypes.push(std::move(valueType)); + arrayTypes.push(zv::Ref(arrayType)); } } return true; } + /* the DummyParameters over ArrayMapCallbackParameterTypes::addPaddingNull(); + * UNDEF = pending exception */ + static zv::Val arrayMapParameters(zv::Arr &valueTypes, zv::Arr &arrayTypes) + { + zv::Val paddedValueTypes = addPaddingNull(valueTypes.raw(), arrayTypes.raw()); + if (UNEXPECTED(paddedValueTypes.isUndef() || !requireArray(paddedValueTypes.raw(), "foreach() argument"))) return zv::Val(); + zv::Arr callbackParameters = zv::Arr::empty(); + for (zv::ArrayEntry entry : zv::ArrRef(paddedValueTypes.raw())) { + zv::Val parameter = dummyParameter(pt_pas_item, zv::Val::copyOf(entry.value().deref())); + if (UNEXPECTED(parameter.isUndef())) return zv::Val(); + callbackParameters.push(std::move(parameter)); + } + return zv::Val(std::move(callbackParameters)); + } + /* the ArrayMapArgVisitor override; false = pending exception */ static bool applyArrayMap(zv::Val ¶metersAcceptors, zval *arrayMapArgs, const Getters &getters) { - zv::Arr callbackParameters = zv::Arr::empty(); - zv::Arr nativeCallbackParameters = zv::Arr::empty(); + zv::Arr callbackValueTypes = zv::Arr::empty(); + zv::Arr callbackArrayTypes = zv::Arr::empty(); + zv::Arr nativeCallbackValueTypes = zv::Arr::empty(); + zv::Arr nativeCallbackArrayTypes = zv::Arr::empty(); if (UNEXPECTED(!requireArray(arrayMapArgs, "foreach() argument"))) return false; for (zv::ArrayEntry entry : zv::ArrRef(arrayMapArgs)) { zval *arg = entry.value().deref().raw(); @@ -2023,22 +2052,23 @@ class ParametersAcceptorSelector zv::Val unpackHold; zval *unpack = readProperty(pt_pas_arg_unpack_site, arg, PT_LC("unpack"), unpackHold); if (UNEXPECTED(unpack == NULL)) return false; - if (zend_is_true(unpack)) { - if (UNEXPECTED(!arrayMapParameters(callbackParameters, argType.raw(), true, getters))) return false; - if (UNEXPECTED(!arrayMapParameters(nativeCallbackParameters, nativeArgType.raw(), true, getters))) return false; - } else { - if (UNEXPECTED(!arrayMapParameters(callbackParameters, argType.raw(), false, getters))) return false; - if (UNEXPECTED(!arrayMapParameters(nativeCallbackParameters, nativeArgType.raw(), false, getters))) return false; - } + bool isUnpack = zend_is_true(unpack); + if (UNEXPECTED(!arrayMapValueTypes(callbackValueTypes, callbackArrayTypes, argType.raw(), isUnpack, getters))) return false; + if (UNEXPECTED(!arrayMapValueTypes(nativeCallbackValueTypes, nativeCallbackArrayTypes, nativeArgType.raw(), isUnpack, getters))) return false; } + zv::Val callbackParameters = arrayMapParameters(callbackValueTypes, callbackArrayTypes); + if (UNEXPECTED(callbackParameters.isUndef())) return false; + zv::Val nativeCallbackParameters = arrayMapParameters(nativeCallbackValueTypes, nativeCallbackArrayTypes); + if (UNEXPECTED(nativeCallbackParameters.isUndef())) return false; + zv::Val acceptor, parameters; if (UNEXPECTED(!firstAcceptor(parametersAcceptors, acceptor, parameters))) return false; zval *firstParameter = issetIndex(Z_ARRVAL_P(parameters.raw()), 0); if (firstParameter == NULL) return true; - zv::Val callable = orNull(callableType(zv::Val(std::move(callbackParameters)), pt_type_new_mixed_type())); + zv::Val callable = orNull(callableType(std::move(callbackParameters), pt_type_new_mixed_type())); if (UNEXPECTED(callable.isUndef())) return false; - zv::Val nativeCallable = orNull(callableType(zv::Val(std::move(nativeCallbackParameters)), pt_type_new_mixed_type())); + zv::Val nativeCallable = orNull(callableType(std::move(nativeCallbackParameters), pt_type_new_mixed_type())); if (UNEXPECTED(nativeCallable.isUndef())) return false; zv::Val replaced = replacedParameterAcceptors(acceptor.raw(), parameters, 0, overrideParameterType(firstParameter, callable.raw(), nativeCallable.raw()), true); if (UNEXPECTED(replaced.isUndef())) return false; diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index e34c9b87288..8dc2e0bdad1 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -415,6 +415,7 @@ static const pt_class_template pt_class_templates[PT_CLASS_COUNT] = { /* PT_CLASS_NATIVE_PHP_TYPE_NODE_HELPER */ {"nativePhpTypeNodeHelper", "PHPStan\\Type\\NativePhpTypeNodeHelper"}, /* PT_CLASS_FULLY_QUALIFIED_NAME_NODE */ {"fullyQualifiedNameNode", "PHPStan\\PhpDocParser\\Ast\\Type\\FullyQualifiedNameNode"}, /* PT_CLASS_TYPE_NODE_DESCRIBER */ {"typeNodeDescriber", "PHPStan\\Type\\TypeNodeDescriber"}, + /* PT_CLASS_ARRAY_MAP_CALLBACK_PARAMETER_TYPES */ {"arrayMapCallbackParameterTypes", "PHPStan\\Reflection\\ArrayMapCallbackParameterTypes"}, }; zend_class_entry *pt_class(int idx) diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 6c0e26f9c3a..ed173b1f57a 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -519,6 +519,8 @@ enum { /* the class names of Type::toPhpDocNode() and how describe() prints them */ PT_CLASS_FULLY_QUALIFIED_NAME_NODE, PT_CLASS_TYPE_NODE_DESCRIBER, + /* the null array_map() passes for the shorter arrays */ + PT_CLASS_ARRAY_MAP_CALLBACK_PARAMETER_TYPES, PT_CLASS_COUNT }; From 6b00283910dfa680c9e9b95b12bc6c4751d68970 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Fri, 30 Oct 2026 09:23:49 +0100 Subject: [PATCH 2/3] Do not assert the type of an unpacked array_map() arg The test asserted array{4, 5, 6} for the parameter of an unpacked arg. At runtime the parameter gets 4, 5 and 6. PHPStan already inferred this wrong type before this change, so the test must not lock it in. Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/PHPStan/Analyser/nsrt/bug-13217.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13217.php b/tests/PHPStan/Analyser/nsrt/bug-13217.php index f5ab5b3c4ea..6a04ae65b2b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13217.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13217.php @@ -56,15 +56,6 @@ public function sizeRange(array $maybe): void }, $maybe, $maybe); } - public function unpacked(): void - { - // the closure gets one parameter per arg here, not one per unpacked array - array_map(function ($a, $b) { - assertType('1|2|3', $a); - assertType('array{4, 5, 6}', $b); - }, [1, 2, 3], ...[[4, 5, 6]]); - } - public function singleArray(): void { array_map(function ($a) { From 87c6971d18dee9514fdb8c3c42fe3e046b8111d6 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sat, 31 Oct 2026 13:45:37 +0100 Subject: [PATCH 3/3] Assert the native types of array_map() callback parameters Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/PHPStan/Analyser/nsrt/bug-13217.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13217.php b/tests/PHPStan/Analyser/nsrt/bug-13217.php index 6a04ae65b2b..e7ea5277de1 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13217.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13217.php @@ -2,6 +2,7 @@ namespace Bug13217; +use function PHPStan\Testing\assertNativeType; use function PHPStan\Testing\assertType; class Foo @@ -12,6 +13,8 @@ public function shorterSecondArray(): void array_map(function ($a, $b) { assertType('1|2', $a); assertType('3|null', $b); + assertNativeType('1|2', $a); + assertNativeType('3|null', $b); }, [1, 2], [3]); array_map(fn ($a, $b) => assertType('3|null', $b), [1, 2], [3]); @@ -24,6 +27,8 @@ public function shorterFirstArray(): void array_map(function ($a, $b) { assertType('1|null', $a); assertType('3|4', $b); + assertNativeType('1|null', $a); + assertNativeType('3|4', $b); }, [1], [3, 4]); } @@ -43,6 +48,8 @@ public function sizeRange(array $maybe): void array_map(function ($a, $b) { assertType('1|2|null', $a); assertType('3|4|5', $b); + assertNativeType('mixed', $a); + assertNativeType('3|4|5', $b); }, $maybe, [3, 4, 5]); array_map(function ($a, $b) {