From 9cb363d184da1f89a5b75b6a45b058674b184309 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Tue, 21 Apr 2026 20:07:57 +0000 Subject: [PATCH] Resolve `ConditionalTypeForParameter` children before converting to `ConditionalType` to prevent infinite traversal - In `ResolvedFunctionVariantWithOriginal::resolveConditionalTypesForParameter()`, traverse children (target, if, else) of `ConditionalTypeForParameter` first, then convert to `ConditionalType` and return without further traversal. This prevents entering the subject (the passed argument type), which may contain its own `ConditionalTypeForParameter` with a colliding parameter name (e.g. `$value`), causing infinite recursion. - Applied the same fix to the analogous pattern in `TypeSpecifier::resolveAssertTypes()`. - Triggered by `is_X(is_Y(...))` first-class callable combinations where both functions have conditional return types using the same parameter name `$value`. --- src/Analyser/TypeSpecifier.php | 8 +++- .../ResolvedFunctionVariantWithOriginal.php | 8 +++- tests/PHPStan/Analyser/nsrt/bug-13872.php | 38 +++++++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13872.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 412a3ae77cc..3e50294c94b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1849,8 +1849,12 @@ private function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\Cal if ($type instanceof ConditionalTypeForParameter) { $parameterName = substr($type->getParameterName(), 1); if (array_key_exists($parameterName, $argsMap)) { - $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[$parameterName])); - $type = $type->toConditional($argType); + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); + return $type->toConditional($argType); + } + return $type; } } diff --git a/src/Reflection/ResolvedFunctionVariantWithOriginal.php b/src/Reflection/ResolvedFunctionVariantWithOriginal.php index 21108d658ef..b29a897c45f 100644 --- a/src/Reflection/ResolvedFunctionVariantWithOriginal.php +++ b/src/Reflection/ResolvedFunctionVariantWithOriginal.php @@ -290,7 +290,13 @@ private function resolveConditionalTypesForParameter(Type $type): Type { return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { if ($type instanceof ConditionalTypeForParameter && array_key_exists($type->getParameterName(), $this->passedArgs)) { - $type = $type->toConditional($this->passedArgs[$type->getParameterName()]); + // Traverse children first, then convert — avoids infinite loop when + // the passed argument contains ConditionalTypeForParameter with a colliding parameter name. + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + return $type->toConditional($this->passedArgs[$type->getParameterName()]); + } + return $type; } return $traverse($type); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13872.php b/tests/PHPStan/Analyser/nsrt/bug-13872.php new file mode 100644 index 00000000000..57bf2dadede --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13872.php @@ -0,0 +1,38 @@ +