Skip to content

Commit d9c5bd2

Browse files
phpstan-botclaude
andcommitted
Check each union member individually for positional calls with different parameter names
When calling a method on a union type where parameters at the same position have different names (e.g., A::foo(int $a, string $b) vs B::foo(string $b, int $a)), the combined positional acceptor produces compound names like $a|$b with union types int|string that accept too broadly. This adds per-member checking in CallMethodsRule for positional-only calls when compound parameter names are detected, so type errors against individual union members are reported. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 89fb038 commit d9c5bd2

3 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/Rules/Methods/CallMethodsRule.php

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
use PHPStan\Analyser\Scope;
1010
use PHPStan\DependencyInjection\RegisteredRule;
1111
use PHPStan\Internal\SprintfHelper;
12+
use PHPStan\Reflection\ExtendedMethodReflection;
1213
use PHPStan\Reflection\ParametersAcceptorSelector;
1314
use PHPStan\Rules\FunctionCallParametersCheck;
1415
use PHPStan\Rules\IdentifierRuleError;
1516
use PHPStan\Rules\Rule;
1617
use function array_merge;
18+
use function str_contains;
1719

1820
/**
1921
* @implements Rule<Node\Expr\MethodCall>
@@ -69,16 +71,51 @@ private function processSingleMethodCall(Scope $scope, MethodCall $node, string
6971
return $errors;
7072
}
7173

74+
$args = $node->getArgs();
75+
$selectedAcceptor = ParametersAcceptorSelector::selectFromArgs(
76+
$scope,
77+
$args,
78+
$methodReflection->getVariants(),
79+
$methodReflection->getNamedArgumentsVariants(),
80+
);
81+
82+
if ($this->shouldCheckPerUnionMember($selectedAcceptor, $args)) {
83+
$callerType = $scope->getType($node->var);
84+
foreach ($callerType->getObjectClassReflections() as $classReflection) {
85+
if (!$classReflection->hasMethod($methodName)) {
86+
continue;
87+
}
88+
$memberMethod = $classReflection->getMethod($methodName, $scope);
89+
$memberAcceptor = ParametersAcceptorSelector::selectFromArgs(
90+
$scope,
91+
$args,
92+
$memberMethod->getVariants(),
93+
$memberMethod->getNamedArgumentsVariants(),
94+
);
95+
$errors = array_merge($errors, $this->checkMethodParameters($memberAcceptor, $scope, $node, $memberMethod));
96+
}
97+
} else {
98+
$errors = array_merge($errors, $this->checkMethodParameters($selectedAcceptor, $scope, $node, $methodReflection));
99+
}
100+
101+
return $errors;
102+
}
103+
104+
/**
105+
* @return list<IdentifierRuleError>
106+
*/
107+
private function checkMethodParameters(
108+
\PHPStan\Reflection\ParametersAcceptor $acceptor,
109+
Scope $scope,
110+
MethodCall $node,
111+
ExtendedMethodReflection $methodReflection,
112+
): array
113+
{
72114
$declaringClass = $methodReflection->getDeclaringClass();
73115
$messagesMethodName = SprintfHelper::escapeFormatString($declaringClass->getDisplayName() . '::' . $methodReflection->getName() . '()');
74116

75-
return array_merge($errors, $this->parametersCheck->check(
76-
ParametersAcceptorSelector::selectFromArgs(
77-
$scope,
78-
$node->getArgs(),
79-
$methodReflection->getVariants(),
80-
$methodReflection->getNamedArgumentsVariants(),
81-
),
117+
return $this->parametersCheck->check(
118+
$acceptor,
82119
$scope,
83120
$declaringClass->isBuiltin(),
84121
$node,
@@ -99,7 +136,33 @@ private function processSingleMethodCall(Scope $scope, MethodCall $node, string
99136
'Return type of call to method ' . $messagesMethodName . ' contains unresolvable type.',
100137
'%s of method ' . $messagesMethodName . ' contains unresolvable type.',
101138
'Method ' . $messagesMethodName . ' invoked with %s, but it\'s not allowed because of @no-named-arguments.',
102-
));
139+
);
140+
}
141+
142+
/**
143+
* @param Node\Arg[] $args
144+
*/
145+
private function shouldCheckPerUnionMember(\PHPStan\Reflection\ParametersAcceptor $acceptor, array $args): bool
146+
{
147+
$hasCompoundName = false;
148+
foreach ($acceptor->getParameters() as $parameter) {
149+
if (str_contains($parameter->getName(), '|')) {
150+
$hasCompoundName = true;
151+
break;
152+
}
153+
}
154+
155+
if (!$hasCompoundName) {
156+
return false;
157+
}
158+
159+
foreach ($args as $arg) {
160+
if ($arg->name !== null) {
161+
return false;
162+
}
163+
}
164+
165+
return true;
103166
}
104167

105168
}

tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4129,6 +4129,22 @@ public function testBug14661(): void
41294129
'Parameter $b of method Bug14661\A::differentTypes() expects string, int given.',
41304130
77,
41314131
],
4132+
[
4133+
'Parameter #1 $a of method Bug14661\A::differentTypes() expects int, string given.',
4134+
82,
4135+
],
4136+
[
4137+
'Parameter #2 $b of method Bug14661\A::differentTypes() expects string, int given.',
4138+
82,
4139+
],
4140+
[
4141+
'Parameter #1 $b of method Bug14661\B::differentTypes() expects string, int given.',
4142+
83,
4143+
],
4144+
[
4145+
'Parameter #2 $a of method Bug14661\B::differentTypes() expects int, string given.',
4146+
83,
4147+
],
41324148
]);
41334149
}
41344150

tests/PHPStan/Rules/Methods/data/bug-14661.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ function differentTypesErrors(A|B $obj): void
7777
$obj->differentTypes(b: 1, a: 'hello');
7878
}
7979

80+
function differentTypesPositional(A|B $obj): void
81+
{
82+
$obj->differentTypes('hello', 1);
83+
$obj->differentTypes(1, 'hello');
84+
}
85+
8086
function threeWayUnion(A|B|C $obj): void
8187
{
8288
$obj->mixedOrder(target: 'value');

0 commit comments

Comments
 (0)