From 50749d779c90dde2b70bf9e9ec05ca239f17e2d6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:16:58 +0000 Subject: [PATCH 01/15] Fix call_user_func_array not updating scope for by-reference args - When call_user_func_array (or call_user_func) passes arguments by reference, the scope now properly reflects that the referenced variables may be modified - Added by-reference parameter handling in FuncCallHandler::processExpr that resolves the inner callable's parameters and assigns the parameter type to by-ref argument expressions via processVirtualAssign - New regression test in tests/PHPStan/Analyser/nsrt/bug-6799.php - Root cause: processArgs only handled by-ref for call_user_func_array's own parameters, not the target callable's parameters --- src/Analyser/ExprHandler/FuncCallHandler.php | 43 ++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-6799.php | 38 +++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-6799.php diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index e345e82365..977777e20f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -208,6 +208,49 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + if ( + $functionReflection !== null + && in_array($functionReflection->getName(), ['call_user_func_array', 'call_user_func'], true) + ) { + $innerResult = $functionReflection->getName() === 'call_user_func_array' + ? ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $scope) + : ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $scope); + + if ($innerResult !== null) { + [$innerParametersAcceptor, $innerFuncCall] = $innerResult; + $innerParameters = $innerParametersAcceptor->getParameters(); + $innerArgs = $innerFuncCall->getArgs(); + + foreach ($innerArgs as $i => $innerArg) { + $innerParameter = null; + if (isset($innerParameters[$i])) { + $innerParameter = $innerParameters[$i]; + } elseif (count($innerParameters) > 0 && $innerParametersAcceptor->isVariadic()) { + $innerParameter = $innerParameters[count($innerParameters) - 1]; + } + + if ($innerParameter === null || !$innerParameter->passedByReference()->createsNewVariable()) { + continue; + } + + $argValue = $innerArg->value; + if ($argValue instanceof Variable && $argValue->name === 'this') { + continue; + } + + $byRefType = $innerParameter->getType(); + $scope = $nodeScopeResolver->processVirtualAssign( + $scope, + $storage, + $stmt, + $argValue, + new TypeExpr($byRefType), + $nodeCallback, + )->getScope(); + } + } + } + if ($normalizedExpr->name instanceof Expr) { $nameType = $scope->getType($normalizedExpr->name); if ( diff --git a/tests/PHPStan/Analyser/nsrt/bug-6799.php b/tests/PHPStan/Analyser/nsrt/bug-6799.php new file mode 100644 index 0000000000..7c2bbfc801 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-6799.php @@ -0,0 +1,38 @@ + $value) { + call_user_func_array(array($this, 'listingAddWhereFilterAtableDefault'), array(&$whereFilter, 'xxxx', $filters[$type], $value)); + } + assertType('array', $whereFilter); + } + } +} From 2d7ce79086998ed4a18c9d5e455f5f2482aef6e9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 24 Mar 2026 15:17:13 +0000 Subject: [PATCH 02/15] Use @param-out type for call_user_func_array by-reference args When a by-reference parameter has a @param-out PHPDoc, use that type instead of the @param type when updating scope after call_user_func_array. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 ++++- tests/PHPStan/Analyser/nsrt/bug-6799.php | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 977777e20f..79f5a03bcb 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -32,6 +32,7 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Reflection\Callables\CallableParametersAcceptor; +use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\FunctionReflection; @@ -238,7 +239,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $byRefType = $innerParameter->getType(); + $byRefType = $innerParameter instanceof ExtendedParameterReflection && $innerParameter->getOutType() !== null + ? $innerParameter->getOutType() + : $innerParameter->getType(); $scope = $nodeScopeResolver->processVirtualAssign( $scope, $storage, diff --git a/tests/PHPStan/Analyser/nsrt/bug-6799.php b/tests/PHPStan/Analyser/nsrt/bug-6799.php index 7c2bbfc801..4196c71079 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6799.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6799.php @@ -35,4 +35,20 @@ protected function listingAddWhereFilterAtable(array $filterValues, array &$wher assertType('array', $whereFilter); } } + + /** + * @param array $items + * @param-out list $items + */ + protected function processWithParamOut(array &$items): void + { + $items = [1, 2, 3]; + } + + protected function testParamOut(): void + { + $items = []; + call_user_func_array([$this, 'processWithParamOut'], [&$items]); + assertType('list', $items); + } } From e30122dfb3e1032b531504c1a7078aad97204687 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 24 Mar 2026 15:40:23 +0000 Subject: [PATCH 03/15] Refactor call_user_func by-ref type resolution to reuse NodeScopeResolver logic Extract resolveByRefParameterType() public method from NodeScopeResolver to centralize by-reference parameter type resolution (extension types, @param-out, builtin vs user-defined distinction). Use it in both processArgs and FuncCallHandler's call_user_func_array/call_user_func handling. Also resolves the inner callee reflection (function or method) from the callback type, and calls lookForUnsetAllowedUndefinedExpressions after virtual assignment. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 49 +++++++++++++++-- src/Analyser/NodeScopeResolver.php | 56 ++++++++++++-------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 79f5a03bcb..612889d359 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -32,10 +32,10 @@ use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Reflection\Callables\CallableParametersAcceptor; -use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; @@ -222,6 +222,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $innerParameters = $innerParametersAcceptor->getParameters(); $innerArgs = $innerFuncCall->getArgs(); + $innerCalleeReflection = $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope); + foreach ($innerArgs as $i => $innerArg) { $innerParameter = null; if (isset($innerParameters[$i])) { @@ -239,9 +241,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $byRefType = $innerParameter instanceof ExtendedParameterReflection && $innerParameter->getOutType() !== null - ? $innerParameter->getOutType() - : $innerParameter->getType(); + $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection, $innerParameter, $scope); $scope = $nodeScopeResolver->processVirtualAssign( $scope, $storage, @@ -250,6 +250,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new TypeExpr($byRefType), $nodeCallback, )->getScope(); + $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } } @@ -866,6 +867,46 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } + /** + * @return FunctionReflection|MethodReflection|null + */ + private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, MutatingScope $scope) + { + if ($innerFuncCall->name instanceof Name && $this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { + return $this->reflectionProvider->getFunction($innerFuncCall->name, $scope); + } + + if (!$innerFuncCall->name instanceof Expr) { + return null; + } + + $callbackType = $scope->getType($innerFuncCall->name); + + foreach ($callbackType->getConstantStrings() as $constantString) { + if ($constantString->getValue() === '') { + continue; + } + $funcName = new Name($constantString->getValue()); + if ($this->reflectionProvider->hasFunction($funcName, $scope)) { + return $this->reflectionProvider->getFunction($funcName, $scope); + } + } + + foreach ($callbackType->getConstantArrays() as $constantArray) { + foreach ($constantArray->findTypeAndMethodNames() as $typeAndMethod) { + if ($typeAndMethod->isUnknown() || !$typeAndMethod->getCertainty()->yes()) { + continue; + } + $methodType = $typeAndMethod->getType(); + if ($methodType->hasMethod($typeAndMethod->getMethod())->yes()) { + return $methodType->getMethod($typeAndMethod->getMethod(), $scope); + } + } + } + + return null; + } + private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection): ?Type { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicFunctionReturnTypeExtensions($functionReflection) as $dynamicFunctionReturnTypeExtension) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca1daa6f34..7c71af8b1c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3475,27 +3475,7 @@ public function processArgs( $argValue = $arg->value; if (!$argValue instanceof Variable || $argValue->name !== 'this') { - $paramOutType = $this->getParameterOutExtensionsType($callLike, $calleeReflection, $currentParameter, $scope); - if ($paramOutType !== null) { - $byRefType = $paramOutType; - } elseif ( - $currentParameter instanceof ExtendedParameterReflection - && $currentParameter->getOutType() !== null - ) { - $byRefType = $currentParameter->getOutType(); - } elseif ( - $calleeReflection instanceof MethodReflection - && !$calleeReflection->getDeclaringClass()->isBuiltin() - ) { - $byRefType = $currentParameter->getType(); - } elseif ( - $calleeReflection instanceof FunctionReflection - && !$calleeReflection->isBuiltin() - ) { - $byRefType = $currentParameter->getType(); - } else { - $byRefType = new MixedType(); - } + $byRefType = $this->resolveByRefParameterType($callLike, $calleeReflection, $currentParameter, $scope); $scope = $this->processVirtualAssign( $scope, @@ -3605,6 +3585,40 @@ private function getParameterTypeFromParameterClosureTypeExtension(CallLike $cal return null; } + /** + * @param MethodReflection|FunctionReflection|null $calleeReflection + */ + public function resolveByRefParameterType(CallLike $callLike, $calleeReflection, ParameterReflection $currentParameter, MutatingScope $scope): Type + { + $paramOutType = $this->getParameterOutExtensionsType($callLike, $calleeReflection, $currentParameter, $scope); + if ($paramOutType !== null) { + return $paramOutType; + } + + if ( + $currentParameter instanceof ExtendedParameterReflection + && $currentParameter->getOutType() !== null + ) { + return $currentParameter->getOutType(); + } + + if ( + $calleeReflection instanceof MethodReflection + && !$calleeReflection->getDeclaringClass()->isBuiltin() + ) { + return $currentParameter->getType(); + } + + if ( + $calleeReflection instanceof FunctionReflection + && !$calleeReflection->isBuiltin() + ) { + return $currentParameter->getType(); + } + + return new MixedType(); + } + /** * @param MethodReflection|FunctionReflection|null $calleeReflection */ From 7e864e413c73da08ca1a428db673ce168cd21d2f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 24 Mar 2026 16:02:12 +0000 Subject: [PATCH 04/15] Simplify resolveCallUserFuncCalleeReflection to only support single-value callbacks Instead of iterating and returning the first match (which incorrectly handles union types like 'foo'|'bar' by only considering 'foo'), only resolve the callee when there is exactly one constant string or one constant array callback value. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 23 +++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 612889d359..3458f6e754 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -882,24 +882,21 @@ private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, Mu $callbackType = $scope->getType($innerFuncCall->name); - foreach ($callbackType->getConstantStrings() as $constantString) { - if ($constantString->getValue() === '') { - continue; - } - $funcName = new Name($constantString->getValue()); + $constantStrings = $callbackType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { + $funcName = new Name($constantStrings[0]->getValue()); if ($this->reflectionProvider->hasFunction($funcName, $scope)) { return $this->reflectionProvider->getFunction($funcName, $scope); } } - foreach ($callbackType->getConstantArrays() as $constantArray) { - foreach ($constantArray->findTypeAndMethodNames() as $typeAndMethod) { - if ($typeAndMethod->isUnknown() || !$typeAndMethod->getCertainty()->yes()) { - continue; - } - $methodType = $typeAndMethod->getType(); - if ($methodType->hasMethod($typeAndMethod->getMethod())->yes()) { - return $methodType->getMethod($typeAndMethod->getMethod(), $scope); + $constantArrays = $callbackType->getConstantArrays(); + if (count($constantArrays) === 1) { + $typeAndMethods = $constantArrays[0]->findTypeAndMethodNames(); + if (count($typeAndMethods) === 1 && !$typeAndMethods[0]->isUnknown() && $typeAndMethods[0]->getCertainty()->yes()) { + $methodType = $typeAndMethods[0]->getType(); + if ($methodType->hasMethod($typeAndMethods[0]->getMethod())->yes()) { + return $methodType->getMethod($typeAndMethods[0]->getMethod(), $scope); } } } From da76a4b71b81db4855aaecbe50dd45844e63be8e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 24 Mar 2026 16:11:27 +0000 Subject: [PATCH 05/15] Add tests for union callback types in call_user_func_array by-ref handling Tests cover: - Union of string method names ('method1'|'method2') - Union of constant array callbacks - Mixed union of string and array callback Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-6799.php | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6799.php b/tests/PHPStan/Analyser/nsrt/bug-6799.php index 4196c71079..e9f81770da 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6799.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6799.php @@ -51,4 +51,43 @@ protected function testParamOut(): void call_user_func_array([$this, 'processWithParamOut'], [&$items]); assertType('list', $items); } + + /** + * @param array $items + * @param-out list $items + */ + protected function processWithParamOutStrings(array &$items): void + { + $items = ['a', 'b']; + } + + /** + * @param 'processWithParamOut'|'processWithParamOutStrings' $method + */ + protected function testUnionStringCallbacks(string $method): void + { + $items = []; + call_user_func_array([$this, $method], [&$items]); + assertType('list', $items); + } + + /** + * @param array{$this, 'processWithParamOut'}|array{$this, 'processWithParamOutStrings'} $callback + */ + protected function testUnionArrayCallbacks(array $callback): void + { + $items = []; + call_user_func_array($callback, [&$items]); + assertType('list', $items); + } + + /** + * @param 'processWithParamOut'|array{$this, 'processWithParamOutStrings'} $callback + */ + protected function testMixedUnionCallback($callback): void + { + $items = []; + call_user_func_array($callback, [&$items]); + assertType('array{}', $items); + } } From 45fb724b7fd609f1703b869ffaa65ba67508261d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Tue, 24 Mar 2026 17:38:33 +0100 Subject: [PATCH 06/15] Rework tests --- tests/PHPStan/Analyser/nsrt/bug-6799.php | 45 ++++++++++++++++++------ 1 file changed, 34 insertions(+), 11 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6799.php b/tests/PHPStan/Analyser/nsrt/bug-6799.php index e9f81770da..d4501794ce 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6799.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6799.php @@ -4,6 +4,22 @@ use function PHPStan\Testing\assertType; +/** + * @param array $items + */ +function functionProcessInts(array &$items): void +{ + $items = [1, 2]; +} + +/** + * @param array $items + */ +function functionProcessStrings(array &$items): void +{ + $items = ['a', 'b']; +} + class HelloWorld { /** @@ -53,41 +69,48 @@ protected function testParamOut(): void } /** - * @param array $items - * @param-out list $items + * @param array $items + */ + protected function processInts(array &$items): void + { + $items = [1, 2]; + } + + /** + * @param array $items */ - protected function processWithParamOutStrings(array &$items): void + protected function processStrings(array &$items): void { $items = ['a', 'b']; } /** - * @param 'processWithParamOut'|'processWithParamOutStrings' $method + * @param 'Bug6799\functionProcessInts'|'Bug6799\functionProcessStrings' $function */ - protected function testUnionStringCallbacks(string $method): void + protected function testUnionStringCallbacks(string $function): void { $items = []; - call_user_func_array([$this, $method], [&$items]); - assertType('list', $items); + call_user_func_array($function, [&$items]); + assertType('mixed', $items); // Could be array|array } /** - * @param array{$this, 'processWithParamOut'}|array{$this, 'processWithParamOutStrings'} $callback + * @param array{$this, 'processInts'}|array{$this, 'processStrings'} $callback */ protected function testUnionArrayCallbacks(array $callback): void { $items = []; call_user_func_array($callback, [&$items]); - assertType('list', $items); + assertType('mixed', $items); // Could be array|array } /** - * @param 'processWithParamOut'|array{$this, 'processWithParamOutStrings'} $callback + * @param 'Bug6799\functionProcessInts'|array{$this, 'processStrings'} $callback */ protected function testMixedUnionCallback($callback): void { $items = []; call_user_func_array($callback, [&$items]); - assertType('array{}', $items); + assertType('mixed', $items); // Could be array|array } } From 67ba647c8daea0d4a574bad500ff01924577414a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 06:34:52 +0000 Subject: [PATCH 07/15] Address review: native return type and reorder $this check - Add native return type to resolveCallUserFuncCalleeReflection - Move $this check to be first inside the loop Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 3458f6e754..439000fe0f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -225,6 +225,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $innerCalleeReflection = $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope); foreach ($innerArgs as $i => $innerArg) { + $argValue = $innerArg->value; + if ($argValue instanceof Variable && $argValue->name === 'this') { + continue; + } + $innerParameter = null; if (isset($innerParameters[$i])) { $innerParameter = $innerParameters[$i]; @@ -236,11 +241,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $argValue = $innerArg->value; - if ($argValue instanceof Variable && $argValue->name === 'this') { - continue; - } - $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection, $innerParameter, $scope); $scope = $nodeScopeResolver->processVirtualAssign( $scope, @@ -867,10 +867,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } - /** - * @return FunctionReflection|MethodReflection|null - */ - private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, MutatingScope $scope) + private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, MutatingScope $scope): FunctionReflection|MethodReflection|null { if ($innerFuncCall->name instanceof Name && $this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { return $this->reflectionProvider->getFunction($innerFuncCall->name, $scope); From 6af1bb828d2184b2398e229087b32dc6888dab5f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 06:37:51 +0000 Subject: [PATCH 08/15] Address staabm review: native return type, early return, reorder $this check - Add native return type to resolveCallUserFuncCalleeReflection - Add early return null after constant strings block since a type cannot be both constant string and constant array - Move $this check to be first inside the loop Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 439000fe0f..3762c9edca 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -885,6 +885,8 @@ private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, Mu if ($this->reflectionProvider->hasFunction($funcName, $scope)) { return $this->reflectionProvider->getFunction($funcName, $scope); } + + return null; } $constantArrays = $callbackType->getConstantArrays(); From 9ee88869e8678dcdeaa4eef165a380a2515bf7c7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 25 Mar 2026 07:42:01 +0100 Subject: [PATCH 09/15] simplify --- src/Analyser/ExprHandler/FuncCallHandler.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 3762c9edca..0313725165 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -869,11 +869,10 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, MutatingScope $scope): FunctionReflection|MethodReflection|null { - if ($innerFuncCall->name instanceof Name && $this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { - return $this->reflectionProvider->getFunction($innerFuncCall->name, $scope); - } - - if (!$innerFuncCall->name instanceof Expr) { + if ($innerFuncCall->name instanceof Name) { + if ($this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { + return $this->reflectionProvider->getFunction($innerFuncCall->name, $scope); + } return null; } From 45ba2fdff8495546aaf8febe43d6cfc224fcaead Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 06:49:32 +0000 Subject: [PATCH 10/15] Use native parameter type for resolveByRefParameterType Co-Authored-By: Claude Opus 4.6 --- src/Analyser/NodeScopeResolver.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7c71af8b1c..caad0c321b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3585,10 +3585,7 @@ private function getParameterTypeFromParameterClosureTypeExtension(CallLike $cal return null; } - /** - * @param MethodReflection|FunctionReflection|null $calleeReflection - */ - public function resolveByRefParameterType(CallLike $callLike, $calleeReflection, ParameterReflection $currentParameter, MutatingScope $scope): Type + public function resolveByRefParameterType(CallLike $callLike, MethodReflection|FunctionReflection|null $calleeReflection, ParameterReflection $currentParameter, MutatingScope $scope): Type { $paramOutType = $this->getParameterOutExtensionsType($callLike, $calleeReflection, $currentParameter, $scope); if ($paramOutType !== null) { From 0487f0a4ea86c0a54d9e156b7aea460bc1fec77c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 06:53:28 +0000 Subject: [PATCH 11/15] Defer resolveCallUserFuncCalleeReflection until needed, native param type - Lazy-initialize innerCalleeReflection with ??= so reflection work only runs after the passedByReference()->createsNewVariable() check - Use native union parameter type on resolveByRefParameterType Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 0313725165..b82d1da76c 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -222,7 +222,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $innerParameters = $innerParametersAcceptor->getParameters(); $innerArgs = $innerFuncCall->getArgs(); - $innerCalleeReflection = $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope); + $innerCalleeReflection = null; foreach ($innerArgs as $i => $innerArg) { $argValue = $innerArg->value; @@ -241,6 +241,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } + $innerCalleeReflection ??= $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope); $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection, $innerParameter, $scope); $scope = $nodeScopeResolver->processVirtualAssign( $scope, From 98edaa0e7920f7b805e4e0389721c978a4ba8308 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 07:03:04 +0000 Subject: [PATCH 12/15] Add rule test for bug-6799 to verify no false positive on count() comparison Co-Authored-By: Claude Opus 4.6 --- ...isonOperatorsConstantConditionRuleTest.php | 5 +++ .../Rules/Comparison/data/bug-6799.php | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-6799.php diff --git a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php index 5400e3edee..b6bd38c930 100644 --- a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php @@ -288,6 +288,11 @@ public function testBug13874(): void $this->analyse([__DIR__ . '/data/bug-13874.php'], []); } + public function testBug6799(): void + { + $this->analyse([__DIR__ . '/data/bug-6799.php'], []); + } + public function testBug12163(): void { $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-12163.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6799.php b/tests/PHPStan/Rules/Comparison/data/bug-6799.php new file mode 100644 index 0000000000..0e248f4543 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-6799.php @@ -0,0 +1,38 @@ + $value) { + call_user_func_array(array($this, 'listingAddWhereFilterAtableDefault'), array(&$whereFilter, 'xxxx', $filters[$type], $value)); + } + if (count($whereFilter) > 0) { + $where[] = implode(' OR ', $whereFilter); + } + } + } +} From c2950325a72effd5907ef0cd125a0983261de83d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 12:19:56 +0000 Subject: [PATCH 13/15] Replace resolveCallUserFuncCalleeReflection with CallableParametersAcceptor::getCalleeReflection() Add getCalleeReflection() to CallableParametersAcceptor interface, returning the underlying FunctionReflection or MethodReflection when available. This replaces the manual callable resolution logic in FuncCallHandler with the existing callable type abstraction. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 49 +++++-------------- .../Callables/CallableParametersAcceptor.php | 4 ++ .../Callables/FunctionCallableVariant.php | 5 ++ .../ExtendedCallableFunctionVariant.php | 7 +++ src/Reflection/InaccessibleMethod.php | 5 ++ .../ResolvedFunctionVariantWithCallable.php | 7 +++ src/Reflection/TrivialParametersAcceptor.php | 7 +++ src/Type/CallableType.php | 7 +++ src/Type/ClosureType.php | 7 +++ 9 files changed, 60 insertions(+), 38 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index b82d1da76c..fd364f91ef 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -35,7 +35,6 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\FunctionReflection; -use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; @@ -241,8 +240,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $innerCalleeReflection ??= $this->resolveCallUserFuncCalleeReflection($innerFuncCall, $scope); - $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection, $innerParameter, $scope); + if ($innerCalleeReflection === null && $innerFuncCall->name instanceof Expr) { + $calledOnType = $scope->getType($innerFuncCall->name); + $callableAcceptors = $calledOnType->getCallableParametersAcceptors($scope); + $innerCalleeReflection = count($callableAcceptors) === 1 + ? $callableAcceptors[0]->getCalleeReflection() + : false; + } + if ($innerCalleeReflection === null) { + $innerCalleeReflection = false; + } + $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection !== false ? $innerCalleeReflection : null, $innerParameter, $scope); $scope = $nodeScopeResolver->processVirtualAssign( $scope, $storage, @@ -868,41 +876,6 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } - private function resolveCallUserFuncCalleeReflection(FuncCall $innerFuncCall, MutatingScope $scope): FunctionReflection|MethodReflection|null - { - if ($innerFuncCall->name instanceof Name) { - if ($this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { - return $this->reflectionProvider->getFunction($innerFuncCall->name, $scope); - } - return null; - } - - $callbackType = $scope->getType($innerFuncCall->name); - - $constantStrings = $callbackType->getConstantStrings(); - if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - $funcName = new Name($constantStrings[0]->getValue()); - if ($this->reflectionProvider->hasFunction($funcName, $scope)) { - return $this->reflectionProvider->getFunction($funcName, $scope); - } - - return null; - } - - $constantArrays = $callbackType->getConstantArrays(); - if (count($constantArrays) === 1) { - $typeAndMethods = $constantArrays[0]->findTypeAndMethodNames(); - if (count($typeAndMethods) === 1 && !$typeAndMethods[0]->isUnknown() && $typeAndMethods[0]->getCertainty()->yes()) { - $methodType = $typeAndMethods[0]->getType(); - if ($methodType->hasMethod($typeAndMethods[0]->getMethod())->yes()) { - return $methodType->getMethod($typeAndMethods[0]->getMethod(), $scope); - } - } - } - - return null; - } - private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection): ?Type { foreach ($this->dynamicReturnTypeExtensionRegistryProvider->getRegistry()->getDynamicFunctionReturnTypeExtensions($functionReflection) as $dynamicFunctionReturnTypeExtension) { diff --git a/src/Reflection/Callables/CallableParametersAcceptor.php b/src/Reflection/Callables/CallableParametersAcceptor.php index bcef9878ee..097f081838 100644 --- a/src/Reflection/Callables/CallableParametersAcceptor.php +++ b/src/Reflection/Callables/CallableParametersAcceptor.php @@ -4,6 +4,8 @@ use PHPStan\Node\InvalidateExprNode; use PHPStan\Reflection\Assertions; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\TrinaryLogic; @@ -60,4 +62,6 @@ public function mustUseReturnValue(): TrinaryLogic; public function getAsserts(): Assertions; + public function getCalleeReflection(): FunctionReflection|MethodReflection|null; + } diff --git a/src/Reflection/Callables/FunctionCallableVariant.php b/src/Reflection/Callables/FunctionCallableVariant.php index 6c48e4b010..009ef199aa 100644 --- a/src/Reflection/Callables/FunctionCallableVariant.php +++ b/src/Reflection/Callables/FunctionCallableVariant.php @@ -179,4 +179,9 @@ public function getAsserts(): Assertions return $this->function->getAsserts(); } + public function getCalleeReflection(): FunctionReflection|ExtendedMethodReflection + { + return $this->function; + } + } diff --git a/src/Reflection/ExtendedCallableFunctionVariant.php b/src/Reflection/ExtendedCallableFunctionVariant.php index 389893394e..7ac2c25953 100644 --- a/src/Reflection/ExtendedCallableFunctionVariant.php +++ b/src/Reflection/ExtendedCallableFunctionVariant.php @@ -6,6 +6,8 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -92,4 +94,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function getCalleeReflection(): FunctionReflection|MethodReflection|null + { + return null; + } + } diff --git a/src/Reflection/InaccessibleMethod.php b/src/Reflection/InaccessibleMethod.php index 68fce995f8..a108a67a7d 100644 --- a/src/Reflection/InaccessibleMethod.php +++ b/src/Reflection/InaccessibleMethod.php @@ -98,4 +98,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function getCalleeReflection(): ExtendedMethodReflection + { + return $this->methodReflection; + } + } diff --git a/src/Reflection/ResolvedFunctionVariantWithCallable.php b/src/Reflection/ResolvedFunctionVariantWithCallable.php index 6f816fa0ac..d8e15a6c3a 100644 --- a/src/Reflection/ResolvedFunctionVariantWithCallable.php +++ b/src/Reflection/ResolvedFunctionVariantWithCallable.php @@ -6,6 +6,8 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -124,4 +126,9 @@ public function getAsserts(): Assertions return $this->assertions ?? Assertions::createEmpty(); } + public function getCalleeReflection(): FunctionReflection|MethodReflection|null + { + return null; + } + } diff --git a/src/Reflection/TrivialParametersAcceptor.php b/src/Reflection/TrivialParametersAcceptor.php index 157368d4c0..e332110e0c 100644 --- a/src/Reflection/TrivialParametersAcceptor.php +++ b/src/Reflection/TrivialParametersAcceptor.php @@ -4,6 +4,8 @@ use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Callables\SimpleImpurePoint; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; @@ -108,4 +110,9 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function getCalleeReflection(): FunctionReflection|MethodReflection|null + { + return null; + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 380c87982a..3e61568820 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -17,6 +17,8 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ClassMemberAccessAnswerer; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; @@ -404,6 +406,11 @@ public function getAsserts(): Assertions return Assertions::createEmpty(); } + public function getCalleeReflection(): FunctionReflection|MethodReflection|null + { + return null; + } + public function toNumber(): Type { return new ErrorType(); diff --git a/src/Type/ClosureType.php b/src/Type/ClosureType.php index 0a380dff93..6b240a0f33 100644 --- a/src/Type/ClosureType.php +++ b/src/Type/ClosureType.php @@ -18,6 +18,8 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ClassConstantReflection; +use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ClassMemberAccessAnswerer; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; @@ -138,6 +140,11 @@ public function getAsserts(): Assertions return $this->assertions; } + public function getCalleeReflection(): FunctionReflection|MethodReflection|null + { + return null; + } + /** * @return array */ From 54b6aec4bf6c05058541b6f7ff26b16bd45c2de9 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 25 Mar 2026 13:42:14 +0100 Subject: [PATCH 14/15] Rework --- src/Analyser/ExprHandler/FuncCallHandler.php | 30 +++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index fd364f91ef..ceda96abce 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -221,8 +221,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $innerParameters = $innerParametersAcceptor->getParameters(); $innerArgs = $innerFuncCall->getArgs(); - $innerCalleeReflection = null; - foreach ($innerArgs as $i => $innerArg) { $argValue = $innerArg->value; if ($argValue instanceof Variable && $argValue->name === 'this') { @@ -240,17 +238,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - if ($innerCalleeReflection === null && $innerFuncCall->name instanceof Expr) { + $innerCalleeReflections = []; + if ($innerFuncCall->name instanceof Expr) { $calledOnType = $scope->getType($innerFuncCall->name); - $callableAcceptors = $calledOnType->getCallableParametersAcceptors($scope); - $innerCalleeReflection = count($callableAcceptors) === 1 - ? $callableAcceptors[0]->getCalleeReflection() - : false; + if (!$calledOnType->isCallable()->no()) { + $innerCalleeReflections = array_map( + static fn (CallableParametersAcceptor $callableAcceptor) => $callableAcceptor->getCalleeReflection(), + $calledOnType->getCallableParametersAcceptors($scope), + ); + } + } elseif ($this->reflectionProvider->hasFunction($innerFuncCall->name, $scope)) { + $innerCalleeReflections = [$this->reflectionProvider->getFunction($innerFuncCall->name, $scope)]; } - if ($innerCalleeReflection === null) { - $innerCalleeReflection = false; + + if ($innerCalleeReflections === []) { + $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, null, $innerParameter, $scope); + } else { + $byRefTypes = []; + foreach ($innerCalleeReflections as $innerCalleeReflection) { + $byRefTypes[] = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection, $innerParameter, $scope); + } + $byRefType = TypeCombinator::union(...$byRefTypes); } - $byRefType = $nodeScopeResolver->resolveByRefParameterType($innerFuncCall, $innerCalleeReflection !== false ? $innerCalleeReflection : null, $innerParameter, $scope); + $scope = $nodeScopeResolver->processVirtualAssign( $scope, $storage, From 3f143a8301649c2fd95051320c7d7b12f20ec45a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 25 Mar 2026 16:00:47 +0100 Subject: [PATCH 15/15] Fix --- tests/PHPStan/Analyser/nsrt/bug-6799.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-6799.php b/tests/PHPStan/Analyser/nsrt/bug-6799.php index d4501794ce..1c4bdee79e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-6799.php +++ b/tests/PHPStan/Analyser/nsrt/bug-6799.php @@ -91,7 +91,7 @@ protected function testUnionStringCallbacks(string $function): void { $items = []; call_user_func_array($function, [&$items]); - assertType('mixed', $items); // Could be array|array + assertType('array', $items); } /** @@ -101,7 +101,7 @@ protected function testUnionArrayCallbacks(array $callback): void { $items = []; call_user_func_array($callback, [&$items]); - assertType('mixed', $items); // Could be array|array + assertType('array', $items); } /** @@ -111,6 +111,6 @@ protected function testMixedUnionCallback($callback): void { $items = []; call_user_func_array($callback, [&$items]); - assertType('mixed', $items); // Could be array|array + assertType('array', $items); } }