From 08aa83c5865ffc342468bf704f0d1a892a4682c2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Jul 2026 14:04:05 +0200 Subject: [PATCH 1/2] Add parameter closure type extensions for array_map, array_filter, array_walk, array_find Ported from the resolve-type-rewrite-2 branch. The intrinsic blocks in ParametersAcceptorSelector::selectFromArgs() keep providing the same parameter types (and the rule-facing messages), so analysis output does not change - the extensions express the same knowledge through the public FunctionParameterClosureTypeExtension interface. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- ...rayFilterParameterClosureTypeExtension.php | 62 +++++++++++++++++++ ...ArrayFindParameterClosureTypeExtension.php | 43 +++++++++++++ .../ArrayMapParameterClosureTypeExtension.php | 62 +++++++++++++++++++ ...ArrayWalkParameterClosureTypeExtension.php | 45 ++++++++++++++ 4 files changed, 212 insertions(+) create mode 100644 src/Type/Php/ArrayFilterParameterClosureTypeExtension.php create mode 100644 src/Type/Php/ArrayFindParameterClosureTypeExtension.php create mode 100644 src/Type/Php/ArrayMapParameterClosureTypeExtension.php create mode 100644 src/Type/Php/ArrayWalkParameterClosureTypeExtension.php diff --git a/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php new file mode 100644 index 00000000000..cbad3124f83 --- /dev/null +++ b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_filter' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = null; + if (isset($args[2])) { + $mode = $scope->getType($args[2]->value); + if ($mode instanceof ConstantIntegerType) { + if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { + $parameters = [$this->createParameter('key', $scope->getIterableKeyType($arrayType))]; + } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { + $parameters = [ + $this->createParameter('item', $scope->getIterableValueType($arrayType)), + $this->createParameter('key', $scope->getIterableKeyType($arrayType)), + ]; + } + } + } + + $parameters ??= [$this->createParameter('item', $scope->getIterableValueType($arrayType))]; + + return new ClosureType($parameters, new BooleanType()); + } + + private function createParameter(string $name, Type $type): NativeParameterReflection + { + return new NativeParameterReflection($name, false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/src/Type/Php/ArrayFindParameterClosureTypeExtension.php b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php new file mode 100644 index 00000000000..e99a0e87368 --- /dev/null +++ b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php @@ -0,0 +1,43 @@ +getName(), ['array_find', 'array_find_key', 'array_any', 'array_all'], true) + && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + + return new ClosureType([ + new NativeParameterReflection('value', false, $scope->getIterableValueType($arrayType), PassedByReference::createNo(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ], new BooleanType()); + } + +} diff --git a/src/Type/Php/ArrayMapParameterClosureTypeExtension.php b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php new file mode 100644 index 00000000000..6ae1c8198a0 --- /dev/null +++ b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_map' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $callbackParameters = []; + $argCount = count($args); + for ($i = 1; $i < $argCount; $i++) { + $arg = $args[$i]; + $arrayType = $scope->getType($arg->value); + if ($arg->unpack) { + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) === 0) { + return null; + } + foreach ($constantArrays as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType)); + } + } + } else { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType)); + } + } + + return new ClosureType($callbackParameters, new MixedType()); + } + + private function createParameter(Type $type): NativeParameterReflection + { + return new NativeParameterReflection('item', false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php new file mode 100644 index 00000000000..4ddf25ab94f --- /dev/null +++ b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php @@ -0,0 +1,45 @@ +getName() === 'array_walk' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = [ + new NativeParameterReflection('item', false, $scope->getIterableValueType($arrayType), PassedByReference::createReadsArgument(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ]; + if (isset($args[2])) { + $parameters[] = new NativeParameterReflection('arg', false, $scope->getType($args[2]->value), PassedByReference::createNo(), false, null); + } + + return new ClosureType($parameters, new MixedType()); + } + +} From c1769f81ae427043383634bf48b74027b6026b88 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Jul 2026 15:47:19 +0200 Subject: [PATCH 2/2] Resolve the native flavour of closure parameters through parameter closure type extensions After a parameter closure type extension overrides the phpdoc-side parameter type, invoke the same extension again on the natively-promoted scope and override the native parameter type too. Extensions asking $scope->getType() then see native types, so the closure parameters keep their native precision without the intrinsic blocks in ParametersAcceptorSelector - verified by neutering the array_map block's native side locally: bug-11014 stays green through the extension path alone. MutatingScope::doNotTreatPhpDocTypesAsCertain() narrows its return type to self (covariant) so the natively-promoted scope can be passed where MutatingScope is expected. The existing PregReplaceCallbackClosureTypeExtension now also resolves the native flavour, making the native side of its match shapes precise (preg_replace_callback_shapes.php). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7 --- src/Analyser/MutatingScope.php | 2 +- src/Analyser/NodeScopeResolver.php | 16 ++++++++++++++++ .../nsrt/preg_replace_callback_shapes.php | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e24dc605a84..6a9b1632714 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1281,7 +1281,7 @@ public function getKeepVoidType(Expr $node): Type return $this->getType($clonedNode); } - public function doNotTreatPhpDocTypesAsCertain(): Scope + public function doNotTreatPhpDocTypesAsCertain(): self { return $this->promoteNativeTypes(); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d0ad5114571..49904b64517 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3691,6 +3691,14 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + + // resolve the native flavour through the same extension on the + // natively-promoted scope, so the closure parameters keep + // their native precision too + $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); + if ($overwritingParameterNativeType !== null) { + $parameterNativeType = $overwritingParameterNativeType; + } } } @@ -3766,6 +3774,14 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + + // resolve the native flavour through the same extension on the + // natively-promoted scope, so the closure parameters keep + // their native precision too + $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); + if ($overwritingParameterNativeType !== null) { + $parameterNativeType = $overwritingParameterNativeType; + } } } diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php index b9aa8f54826..9f8c608d471 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php @@ -63,7 +63,7 @@ function bug14904(string $string): void { '/a|(?b)/', function (array $match): string { assertType("array{0: non-empty-string, b: 'b'|null, 1: 'b'|null}", $match); - assertNativeType('array', $match); + assertNativeType("array{0: non-empty-string, b: 'b'|null, 1: 'b'|null}", $match); return ''; }, $string,