Skip to content

Commit 984090f

Browse files
phpstan-botclaude
andcommitted
Detect cross-trait method collisions when a class uses multiple traits
When a class uses multiple traits that define the same method without `insteadof` resolution, PHP reports a fatal error. Add detection for this case in DuplicateDeclarationRule via a new checkTraitMethodCollisions helper method. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d0974e2 commit 984090f

4 files changed

Lines changed: 197 additions & 4 deletions

File tree

src/Rules/Classes/DuplicateDeclarationHelper.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
use PhpParser\Node\Stmt\ClassConst;
77
use PhpParser\Node\Stmt\ClassLike;
88
use PhpParser\Node\Stmt\EnumCase;
9+
use PhpParser\Node\Stmt\TraitUse;
10+
use PhpParser\Node\Stmt\TraitUseAdaptation\Precedence;
11+
use PHPStan\Reflection\ClassReflection;
912
use PHPStan\Rules\RuleErrorBuilder;
1013
use PHPStan\ShouldNotHappenException;
1114
use function array_key_exists;
15+
use function count;
1216
use function is_string;
1317
use function sprintf;
1418
use function strtolower;
@@ -119,4 +123,85 @@ public static function checkClassLike(ClassLike $classLike, string $displayName,
119123
return $errors;
120124
}
121125

126+
/**
127+
* @return list<\PHPStan\Rules\IdentifierRuleError>
128+
*/
129+
public static function checkTraitMethodCollisions(ClassLike $classLike, ClassReflection $classReflection): array
130+
{
131+
$errors = [];
132+
133+
// Collect insteadof adaptations: trait::method pairs that are excluded
134+
$excludedTraitMethods = [];
135+
foreach ($classLike->stmts as $stmt) {
136+
if (!$stmt instanceof TraitUse) {
137+
continue;
138+
}
139+
foreach ($stmt->adaptations as $adaptation) {
140+
if (!$adaptation instanceof Precedence) {
141+
continue;
142+
}
143+
$methodName = strtolower($adaptation->method->name);
144+
foreach ($adaptation->insteadof as $excludedTrait) {
145+
$excludedTraitMethods[strtolower($excludedTrait->toString()) . '::' . $methodName] = true;
146+
}
147+
}
148+
}
149+
150+
// Collect class-defined methods (these override trait methods)
151+
$classDefinedMethods = [];
152+
foreach ($classLike->getMethods() as $method) {
153+
$classDefinedMethods[strtolower($method->name->name)] = true;
154+
}
155+
156+
// Collect methods from each trait
157+
/** @var array<string, list<array{string, string}>> $methodTraits methodNameLower => [[traitDisplayName, originalMethodName], ...] */
158+
$methodTraits = [];
159+
$traits = $classReflection->getTraits();
160+
foreach ($traits as $trait) {
161+
foreach ($trait->getNativeReflection()->getMethods() as $method) {
162+
$methodNameLower = strtolower($method->getName());
163+
164+
// Skip if this method is excluded via insteadof
165+
$key = strtolower($trait->getName()) . '::' . $methodNameLower;
166+
if (array_key_exists($key, $excludedTraitMethods)) {
167+
continue;
168+
}
169+
170+
// Skip if the class defines this method (class overrides traits)
171+
if (array_key_exists($methodNameLower, $classDefinedMethods)) {
172+
continue;
173+
}
174+
175+
$methodTraits[$methodNameLower][] = [$trait->getDisplayName(), $method->getName()];
176+
}
177+
}
178+
179+
// Find the use statement line for error reporting
180+
$useLine = null;
181+
foreach ($classLike->stmts as $stmt) {
182+
if ($stmt instanceof TraitUse) {
183+
$useLine = $stmt->getStartLine();
184+
break;
185+
}
186+
}
187+
188+
// Report conflicts
189+
foreach ($methodTraits as $traitInfos) {
190+
if (count($traitInfos) <= 1) {
191+
continue;
192+
}
193+
194+
$errors[] = RuleErrorBuilder::message(sprintf(
195+
'Trait method %s has not been applied, because there are collisions with other trait methods on %s.',
196+
$traitInfos[1][0] . '::' . $traitInfos[1][1] . '()',
197+
$classReflection->getDisplayName(),
198+
))->identifier('class.traitMethodCollision')
199+
->line($useLine ?? $classLike->getStartLine())
200+
->nonIgnorable()
201+
->build();
202+
}
203+
204+
return $errors;
205+
}
206+
122207
}

src/Rules/Classes/DuplicateDeclarationRule.php

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PHPStan\DependencyInjection\RegisteredRule;
88
use PHPStan\Node\InClassNode;
99
use PHPStan\Rules\Rule;
10+
use function array_merge;
1011
use function strtolower;
1112

1213
/**
@@ -25,10 +26,16 @@ public function processNode(Node $node, Scope $scope): array
2526
{
2627
$classReflection = $node->getClassReflection();
2728

28-
return DuplicateDeclarationHelper::checkClassLike(
29-
$node->getOriginalNode(),
30-
$classReflection->getDisplayName(),
31-
strtolower($classReflection->getClassTypeDescription()),
29+
return array_merge(
30+
DuplicateDeclarationHelper::checkClassLike(
31+
$node->getOriginalNode(),
32+
$classReflection->getDisplayName(),
33+
strtolower($classReflection->getClassTypeDescription()),
34+
),
35+
DuplicateDeclarationHelper::checkTraitMethodCollisions(
36+
$node->getOriginalNode(),
37+
$classReflection,
38+
),
3239
);
3340
}
3441

tests/PHPStan/Rules/Classes/DuplicateDeclarationRuleTest.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ public function testDuplicatePromotedProperty(): void
6666
]);
6767
}
6868

69+
public function testBug14250TraitMethodCollisions(): void
70+
{
71+
$this->analyse([__DIR__ . '/data/bug-14250.php'], [
72+
[
73+
'Trait method Bug14250\MyTrait2::doSomething() has not been applied, because there are collisions with other trait methods on Bug14250\FooWithMultipleConflictingTraits.',
74+
93,
75+
],
76+
]);
77+
}
78+
79+
public function testTraitMethodCollisions(): void
80+
{
81+
$this->analyse([__DIR__ . '/data/trait-method-collisions.php'], [
82+
[
83+
'Trait method TraitMethodCollisions\TraitB::doSomething() has not been applied, because there are collisions with other trait methods on TraitMethodCollisions\UnresolvedCollision.',
84+
22,
85+
],
86+
[
87+
'Trait method TraitMethodCollisions\TraitD::foo() has not been applied, because there are collisions with other trait methods on TraitMethodCollisions\PartialCollision.',
88+
76,
89+
],
90+
]);
91+
}
92+
6993
#[RequiresPhp('>= 8.1')]
7094
public function testDuplicateEnumCase(): void
7195
{
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace TraitMethodCollisions;
4+
5+
trait TraitA
6+
{
7+
public function doSomething(): void
8+
{
9+
}
10+
}
11+
12+
trait TraitB
13+
{
14+
public function doSomething(): void
15+
{
16+
}
17+
}
18+
19+
// Should report collision - two traits with same method, no insteadof
20+
class UnresolvedCollision
21+
{
22+
use TraitA, TraitB;
23+
}
24+
25+
// Should not report - collision resolved via insteadof
26+
class ResolvedCollision
27+
{
28+
use TraitA, TraitB {
29+
TraitA::doSomething insteadof TraitB;
30+
}
31+
}
32+
33+
// Should not report - class defines the method itself
34+
class ClassOverridesTraitMethod
35+
{
36+
use TraitA, TraitB {
37+
TraitA::doSomething insteadof TraitB;
38+
}
39+
40+
public function doSomething(): void
41+
{
42+
}
43+
}
44+
45+
// Should not report - single trait, no collision possible
46+
class SingleTrait
47+
{
48+
use TraitA;
49+
}
50+
51+
trait TraitC
52+
{
53+
public function foo(): void
54+
{
55+
}
56+
57+
public function bar(): void
58+
{
59+
}
60+
}
61+
62+
trait TraitD
63+
{
64+
public function foo(): void
65+
{
66+
}
67+
68+
public function baz(): void
69+
{
70+
}
71+
}
72+
73+
// Should report collision for foo only
74+
class PartialCollision
75+
{
76+
use TraitC, TraitD;
77+
}

0 commit comments

Comments
 (0)