From c971a7452bef323e96f82305a17e8ca2d58fdef7 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 31 Jul 2026 23:07:47 +0200 Subject: [PATCH 1/2] Handle test traits in DataProviderAnnotationToAttributeRector Traits have no parent class, so isInTestClass() could never match them and every @dataProvider annotation in a test trait was left behind. Detect traits by a Test/Tests namespace part instead, and drop the now-redundant isClass() guard in the rule. --- .../Fixture/in_test_trait.php.inc | 29 +++++++++++++++++++ ...ataProviderAnnotationToAttributeRector.php | 12 -------- src/NodeAnalyzer/TestsNodeAnalyzer.php | 19 ++++++++++++ 3 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/in_test_trait.php.inc diff --git a/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/in_test_trait.php.inc b/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/in_test_trait.php.inc new file mode 100644 index 00000000..80b3a76c --- /dev/null +++ b/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/in_test_trait.php.inc @@ -0,0 +1,29 @@ + +----- + diff --git a/rules/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector.php b/rules/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector.php index 51d8a6e5..1dced7a3 100644 --- a/rules/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector.php +++ b/rules/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector.php @@ -9,7 +9,6 @@ use PhpParser\Node\Stmt\ClassMethod; use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; -use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ReflectionProvider; use Rector\BetterPhpDocParser\PhpDoc\DoctrineAnnotationTagValueNode; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo; @@ -20,7 +19,6 @@ use Rector\PHPUnit\Enum\PHPUnitAttribute; use Rector\PHPUnit\NodeAnalyzer\TestsNodeAnalyzer; use Rector\Rector\AbstractRector; -use Rector\Reflection\ReflectionResolver; use Rector\ValueObject\PhpVersion; use Rector\VersionBonding\Contract\ComposerPackageConstraintInterface; use Rector\VersionBonding\Contract\MinPhpVersionInterface; @@ -37,7 +35,6 @@ public function __construct( private readonly TestsNodeAnalyzer $testsNodeAnalyzer, private readonly PhpAttributeGroupFactory $phpAttributeGroupFactory, private readonly PhpDocTagRemover $phpDocTagRemover, - private readonly ReflectionResolver $reflectionResolver, private readonly DocBlockUpdater $docBlockUpdater, private readonly PhpDocInfoFactory $phpDocInfoFactory, private readonly ReflectionProvider $reflectionProvider @@ -126,15 +123,6 @@ public function refactor(Node $node): ?Node return null; } - $classReflection = $this->reflectionResolver->resolveClassReflection($node); - if (! $classReflection instanceof ClassReflection) { - return null; - } - - if (! $classReflection->isClass()) { - return null; - } - foreach ($desiredTagValueNodes as $desiredTagValueNode) { if (! $desiredTagValueNode->value instanceof GenericTagValueNode && ! $desiredTagValueNode->value instanceof DoctrineAnnotationTagValueNode) { continue; diff --git a/src/NodeAnalyzer/TestsNodeAnalyzer.php b/src/NodeAnalyzer/TestsNodeAnalyzer.php index b6cc8d8f..6caf99e4 100644 --- a/src/NodeAnalyzer/TestsNodeAnalyzer.php +++ b/src/NodeAnalyzer/TestsNodeAnalyzer.php @@ -35,6 +35,12 @@ public function isInTestClass(Node $node): bool return false; } + // traits have no parent, so the test case check below can never match them; + // fall back to the namespace, as test traits live next to the test cases that use them + if ($classReflection->isTrait()) { + return $this->isInTestsNamespace($classReflection); + } + return array_any( PHPUnitClassName::TEST_CLASSES, fn (string $testCaseObjectClass): bool => $classReflection->is($testCaseObjectClass) @@ -83,6 +89,19 @@ public function isAssertMethodCallName(Node $node, string $name): bool return $this->nodeNameResolver->isName($node->name, $name); } + private function isInTestsNamespace(ClassReflection $classReflection): bool + { + $nameParts = explode('\\', $classReflection->getName()); + + // drop the short trait name, only the namespace matters here + array_pop($nameParts); + + return array_any( + $nameParts, + static fn (string $namePart): bool => in_array($namePart, ['Test', 'Tests'], true) + ); + } + /** * @param string[] $names */ From aa904fd2e2ea50867ec59a0e1e3af0bd4f2c6a6b Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 31 Jul 2026 23:16:39 +0200 Subject: [PATCH 2/2] Narrow test trait detection to public non-static methods A trait method can only be a test method when it is public and non-static. Beyond that, either the trait sits in a Test/Tests namespace or the method carries the "test" prefix. --- .../skip_non_public_trait_method.php.inc | 13 ++++++++ .../Fixture/skip_static_trait_method.php.inc | 13 ++++++++ src/NodeAnalyzer/TestsNodeAnalyzer.php | 31 +++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/skip_non_public_trait_method.php.inc create mode 100644 rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/skip_static_trait_method.php.inc diff --git a/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/skip_non_public_trait_method.php.inc b/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/skip_non_public_trait_method.php.inc new file mode 100644 index 00000000..907a7df0 --- /dev/null +++ b/rules-tests/AnnotationsToAttributes/Rector/ClassMethod/DataProviderAnnotationToAttributeRector/Fixture/skip_non_public_trait_method.php.inc @@ -0,0 +1,13 @@ +isTrait()) { - return $this->isInTestsNamespace($classReflection); + return $this->isInTestTrait($classReflection, $node); } return array_any( @@ -89,6 +88,32 @@ public function isAssertMethodCallName(Node $node, string $name): bool return $this->nodeNameResolver->isName($node->name, $name); } + /** + * Test traits live next to the test cases that use them, so the namespace is the main hint. + * Only public non-static methods can be test methods, and a "test" prefixed one is a test + * method even outside a tests namespace. + */ + private function isInTestTrait(ClassReflection $classReflection, Node $node): bool + { + if (! $node instanceof ClassMethod) { + return $this->isInTestsNamespace($classReflection); + } + + if (! $node->isPublic()) { + return false; + } + + if ($node->isStatic()) { + return false; + } + + if ($this->isInTestsNamespace($classReflection)) { + return true; + } + + return str_starts_with($node->name->toString(), 'test'); + } + private function isInTestsNamespace(ClassReflection $classReflection): bool { $nameParts = explode('\\', $classReflection->getName());