From 8183e6873b03f1f3f1c0c36fc3869f3532d93c5a Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sat, 4 Jul 2026 06:59:42 +0000 Subject: [PATCH 1/2] Apply FunctionParameterClosureTypeExtension override to the in-function-call stack so the closure's inferred return type matches its analysed body - `ClosureTypeResolver` infers a closure's return type by re-deriving the callable parameter types from the parameter on top of `inFunctionCallsStack`. That parameter was the plain declared callback parameter (e.g. `callable(array): string` from the function map), even when a `*ParameterClosureTypeExtension` had overridden it (e.g. `preg_replace_callback` narrowing `$matches` to the regex shape). The body was analysed with the overridden type but the return type was inferred with the original, so branches could be considered dead during return-type inference and reachable during the actual walk, producing false `Anonymous function should return X but returns Y` errors. - Add `MutatingScope::replaceInFunctionCallStackParameterType()` which replaces the type of the parameter on top of the in-function-call stack. - In `NodeScopeResolver`, after a closure/arrow-function parameter type is overridden by a parameter closure type extension, update the scope passed to `processClosureNode`/`processArrowFunctionNode` so return-type inference reads the overridden type. This covers function, method and static-method parameter closure type extensions (all three go through the same argument-processing loop). --- src/Analyser/MutatingScope.php | 51 +++++++ src/Analyser/NodeScopeResolver.php | 2 + ...nTypeParameterClosureExtensionRuleTest.php | 44 ++++++ .../Functions/ClosureReturnTypeRuleTest.php | 5 + .../Rules/Functions/data/bug-14914.php | 20 +++ ...turn-type-parameter-closure-extension.neon | 15 ++ ...eturn-type-parameter-closure-extension.php | 130 ++++++++++++++++++ 7 files changed, 267 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-14914.php create mode 100644 tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon create mode 100644 tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a8d0d796c7b..a0cdb78baa2 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -57,6 +57,7 @@ use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; @@ -1398,6 +1399,56 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, return $functionScope; } + /** + * Overrides the type of the parameter on top of the in-function-call stack. + * + * Used when a FunctionParameterClosureTypeExtension changes the type of a + * closure/callable parameter, so that the closure's return type inferred via + * ClosureTypeResolver (which reads the parameter type off this stack) matches + * the parameter type the closure body is actually analysed with. + */ + public function replaceInFunctionCallStackParameterType(Type $type): self + { + if (count($this->inFunctionCallsStack) === 0) { + return $this; + } + + $stack = $this->inFunctionCallsStack; + $lastIndex = count($stack) - 1; + [$reflection, $parameter] = $stack[$lastIndex]; + if ($parameter === null) { + return $this; + } + + $stack[$lastIndex] = [$reflection, new NativeParameterReflection( + $parameter->getName(), + $parameter->isOptional(), + $type, + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + )]; + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + public function popInFunctionCall(): self { $stack = $this->inFunctionCallsStack; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 07704c9fe7c..ffac938a060 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3645,6 +3645,7 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + $scopeToPass = $scopeToPass->replaceInFunctionCallStackParameterType($overwritingParameterType); } } @@ -3711,6 +3712,7 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + $scopeToPass = $scopeToPass->replaceInFunctionCallStackParameterType($overwritingParameterType); } } diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php new file mode 100644 index 00000000000..a18cf7e223d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php @@ -0,0 +1,44 @@ + + */ +class ClosureReturnTypeParameterClosureExtensionRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ClosureReturnTypeRule(new FunctionReturnTypeCheck( + new RuleLevelHelper( + self::createReflectionProvider(), + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + )); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/closure-return-type-parameter-closure-extension.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/closure-return-type-parameter-closure-extension.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php index c363c194d36..3a9832d5931 100644 --- a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php @@ -149,4 +149,9 @@ public function testBug13964(): void $this->analyse([__DIR__ . '/data/bug-13964.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php new file mode 100644 index 00000000000..e6069300620 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -0,0 +1,20 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14914; + +function doFoo(): void +{ + preg_replace_callback( + '/a|(?b)/', + function (array $match): string { + if ($match['b'] !== null) { + return 'aa'; + } + return 'possible?'; + }, + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} diff --git a/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon new file mode 100644 index 00000000000..4ba3971320d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon @@ -0,0 +1,15 @@ +services: + - + class: ClosureReturnTypeParameterClosureExtension\FunctionParameterClosureTypeExtension + tags: + - phpstan.functionParameterClosureTypeExtension + + - + class: ClosureReturnTypeParameterClosureExtension\MethodParameterClosureTypeExtension + tags: + - phpstan.methodParameterClosureTypeExtension + + - + class: ClosureReturnTypeParameterClosureExtension\StaticMethodParameterClosureTypeExtension + tags: + - phpstan.staticMethodParameterClosureTypeExtension diff --git a/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php new file mode 100644 index 00000000000..b91be447f4f --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php @@ -0,0 +1,130 @@ +setOffsetValueType(new ConstantStringType('k'), TypeCombinator::union(new StringType(), new NullType())); + + return new ClosureType( + [ + new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $builder->getArray(), $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), + ], + new StringType(), + ); +} + +class FunctionParameterClosureTypeExtension implements \PHPStan\Type\FunctionParameterClosureTypeExtension +{ + + public function isFunctionSupported(FunctionReflection $functionReflection, ParameterReflection $parameter): bool + { + return $functionReflection->getName() === 'ClosureReturnTypeParameterClosureExtension\functionWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class MethodParameterClosureTypeExtension implements \PHPStan\Type\MethodParameterClosureTypeExtension +{ + + public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'methodWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class StaticMethodParameterClosureTypeExtension implements \PHPStan\Type\StaticMethodParameterClosureTypeExtension +{ + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'staticMethodWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class Foo +{ + + /** @param Closure(array{k: string}): string $callback */ + public function methodWithClosure(Closure $callback): void + { + } + + /** @param Closure(array{k: string}): string $callback */ + public static function staticMethodWithClosure(Closure $callback): void + { + } + +} + +/** @param Closure(array{k: string}): string $callback */ +function functionWithClosure(Closure $callback): void +{ +} + +function test(Foo $foo): void +{ + functionWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + $foo->methodWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + Foo::staticMethodWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + functionWithClosure(fn (array $x): string => $x['k'] !== null ? 'a' : 'b'); +} From b93cd6c102a967ded7aebfee5e52ffa34bcd230e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 4 Jul 2026 07:11:53 +0000 Subject: [PATCH 2/2] Cover the preg_replace_callback arrow-function reproducer in ArrowFunctionReturnTypeRuleTest The second playground link on phpstan/phpstan#14914 uses an arrow function without an explicit return type as the preg_replace_callback callback. That case goes through ArrowFunctionReturnTypeRule (not ClosureReturnTypeRule), so add it to bug-14914.php and assert no error there. Without the replaceInFunctionCallStackParameterType fix it reports "Anonymous function should return 'aa' but returns 'aa'|'possible?'". Co-Authored-By: Claude Opus 4.8 --- .../Functions/ArrowFunctionReturnTypeRuleTest.php | 5 +++++ tests/PHPStan/Rules/Functions/data/bug-14914.php | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php index 23093600316..4ab72a3893c 100644 --- a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php @@ -77,4 +77,9 @@ public function testBugFunctionMethodConstants(): void $this->analyse([__DIR__ . '/data/bug-anonymous-function-method-constant.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php index e6069300620..7450dcf861c 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-14914.php +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -18,3 +18,13 @@ function (array $match): string { flags: PREG_UNMATCHED_AS_NULL, ); } + +function doBar(): void +{ + preg_replace_callback( + '/a|(?b)/', + fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?', + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +}