From 64aa1e9e81d08a6d6137366da92df96a2e748bc6 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 20 May 2026 21:47:52 +0000 Subject: [PATCH] Resolve component names from combined `|`-separated parameter names for named argument lookup on union types - When `combineAcceptors()` merges parameters from union type method variants by position, parameters at the same position with different names get a combined name like `other|target`. Named argument lookup then fails because it searches for `target` but only finds `other|target` in the parameters-by-name map. - Add `mapCombinedParameterNames()` to `FunctionCallParametersCheck` to split combined parameter names and register each component name in the lookup maps, using a primary-names-first strategy to ensure each component maps to a unique parameter. - Add `mapCombinedParameterPositions()` to `ArgumentsNormalizer` with the same strategy for the argument positions map used during argument reordering. - Fix covers instance method calls, static method calls, constructor calls, and function calls since they all funnel through the same `FunctionCallParametersCheck::check()` and `ArgumentsNormalizer::reorderArgs()` code paths. - Intersection types use a different approach (selecting the method with most parameters) and are not affected. --- src/Analyser/ArgumentsNormalizer.php | 38 ++++++++ src/Rules/FunctionCallParametersCheck.php | 55 +++++++++++- .../Rules/Methods/CallMethodsRuleTest.php | 21 +++++ .../Methods/CallStaticMethodsRuleTest.php | 11 +++ .../Rules/Methods/data/bug-14661-static.php | 27 ++++++ .../PHPStan/Rules/Methods/data/bug-14661.php | 89 +++++++++++++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14661-static.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14661.php diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index ae6fb6511ca..55dde0dadd3 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -12,6 +12,7 @@ use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\String_; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; @@ -22,11 +23,13 @@ use function array_keys; use function array_values; use function count; +use function explode; use function is_string; use function key; use function ksort; use function max; use function sprintf; +use function str_contains; /** * @api @@ -323,6 +326,8 @@ public static function reorderArgs(ParametersAcceptor $parametersAcceptor, array $argumentPositions[$parameter->getName()] = $i; } + self::mapCombinedParameterPositions($signatureParameters, $argumentPositions); + $reorderedArgs = []; $additionalNamedArgs = []; $appendArgs = []; @@ -439,4 +444,37 @@ public static function reorderArgs(ParametersAcceptor $parametersAcceptor, array return $reorderedArgs; } + /** + * @param list $signatureParameters + * @param array $argumentPositions + */ + private static function mapCombinedParameterPositions(array $signatureParameters, array &$argumentPositions): void + { + foreach ($signatureParameters as $i => $parameter) { + $parameterName = $parameter->getName(); + if (!str_contains($parameterName, '|')) { + continue; + } + $primaryName = explode('|', $parameterName, 2)[0]; + if (array_key_exists($primaryName, $argumentPositions)) { + continue; + } + + $argumentPositions[$primaryName] = $i; + } + + foreach ($signatureParameters as $i => $parameter) { + $parameterName = $parameter->getName(); + if (!str_contains($parameterName, '|')) { + continue; + } + foreach (explode('|', $parameterName) as $name) { + if (array_key_exists($name, $argumentPositions)) { + continue; + } + $argumentPositions[$name] = $i; + } + } + } + } diff --git a/src/Rules/FunctionCallParametersCheck.php b/src/Rules/FunctionCallParametersCheck.php index 4308efe56c3..6f572fefcc9 100644 --- a/src/Rules/FunctionCallParametersCheck.php +++ b/src/Rules/FunctionCallParametersCheck.php @@ -32,12 +32,14 @@ use function array_key_exists; use function array_last; use function count; +use function explode; use function implode; use function in_array; use function is_int; use function is_string; use function max; use function sprintf; +use function str_contains; #[AutowiredService] final class FunctionCallParametersCheck @@ -581,15 +583,20 @@ private function processArguments( $errors = []; $isNativelyVariadic = false; foreach ($parameters as $i => $parameter) { - $parametersByName[$parameter->getName()] = $parameter; - $originalParametersByName[$parameter->getName()] = $originalParameters[$i]; + $parameterName = $parameter->getName(); + $parametersByName[$parameterName] = $parameter; + $originalParametersByName[$parameterName] = $originalParameters[$i]; if ($parameter->isVariadic()) { $isNativelyVariadic = true; continue; } - $unusedParametersByName[$parameter->getName()] = $parameter; + $unusedParametersByName[$parameterName] = $parameter; + } + + if ($hasNamedArguments) { + self::mapCombinedParameterNames($parameters, $originalParameters, $parametersByName, $originalParametersByName); } $newArguments = []; @@ -686,6 +693,48 @@ private function processArguments( return [$errors, $newArguments]; } + /** + * @param list $parameters + * @param array $originalParameters + * @param array $parametersByName + * @param array $originalParametersByName + */ + private static function mapCombinedParameterNames( + array $parameters, + array $originalParameters, + array &$parametersByName, + array &$originalParametersByName, + ): void + { + foreach ($parameters as $i => $parameter) { + $parameterName = $parameter->getName(); + if (!str_contains($parameterName, '|')) { + continue; + } + $primaryName = explode('|', $parameterName, 2)[0]; + if (array_key_exists($primaryName, $parametersByName)) { + continue; + } + + $parametersByName[$primaryName] = $parameter; + $originalParametersByName[$primaryName] = $originalParameters[$i]; + } + + foreach ($parameters as $i => $parameter) { + $parameterName = $parameter->getName(); + if (!str_contains($parameterName, '|')) { + continue; + } + foreach (explode('|', $parameterName) as $name) { + if (array_key_exists($name, $parametersByName)) { + continue; + } + $parametersByName[$name] = $parameter; + $originalParametersByName[$name] = $originalParameters[$i]; + } + } + } + private function describeParameter(ParameterReflection $parameter, int|string|null $positionOrNamed): string { $parts = []; diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index d7a4d6766d8..e36ca6cc0d7 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4106,4 +4106,25 @@ public function testBug14596(): void ]); } + public function testBug14661(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-14661.php'], [ + [ + 'Unknown parameter $unknown in call to method Bug14661\A::mixedOrder().', + 47, + ], + [ + 'Missing parameter $b|a (int|string) in call to method Bug14661\C::foo().', + 64, + ], + [ + 'Missing parameter $a|b (int|string) in call to method Bug14661\C::foo().', + 65, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 47a70c9d02a..1e31235f495 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1033,4 +1033,15 @@ public function testBug14596(): void ]); } + public function testBug14661(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/bug-14661-static.php'], [ + [ + 'Unknown parameter $unknown in call to static method Bug14661Static\E::bar().', + 26, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14661-static.php b/tests/PHPStan/Rules/Methods/data/bug-14661-static.php new file mode 100644 index 00000000000..5e7699d20c5 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14661-static.php @@ -0,0 +1,27 @@ +mixedOrder(target: 'value'); + $obj->mixedOrder(other: 'value'); + $obj->mixedOrder(target: 'value1', other: 'value2'); + $obj->mixedOrder(other: 'value1', target: 'value2'); +} + +function sameOrder(A|B $obj): void +{ + $obj->sameOrder(target: 'value'); + $obj->sameOrder(other: 'value'); +} + +function unknownParam(A|B $obj): void +{ + $obj->mixedOrder(unknown: 'value'); +} + +class C +{ + public function foo(string $a, int $b): void {} +} + +class D +{ + public function foo(int $b, string $a): void {} +} + +function differentTypes(C|D $obj): void +{ + $obj->foo(a: 'hello', b: 42); + $obj->foo(b: 42, a: 'hello'); + $obj->foo(a: 'hello'); + $obj->foo(b: 42); +} + +class E +{ + public static function bar( + ?string $x = null, + ?string $y = null, + ): void {} +} + +class F +{ + public static function bar( + ?string $y = null, + ?string $x = null, + ): void {} +} + +function staticMethodCall(E|F $obj): void +{ + $obj::bar(x: 'value'); + $obj::bar(y: 'value'); + $obj::bar(x: 'v1', y: 'v2'); +}