From 36833c0cf9e8a9c5571794625d79c3978439173c Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:03:57 +0000 Subject: [PATCH] Detect conflicting trait methods in class compositions - New ConflictingTraitMethodsRule reports when a class uses multiple traits that define the same method without resolving the conflict via insteadof - Handles abstract trait methods correctly (abstract + concrete is allowed) - Conflicts are also resolved if the class defines the method itself - Registered at level 0 as this is a fatal error in PHP - New regression tests in tests/PHPStan/Rules/Classes/data/bug-14332.php --- .../Classes/ConflictingTraitMethodsRule.php | 155 ++++++++++++++++++ .../ConflictingTraitMethodsRuleTest.php | 49 ++++++ .../Rules/Classes/data/bug-14332-abstract.php | 21 +++ .../PHPStan/Rules/Classes/data/bug-14332.php | 101 ++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 src/Rules/Classes/ConflictingTraitMethodsRule.php create mode 100644 tests/PHPStan/Rules/Classes/ConflictingTraitMethodsRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-14332-abstract.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-14332.php diff --git a/src/Rules/Classes/ConflictingTraitMethodsRule.php b/src/Rules/Classes/ConflictingTraitMethodsRule.php new file mode 100644 index 0000000000..6415972112 --- /dev/null +++ b/src/Rules/Classes/ConflictingTraitMethodsRule.php @@ -0,0 +1,155 @@ + + */ +#[RegisteredRule(level: 0)] +final class ConflictingTraitMethodsRule implements Rule +{ + + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + /** + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $classLike = $node->getOriginalNode(); + $traitUses = $classLike->getTraitUses(); + + if ($traitUses === []) { + return []; + } + + // Collect methods defined directly on the class (not from traits) + $classOwnMethods = []; + foreach ($classLike->getMethods() as $method) { + $classOwnMethods[strtolower($method->name->name)] = true; + } + + // Collect all insteadof adaptations + // Key: "traitName::methodName" that is being overridden + $insteadofResolutions = []; + foreach ($traitUses as $traitUse) { + foreach ($traitUse->adaptations as $adaptation) { + if (!$adaptation instanceof Precedence) { + continue; + } + $methodName = strtolower($adaptation->method->name); + foreach ($adaptation->insteadof as $insteadofTrait) { + $insteadofResolutions[strtolower((string) $insteadofTrait) . '::' . $methodName] = true; + } + } + } + + // Collect methods from each trait + // Map: lowercased method name => [traitName => true] + $methodTraitMap = []; + foreach ($traitUses as $traitUse) { + foreach ($traitUse->traits as $traitName) { + $traitNameStr = (string) $traitName; + if (!$this->reflectionProvider->hasClass($traitNameStr)) { + continue; + } + $traitReflection = $this->reflectionProvider->getClass($traitNameStr); + if (!$traitReflection->isTrait()) { + continue; + } + + foreach ($traitReflection->getNativeReflection()->getMethods() as $method) { + $lowerMethodName = strtolower($method->getName()); + $methodTraitMap[$lowerMethodName][$traitReflection->getName()] = [ + 'name' => $method->getName(), + 'abstract' => $method->isAbstract(), + ]; + } + } + } + + $errors = []; + foreach ($methodTraitMap as $lowerMethodName => $traits) { + if (count($traits) <= 1) { + continue; + } + + // If the class defines the method itself, no conflict + if (array_key_exists($lowerMethodName, $classOwnMethods)) { + continue; + } + + // Filter out abstract methods - PHP allows abstract + concrete without conflict + $concreteTraits = []; + foreach ($traits as $traitName => $methodInfo) { + if ($methodInfo['abstract']) { + continue; + } + + $concreteTraits[$traitName] = $methodInfo; + } + + if (count($concreteTraits) <= 1) { + continue; + } + + // Check which traits still have unresolved conflicts + $unresolvedTraits = []; + foreach ($concreteTraits as $traitName => $methodInfo) { + $key = strtolower($traitName) . '::' . $lowerMethodName; + if (array_key_exists($key, $insteadofResolutions)) { + continue; + } + + $unresolvedTraits[$traitName] = $methodInfo['name']; + } + + if (count($unresolvedTraits) <= 1) { + continue; + } + + $traitNames = array_keys($unresolvedTraits); + $methodName = reset($unresolvedTraits); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Trait method %s::%s() has not been applied as %s::%s(), because of collision with %s::%s().', + $traitNames[1], + $methodName, + $classReflection->getDisplayName(), + $methodName, + $traitNames[0], + $methodName, + )) + ->identifier('class.traitMethodCollision') + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Classes/ConflictingTraitMethodsRuleTest.php b/tests/PHPStan/Rules/Classes/ConflictingTraitMethodsRuleTest.php new file mode 100644 index 0000000000..4df7c03d3c --- /dev/null +++ b/tests/PHPStan/Rules/Classes/ConflictingTraitMethodsRuleTest.php @@ -0,0 +1,49 @@ + + */ +class ConflictingTraitMethodsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ConflictingTraitMethodsRule( + self::getContainer()->getByType(ReflectionProvider::class), + ); + } + + public function testBug14332(): void + { + $this->analyse([__DIR__ . '/data/bug-14332.php'], [ + [ + 'Trait method Bug14332\MyTrait2::doSomething() has not been applied as Bug14332\FooWithMultipleConflictingTraits::doSomething(), because of collision with Bug14332\MyTrait1::doSomething().', + 20, + ], + [ + 'Trait method Bug14332\MyTrait4::doSomething() has not been applied as Bug14332\FooWithMultipleConflicts::doSomething(), because of collision with Bug14332\MyTrait1::doSomething().', + 75, + ], + [ + 'Trait method Bug14332\MyTrait5::anotherMethod() has not been applied as Bug14332\FooWithMultipleConflicts::anotherMethod(), because of collision with Bug14332\MyTrait4::anotherMethod().', + 75, + ], + [ + 'Trait method Bug14332\MyTrait5::anotherMethod() has not been applied as Bug14332\FooWithPartialResolution::anotherMethod(), because of collision with Bug14332\MyTrait4::anotherMethod().', + 81, + ], + ]); + } + + public function testBug14332Abstract(): void + { + $this->analyse([__DIR__ . '/data/bug-14332-abstract.php'], []); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-14332-abstract.php b/tests/PHPStan/Rules/Classes/data/bug-14332-abstract.php new file mode 100644 index 0000000000..bf50d278e4 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-14332-abstract.php @@ -0,0 +1,21 @@ +