From a7bfb488c3cbf10067c5b50908e0c4388d72024d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 4 Dec 2025 09:20:58 +0100 Subject: [PATCH 1/4] Implement AttributeRequiresPhpVersionRule --- rules.neon | 7 ++ .../AttributeRequiresPhpVersionRule.php | 99 +++++++++++++++++++ src/Rules/PHPUnit/PHPUnitVersion.php | 32 +++++- src/Rules/PHPUnit/PHPUnitVersionDetector.php | 7 +- src/Rules/PHPUnit/TestMethodsHelper.php | 12 +++ .../AttributeRequiresPhpVersionRuleTest.php | 87 ++++++++++++++++ .../PHPUnit/DataProviderDataRuleTest.php | 2 +- .../DataProviderDeclarationRuleTest.php | 2 +- .../PHPUnit/data/requires-php-version.php | 24 +++++ 9 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php create mode 100644 tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php create mode 100644 tests/Rules/PHPUnit/data/requires-php-version.php diff --git a/rules.neon b/rules.neon index 8272f47a..8469bd1e 100644 --- a/rules.neon +++ b/rules.neon @@ -25,6 +25,13 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\PHPUnit\AttributeRequiresPhpVersionRule + arguments: + deprecationRulesInstalled: %deprecationRulesInstalled% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\PHPUnit\AssertEqualsIsDiscouragedRule diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php new file mode 100644 index 00000000..6474f530 --- /dev/null +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -0,0 +1,99 @@ + + */ +class AttributeRequiresPhpVersionRule implements Rule +{ + + private PHPUnitVersion $PHPUnitVersion; + + private TestMethodsHelper $testMethodsHelper; + + /** + * When phpstan-deprecation-rules is installed, it reports deprecated usages. + */ + private bool $deprecationRulesInstalled; + + public function __construct( + PHPUnitVersion $PHPUnitVersion, + TestMethodsHelper $testMethodsHelper, + bool $deprecationRulesInstalled + ) + { + $this->PHPUnitVersion = $PHPUnitVersion; + $this->testMethodsHelper = $testMethodsHelper; + $this->deprecationRulesInstalled = $deprecationRulesInstalled; + } + + public function getNodeType(): string + { + return InClassMethodNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + if ($classReflection === null || $classReflection->is(TestCase::class) === false) { + return []; + } + + $reflectionMethod = $this->testMethodsHelper->getTestMethodReflection($classReflection, $node->getMethodReflection(), $scope); + if ($reflectionMethod === null) { + return []; + } + + /** @phpstan-ignore function.alreadyNarrowedType */ + if (!method_exists($reflectionMethod, 'getAttributes')) { + return []; + } + + $errors = []; + foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { + $args = $attr->getArguments(); + if (count($args) !== 1) { + continue; + } + + if ( + !is_numeric($args[0]) + ) { + continue; + } + + if ($this->PHPUnitVersion->notSupportsPhpversionAttributeWithoutOperator()->yes()) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement is missing operator.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } elseif ( + $this->deprecationRulesInstalled + && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() + ) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement without operator is deprecated.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + } + + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index b7259a84..1ddc26f5 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -9,9 +9,12 @@ class PHPUnitVersion private ?int $majorVersion; - public function __construct(?int $majorVersion) + private ?int $minorVersion; + + public function __construct(?int $majorVersion, ?int $minorVersion) { $this->majorVersion = $majorVersion; + $this->minorVersion = $minorVersion; } public function supportsDataProviderAttribute(): TrinaryLogic @@ -46,4 +49,31 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); } + public function notSupportsPhpversionAttributeWithoutOperator(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); + } + + public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic + { + return $this->minVersion(12, 4); + } + + private function minVersion(int $major, int $minor): TrinaryLogic + { + if ($this->majorVersion === null || $this->minorVersion === null) { + return TrinaryLogic::createMaybe(); + } + + if ($this->majorVersion > $major) { + return TrinaryLogic::createYes(); + } + + if ($this->majorVersion === $major && $this->minorVersion >= $minor) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createNo(); + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersionDetector.php b/src/Rules/PHPUnit/PHPUnitVersionDetector.php index f0e2c4b9..b82caaf4 100644 --- a/src/Rules/PHPUnit/PHPUnitVersionDetector.php +++ b/src/Rules/PHPUnit/PHPUnitVersionDetector.php @@ -23,6 +23,7 @@ public function __construct(ReflectionProvider $reflectionProvider) public function createPHPUnitVersion(): PHPUnitVersion { $majorVersion = null; + $minorVersion = null; if ($this->reflectionProvider->hasClass(TestCase::class)) { $testCase = $this->reflectionProvider->getClass(TestCase::class); $file = $testCase->getFileName(); @@ -35,14 +36,16 @@ public function createPHPUnitVersion(): PHPUnitVersion $json = json_decode($composerJson, true); $version = $json['extra']['branch-alias']['dev-main'] ?? null; if ($version !== null) { - $majorVersion = (int) explode('.', $version)[0]; + $versionParts = explode('.', $version); + $majorVersion = (int) $versionParts[0]; + $minorVersion = (int) $versionParts[1]; } } } } } - return new PHPUnitVersion($majorVersion); + return new PHPUnitVersion($majorVersion, $minorVersion); } } diff --git a/src/Rules/PHPUnit/TestMethodsHelper.php b/src/Rules/PHPUnit/TestMethodsHelper.php index 5eb274e0..a215c8f4 100644 --- a/src/Rules/PHPUnit/TestMethodsHelper.php +++ b/src/Rules/PHPUnit/TestMethodsHelper.php @@ -5,6 +5,7 @@ use PHPStan\Analyser\Scope; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\MethodReflection; use PHPStan\Type\FileTypeMapper; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -27,6 +28,17 @@ public function __construct( $this->PHPUnitVersion = $PHPUnitVersion; } + public function getTestMethodReflection(ClassReflection $classReflection, MethodReflection $methodReflection, Scope $scope): ?ReflectionMethod + { + foreach ($this->getTestMethods($classReflection, $scope) as $testMethod) { + if ($testMethod->getName() === $methodReflection->getName()) { + return $testMethod; + } + } + + return null; + } + /** * @return array */ diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php new file mode 100644 index 00000000..04b166dd --- /dev/null +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -0,0 +1,87 @@ + + */ +final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase +{ + + private int $phpunitMajorVersion; + + private int $phpunitMinorVersion; + + private bool $deprecationRulesInstalled = true; + + public function testRuleOnPHPUnit115(): void + { + $this->phpunitMajorVersion = 11; + $this->phpunitMinorVersion = 5; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit123(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 3; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit124DeprecationsOn(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement without operator is deprecated.', + 12, + ], + ]); + } + + public function testRuleOnPHPUnit124DeprecationsOff(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testRuleOnPHPUnit13(): void + { + $this->phpunitMajorVersion = 13; + $this->phpunitMinorVersion = 0; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement is missing operator.', + 12, + ], + ]); + } + + protected function getRule(): Rule + { + $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); + + return new AttributeRequiresPhpVersionRule( + $phpunitVersion, + new TestMethodsHelper( + self::getContainer()->getByType(FileTypeMapper::class), + $phpunitVersion, + ), + $this->deprecationRulesInstalled, + ); + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php index cca88e7f..012fce70 100644 --- a/tests/Rules/PHPUnit/DataProviderDataRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDataRuleTest.php @@ -22,7 +22,7 @@ class DataProviderDataRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = $this->createReflectionProvider(); - $phpunitVersion = new PHPUnitVersion($this->phpunitVersion); + $phpunitVersion = new PHPUnitVersion($this->phpunitVersion, 0); /** @var list> $rules */ $rules = [ diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php index 2bf9d870..f63c9e65 100644 --- a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -24,7 +24,7 @@ protected function getRule(): Rule $reflection, self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getService('defaultAnalysisParser'), - new PHPUnitVersion($this->phpunitVersion) + new PHPUnitVersion($this->phpunitVersion, 0) ), true, true diff --git a/tests/Rules/PHPUnit/data/requires-php-version.php b/tests/Rules/PHPUnit/data/requires-php-version.php new file mode 100644 index 00000000..5550edff --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version.php @@ -0,0 +1,24 @@ +=8.0')] + public function testHappyPath(): void { + + } +} From 07b590b2df68042e02acc915940155b8c3970b30 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 08:23:28 +0100 Subject: [PATCH 2/4] prevent double negation --- src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php | 2 +- src/Rules/PHPUnit/PHPUnitVersion.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 6474f530..f106bf8a 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -74,7 +74,7 @@ public function processNode(Node $node, Scope $scope): array continue; } - if ($this->PHPUnitVersion->notSupportsPhpversionAttributeWithoutOperator()->yes()) { + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { $errors[] = RuleErrorBuilder::message( sprintf('Version requirement is missing operator.'), ) diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index 1ddc26f5..74558266 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -49,7 +49,7 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->majorVersion >= 11); } - public function notSupportsPhpversionAttributeWithoutOperator(): TrinaryLogic + public function requiresPhpversionAttributeWithOperator(): TrinaryLogic { return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); } From f258583cec2ba54fb7905d260eeae209451d9af1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 08:24:33 +0100 Subject: [PATCH 3/4] kill mutations --- .../PHPUnit/AttributeRequiresPhpVersionRuleTest.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 04b166dd..92d9715c 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -12,12 +12,20 @@ final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase { - private int $phpunitMajorVersion; + private ?int $phpunitMajorVersion; - private int $phpunitMinorVersion; + private ?int $phpunitMinorVersion; private bool $deprecationRulesInstalled = true; + public function testRuleOnPHPUnitUnknown(): void + { + $this->phpunitMajorVersion = null; + $this->phpunitMinorVersion = null; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + public function testRuleOnPHPUnit115(): void { $this->phpunitMajorVersion = 11; From 68f8f4d845f6242166f4e82bf076a7d393af4920 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 08:28:31 +0100 Subject: [PATCH 4/4] Update PHPUnitVersion.php --- src/Rules/PHPUnit/PHPUnitVersion.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index 74558266..56eb7558 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -51,6 +51,9 @@ public function supportsNamedArgumentsInDataProvider(): TrinaryLogic public function requiresPhpversionAttributeWithOperator(): TrinaryLogic { + if ($this->majorVersion === null) { + return TrinaryLogic::createMaybe(); + } return TrinaryLogic::createFromBoolean($this->majorVersion >= 13); }