From f13a95b3d19213020df50a6785299fc067d8d6c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 16:55:36 +0000 Subject: [PATCH] Fix phpstan/phpstan#12038: anonymous polymorphic functions not composable Two issues prevented generic callables from composing correctly: 1. inferTemplateTypesOnParametersAcceptor in CallableType/ClosureType eagerly resolved inner callable template types to ErrorType (then mixed) when the outer callable's parameters were themselves TemplateTypes. Now preserves original unresolved inner templates when their names don't collide with outer template names. 2. GenericParametersAcceptorResolver::resolve collapsed all positional arguments into one map entry when PHPDoc callable parameters had empty names (e.g. callable(X, Y): Z). Now assigns unique synthetic names to unnamed parameters. --- .../GenericParametersAcceptorResolver.php | 21 ++++- src/Type/CallableType.php | 46 ++++++++- src/Type/ClosureType.php | 43 ++++++++- tests/PHPStan/Analyser/nsrt/bug-12038.php | 94 +++++++++++++++++++ 4 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12038.php diff --git a/src/Reflection/GenericParametersAcceptorResolver.php b/src/Reflection/GenericParametersAcceptorResolver.php index 27d8cfc11c..7f625de439 100644 --- a/src/Reflection/GenericParametersAcceptorResolver.php +++ b/src/Reflection/GenericParametersAcceptorResolver.php @@ -32,11 +32,23 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $passedArgs = []; $parameters = $parametersAcceptor->getParameters(); + + // Build a name map that handles unnamed parameters (e.g. from PHPDoc callables) + // by assigning unique synthetic names to avoid collisions + $paramNameMap = []; + foreach ($parameters as $idx => $param) { + $name = $param->getName(); + if ($name === '' || isset($paramNameMap[$name])) { + $name = '__param_' . $idx; + } + $paramNameMap[$idx] = $name; + } + $namedArgTypes = []; foreach ($argTypes as $i => $argType) { if (is_int($i)) { if (isset($parameters[$i])) { - $namedArgTypes[$parameters[$i]->getName()] = $argType; + $namedArgTypes[$paramNameMap[$i]] = $argType; continue; } if (count($parameters) > 0) { @@ -56,8 +68,11 @@ public static function resolve(array $argTypes, ParametersAcceptor $parametersAc $namedArgTypes[$i] = $argType; } - foreach ($parameters as $param) { - if (isset($namedArgTypes[$param->getName()])) { + foreach ($parameters as $idx => $param) { + $lookupName = $paramNameMap[$idx] ?? $param->getName(); + if (isset($namedArgTypes[$lookupName])) { + $argType = $namedArgTypes[$lookupName]; + } elseif (isset($namedArgTypes[$param->getName()])) { $argType = $namedArgTypes[$param->getName()]; } elseif ($param->getDefaultValue() !== null) { $argType = $param->getDefaultValue(); diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 9d9cfb65e4..1b39bb8093 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -500,9 +500,49 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap { $parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters()); - $parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); - $args = $parametersAcceptor->getParameters(); - $returnType = $parametersAcceptor->getReturnType(); + $resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); + + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // But only when inner template names don't collide with outer ones, + // to avoid cross-resolution issues. + $useOriginal = false; + if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) { + $hasUnresolved = false; + foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) { + if ($type instanceof ErrorType) { + $hasUnresolved = true; + break; + } + } + if ($hasUnresolved) { + $outerTemplateNames = []; + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + } + foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + $hasCollision = false; + foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) { + if (isset($outerTemplateNames[$name])) { + $hasCollision = true; + break; + } + } + $useOriginal = !$hasCollision; + } + } + + $acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor; + $args = $acceptor->getParameters(); + $returnType = $acceptor->getReturnType(); $typeMap = TemplateTypeMap::createEmpty(); foreach ($this->getParameters() as $i => $param) { diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index dac3a2a07f..f40bff5002 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -596,9 +596,46 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap private function inferTemplateTypesOnParametersAcceptor(ParametersAcceptor $parametersAcceptor): TemplateTypeMap { $parameterTypes = array_map(static fn ($parameter) => $parameter->getType(), $this->getParameters()); - $parametersAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); - $args = $parametersAcceptor->getParameters(); - $returnType = $parametersAcceptor->getReturnType(); + $resolvedAcceptor = ParametersAcceptorSelector::selectFromTypes($parameterTypes, [$parametersAcceptor], false); + + // If the inner callable had template types that couldn't be resolved + // (mapped to ErrorType), use the original unresolved parameters to + // preserve template types through composition (e.g. flip(zip(...))) + // But only when inner template names don't collide with outer ones, + // to avoid cross-resolution issues. + $useOriginal = false; + if ($parametersAcceptor->getTemplateTypeMap()->count() > 0) { + $hasUnresolved = false; + foreach ($resolvedAcceptor->getResolvedTemplateTypeMap()->getTypes() as $type) { + if ($type instanceof ErrorType) { + $hasUnresolved = true; + break; + } + } + if ($hasUnresolved) { + $outerTemplateNames = []; + foreach ($this->getParameters() as $param) { + foreach ($param->getType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + } + foreach ($this->getReturnType()->getReferencedTemplateTypes(TemplateTypeVariance::createInvariant()) as $ref) { + $outerTemplateNames[$ref->getType()->getName()] = true; + } + $hasCollision = false; + foreach ($parametersAcceptor->getTemplateTypeMap()->getTypes() as $name => $type) { + if (isset($outerTemplateNames[$name])) { + $hasCollision = true; + break; + } + } + $useOriginal = !$hasCollision; + } + } + + $acceptor = $useOriginal ? $parametersAcceptor : $resolvedAcceptor; + $args = $acceptor->getParameters(); + $returnType = $acceptor->getReturnType(); $typeMap = TemplateTypeMap::createEmpty(); foreach ($this->getParameters() as $i => $param) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-12038.php b/tests/PHPStan/Analyser/nsrt/bug-12038.php new file mode 100644 index 0000000000..c521833db7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12038.php @@ -0,0 +1,94 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug12038; + +use function PHPStan\Testing\assertType; + +/** + * @template X + * @template Y + * @template Z + * + * @param callable(X, Y): Z $fn + * @return callable(Y, X): Z + */ +function flip(callable $fn): callable +{ + return fn ($y, $x) => $fn($x, $y); +} + +/** + * @template A + * @template B + * + * @param list $fa + * @param list $fb + * @return list + */ +function zip(array $fa, array $fb): array +{ + $length = min(count($fa), count($fb)); + $zipped = []; + + for ($i = 0; $i < $length; $i++) { + $zipped[] = [$fa[$i], $fb[$i]]; + } + + return $zipped; +} + +/** + * @template A + * @template B + * @template C + * + * @param callable(A): B $ab + * @param callable(B): C $bc + * @return callable(A): C + */ +function compose(callable $ab, callable $bc): callable +{ + return fn($a) => $bc($ab($a)); +} + +/** + * @template T + * @param T $a + * @return list + */ +function toList(mixed $a): array +{ + return [$a]; +} + +/** + * @template V + * @param V $a + * @return array{boxed: V} + */ +function box(mixed $a): array +{ + return ['boxed' => $a]; +} + +// flip(zip(...)) should preserve template types +$flipZip = flip(zip(...)); +assertType('callable(list, list): list', $flipZip); + +/** @var list */ +$strings = []; +/** @var list */ +$ints = []; + +assertType('list', $flipZip($strings, $ints)); +assertType('list', $flipZip($ints, $strings)); + +// compose(toList(...), box(...)) should properly unify template types +$composed1 = compose(toList(...), box(...)); +assertType('callable(A): array{boxed: list}', $composed1); + +// compose(box(...), toList(...)) should properly unify template types +$composed2 = compose(box(...), toList(...)); +assertType('callable(A): list', $composed2);