From 652d98c9f23346a14ec9b4c46cda49fc7a6a3acb Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 15:33:40 +0100 Subject: [PATCH 01/13] Sanity check `#[RequiresPhp]` value and range --- .../AttributeRequiresPhpVersionRule.php | 58 ++++++++++++++----- .../AttributeRequiresPhpVersionRuleTest.php | 32 ++++++++++ .../data/requires-php-version-invalid.php | 17 ++++++ .../data/requires-php-version-mismatch.php | 24 ++++++++ 4 files changed, 118 insertions(+), 13 deletions(-) create mode 100644 tests/Rules/PHPUnit/data/requires-php-version-invalid.php create mode 100644 tests/Rules/PHPUnit/data/requires-php-version-mismatch.php diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 899ff1c..bd18a3a 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -2,12 +2,16 @@ namespace PHPStan\Rules\PHPUnit; +use Composer\Semver\Constraint\ConstraintInterface; +use Composer\Semver\VersionParser; use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassMethodNode; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPUnit\Framework\TestCase; +use UnexpectedValueException; use function count; use function is_numeric; use function sprintf; @@ -18,6 +22,8 @@ class AttributeRequiresPhpVersionRule implements Rule { + private ConstraintInterface $phpstanVersionConstraint; + private PHPUnitVersion $PHPUnitVersion; private TestMethodsHelper $testMethodsHelper; @@ -30,12 +36,16 @@ class AttributeRequiresPhpVersionRule implements Rule public function __construct( PHPUnitVersion $PHPUnitVersion, TestMethodsHelper $testMethodsHelper, - bool $deprecationRulesInstalled + bool $deprecationRulesInstalled, + PhpVersion $phpVersion ) { $this->PHPUnitVersion = $PHPUnitVersion; $this->testMethodsHelper = $testMethodsHelper; $this->deprecationRulesInstalled = $deprecationRulesInstalled; + + $parser = new VersionParser(); + $this->phpstanVersionConstraint = $parser->parseConstraints($phpVersion->getVersionString()); } public function getNodeType(): string @@ -56,6 +66,7 @@ public function processNode(Node $node, Scope $scope): array } $errors = []; + $parser = new VersionParser(); foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { @@ -63,28 +74,49 @@ public function processNode(Node $node, Scope $scope): array } if ( - !is_numeric($args[0]) + is_numeric($args[0]) ) { + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->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(); + } + continue; } - if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { + try { + $testPhpVersionConstraint = $parser->parseConstraints($args[0]); + } catch (UnexpectedValueException $e) { $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.'), + sprintf($e->getMessage()), ) ->identifier('phpunit.attributeRequiresPhpVersion') ->build(); + + continue; + } + + if ($this->phpstanVersionConstraint->matches($testPhpVersionConstraint)) { + continue; } + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement will always evaluate to false.'), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); } return $errors; diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 92d9715..61100a8 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\PHPUnit; +use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPStan\Type\FileTypeMapper; @@ -12,6 +13,8 @@ final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase { + private int $phpVersion = 80500; + private ?int $phpunitMajorVersion; private ?int $phpunitMinorVersion; @@ -78,6 +81,34 @@ public function testRuleOnPHPUnit13(): void ]); } + public function testPhpVersionMismatch(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version-mismatch.php'], [ + [ + 'Version requirement will always evaluate to false.', + 12, + ], + ]); + } + + public function testInvalidPhpVersion(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 4; + $this->deprecationRulesInstalled = false; + + $this->analyse([__DIR__ . '/data/requires-php-version-invalid.php'], [ + [ + 'Could not parse version constraint abc: Invalid version string "abc"', + 12, + ], + ]); + } + protected function getRule(): Rule { $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); @@ -89,6 +120,7 @@ protected function getRule(): Rule $phpunitVersion, ), $this->deprecationRulesInstalled, + new PhpVersion($this->phpVersion), ); } diff --git a/tests/Rules/PHPUnit/data/requires-php-version-invalid.php b/tests/Rules/PHPUnit/data/requires-php-version-invalid.php new file mode 100644 index 0000000..a3d57b4 --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version-invalid.php @@ -0,0 +1,17 @@ +=8.0')] + public function testFoo(): void { + + } +} From 5e26ee1439f725101cc540b8160c09dcb18d950c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 15:35:54 +0100 Subject: [PATCH 02/13] simplify diff --- .../AttributeRequiresPhpVersionRule.php | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index bd18a3a..68ad3ca 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -74,33 +74,27 @@ public function processNode(Node $node, Scope $scope): array } if ( - is_numeric($args[0]) + !is_numeric($args[0]) ) { - if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->yes()) { - $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement is missing operator.'), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); - } elseif ( - $this->deprecationRulesInstalled - && $this->PHPUnitVersion->deprecatesPhpversionAttributeWithoutOperator()->yes() - ) { + + try { + $testPhpVersionConstraint = $parser->parseConstraints($args[0]); + } catch (UnexpectedValueException $e) { $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement without operator is deprecated.'), + sprintf($e->getMessage()), ) ->identifier('phpunit.attributeRequiresPhpVersion') ->build(); + + continue; } - continue; - } + if ($this->phpstanVersionConstraint->matches($testPhpVersionConstraint)) { + continue; + } - try { - $testPhpVersionConstraint = $parser->parseConstraints($args[0]); - } catch (UnexpectedValueException $e) { $errors[] = RuleErrorBuilder::message( - sprintf($e->getMessage()), + sprintf('Version requirement will always evaluate to false.'), ) ->identifier('phpunit.attributeRequiresPhpVersion') ->build(); @@ -108,15 +102,22 @@ public function processNode(Node $node, Scope $scope): array continue; } - if ($this->phpstanVersionConstraint->matches($testPhpVersionConstraint)) { - continue; + if ($this->PHPUnitVersion->requiresPhpversionAttributeWithOperator()->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(); } - - $errors[] = RuleErrorBuilder::message( - sprintf('Version requirement will always evaluate to false.'), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); } return $errors; From d7edde968e2b94fdc6575abe0d467d0f11149019 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 8 Dec 2025 10:24:22 +0100 Subject: [PATCH 03/13] 1:1 reimplement php-version detection --- .../AttributeRequiresPhpVersionRule.php | 50 ++++++++++++------- .../AttributeRequiresPhpVersionRuleTest.php | 2 +- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 68ad3ca..0ac6f64 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -4,6 +4,10 @@ use Composer\Semver\Constraint\ConstraintInterface; use Composer\Semver\VersionParser; +use PharIo\Version\UnsupportedVersionConstraintException; +use PharIo\Version\Version; +use PharIo\Version\VersionConstraint; +use PharIo\Version\VersionConstraintParser; use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassMethodNode; @@ -14,6 +18,8 @@ use UnexpectedValueException; use function count; use function is_numeric; +use function method_exists; +use function preg_match; use function sprintf; /** @@ -21,8 +27,10 @@ */ class AttributeRequiresPhpVersionRule implements Rule { + private const VERSION_COMPARISON = "/(?P!=|<|<=|<>|=|==|>|>=)?\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m"; - private ConstraintInterface $phpstanVersionConstraint; + + private Version $phpstanPhpVersion; private PHPUnitVersion $PHPUnitVersion; @@ -44,8 +52,7 @@ public function __construct( $this->testMethodsHelper = $testMethodsHelper; $this->deprecationRulesInstalled = $deprecationRulesInstalled; - $parser = new VersionParser(); - $this->phpstanVersionConstraint = $parser->parseConstraints($phpVersion->getVersionString()); + $this->phpstanPhpVersion = new Version($phpVersion->getVersionString()); } public function getNodeType(): string @@ -66,7 +73,7 @@ public function processNode(Node $node, Scope $scope): array } $errors = []; - $parser = new VersionParser(); + $parser = new VersionConstraintParser(); foreach ($reflectionMethod->getAttributesByName('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { @@ -76,21 +83,28 @@ public function processNode(Node $node, Scope $scope): array if ( !is_numeric($args[0]) ) { - try { - $testPhpVersionConstraint = $parser->parseConstraints($args[0]); - } catch (UnexpectedValueException $e) { - $errors[] = RuleErrorBuilder::message( - sprintf($e->getMessage()), - ) - ->identifier('phpunit.attributeRequiresPhpVersion') - ->build(); - - continue; - } - - if ($this->phpstanVersionConstraint->matches($testPhpVersionConstraint)) { - continue; + $testPhpVersionConstraint = $parser->parse($args[0]); + + if ($testPhpVersionConstraint->complies($this->phpstanPhpVersion)) { + continue; + } + } catch (UnsupportedVersionConstraintException $e) { + if (preg_match(self::VERSION_COMPARISON, $args[0], $matches) > 0) { + $operator = $matches['operator'] !== '' ? $matches['operator'] : '>='; + + if (version_compare($this->phpstanPhpVersion->getVersionString(), $matches['version'], $operator)) { + continue; + } + } else { + $errors[] = RuleErrorBuilder::message( + sprintf($e->getMessage()), + ) + ->identifier('phpunit.attributeRequiresPhpVersion') + ->build(); + + continue; + } } $errors[] = RuleErrorBuilder::message( diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 61100a8..259dcd1 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -103,7 +103,7 @@ public function testInvalidPhpVersion(): void $this->analyse([__DIR__ . '/data/requires-php-version-invalid.php'], [ [ - 'Could not parse version constraint abc: Invalid version string "abc"', + 'Version constraint abc is not supported.', 12, ], ]); From 3781402c4aafd52288ef37b272d765a8cc685691 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 8 Dec 2025 10:24:54 +0100 Subject: [PATCH 04/13] cs --- .../AttributeRequiresPhpVersionRule.php | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 0ac6f64..1e708bb 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -2,11 +2,8 @@ namespace PHPStan\Rules\PHPUnit; -use Composer\Semver\Constraint\ConstraintInterface; -use Composer\Semver\VersionParser; use PharIo\Version\UnsupportedVersionConstraintException; use PharIo\Version\Version; -use PharIo\Version\VersionConstraint; use PharIo\Version\VersionConstraintParser; use PhpParser\Node; use PHPStan\Analyser\Scope; @@ -15,20 +12,20 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPUnit\Framework\TestCase; -use UnexpectedValueException; use function count; use function is_numeric; use function method_exists; use function preg_match; use function sprintf; +use function version_compare; /** * @implements Rule */ class AttributeRequiresPhpVersionRule implements Rule { - private const VERSION_COMPARISON = "/(?P!=|<|<=|<>|=|==|>|>=)?\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m"; + private const VERSION_COMPARISON = "/(?P!=|<|<=|<>|=|==|>|>=)?\s*(?P[\d\.-]+(dev|(RC|alpha|beta)[\d\.])?)[ \t]*\r?$/m"; private Version $phpstanPhpVersion; @@ -90,13 +87,7 @@ public function processNode(Node $node, Scope $scope): array continue; } } catch (UnsupportedVersionConstraintException $e) { - if (preg_match(self::VERSION_COMPARISON, $args[0], $matches) > 0) { - $operator = $matches['operator'] !== '' ? $matches['operator'] : '>='; - - if (version_compare($this->phpstanPhpVersion->getVersionString(), $matches['version'], $operator)) { - continue; - } - } else { + if (preg_match(self::VERSION_COMPARISON, $args[0], $matches) <= 0) { $errors[] = RuleErrorBuilder::message( sprintf($e->getMessage()), ) @@ -105,6 +96,12 @@ public function processNode(Node $node, Scope $scope): array continue; } + + $operator = $matches['operator'] !== '' ? $matches['operator'] : '>='; + + if (version_compare($this->phpstanPhpVersion->getVersionString(), $matches['version'], $operator)) { + continue; + } } $errors[] = RuleErrorBuilder::message( From aa842d7295217f19aacf9900ce3a741264fc81be Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 13 Dec 2025 09:55:53 +0100 Subject: [PATCH 05/13] more tests --- .../AttributeRequiresPhpVersionRule.php | 2 + .../AttributeRequiresPhpVersionRuleTest.php | 12 +++++ .../data/requires-php-version-mismatch.php | 48 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 1e708bb..ccf49b6 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -81,12 +81,14 @@ public function processNode(Node $node, Scope $scope): array !is_numeric($args[0]) ) { try { + // check composer like version constraints, e.g. ^1 or ~2 $testPhpVersionConstraint = $parser->parse($args[0]); if ($testPhpVersionConstraint->complies($this->phpstanPhpVersion)) { continue; } } catch (UnsupportedVersionConstraintException $e) { + // test php-src builtin operators as in version_compare() if (preg_match(self::VERSION_COMPARISON, $args[0], $matches) <= 0) { $errors[] = RuleErrorBuilder::message( sprintf($e->getMessage()), diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 259dcd1..4d300e0 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -92,6 +92,18 @@ public function testPhpVersionMismatch(): void 'Version requirement will always evaluate to false.', 12, ], + [ + 'Version requirement will always evaluate to false.', + 20, + ], + [ + 'Version requirement will always evaluate to false.', + 28, + ], + [ + 'Version requirement will always evaluate to false.', + 36, + ], ]); } diff --git a/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php b/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php index 0572e71..b9aa2f6 100644 --- a/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php +++ b/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php @@ -15,6 +15,30 @@ public function testFoo(): void { } } +class RequiresPhp5Caret extends TestCase +{ + #[RequiresPhp('^5.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp5Tilde extends TestCase +{ + #[RequiresPhp('~5.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp5Star extends TestCase +{ + #[RequiresPhp('5.*')] + public function testFoo(): void { + + } +} + class RequiresPhp8 extends TestCase { #[RequiresPhp('>=8.0')] @@ -22,3 +46,27 @@ public function testFoo(): void { } } + +class RequiresPhp8Caret extends TestCase +{ + #[RequiresPhp('^8.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp8Tilde extends TestCase +{ + #[RequiresPhp('~8.0')] + public function testFoo(): void { + + } +} + +class RequiresPhp8Star extends TestCase +{ + #[RequiresPhp('8.*')] + public function testFoo(): void { + + } +} From a0e6c857a089923c0203b0185d3e54d705e70078 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 17 Dec 2025 07:59:24 +0100 Subject: [PATCH 06/13] add more tests --- .../data/requires-php-version-mismatch.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php b/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php index b9aa2f6..8d611aa 100644 --- a/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php +++ b/tests/Rules/PHPUnit/data/requires-php-version-mismatch.php @@ -7,6 +7,14 @@ use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\RequiresPhp; +class RequiresPhpLowerEqual85a extends TestCase +{ + #[RequiresPhp('<= 8.5')] + public function testFoo(): void { + + } +} + class RequiresPhp5 extends TestCase { #[RequiresPhp('< 7.0')] @@ -39,6 +47,38 @@ public function testFoo(): void { } } +class RequiresPhpLowerEqual84 extends TestCase +{ + #[RequiresPhp('<= 8.4')] + public function testFoo(): void { + + } +} + +class RequiresPhpLowerEqual85 extends TestCase +{ + #[RequiresPhp('<= 8.5')] + public function testFoo(): void { + + } +} + +class RequiresPhp83 extends TestCase +{ + #[RequiresPhp('8.3.*')] + public function testFoo(): void { + + } +} + +class RequiresPhp85 extends TestCase +{ + #[RequiresPhp('8.5.*')] + public function testFoo(): void { + + } +} + class RequiresPhp8 extends TestCase { #[RequiresPhp('>=8.0')] From 6d9b13782f3d082ab5b485784cee763f48852577 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:01:54 +0200 Subject: [PATCH 07/13] Update composer.json --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 308327f..4165994 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "keywords": ["static analysis"], "require": { "php": "^7.4 || ^8.0", + "phar-io/version": "^3.2", "phpstan/phpstan": "^2.1.48" }, "conflict": { From d0c0069eb856d105812434b790f644e421fdbb8c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:02:00 +0200 Subject: [PATCH 08/13] Update AttributeRequiresPhpVersionRule.php --- src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index ccf49b6..55244c5 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -77,19 +77,20 @@ public function processNode(Node $node, Scope $scope): array continue; } + $versionRequirement = $args[0]; if ( - !is_numeric($args[0]) + !is_numeric($versionRequirement) ) { try { // check composer like version constraints, e.g. ^1 or ~2 - $testPhpVersionConstraint = $parser->parse($args[0]); + $testPhpVersionConstraint = $parser->parse($versionRequirement); if ($testPhpVersionConstraint->complies($this->phpstanPhpVersion)) { continue; } } catch (UnsupportedVersionConstraintException $e) { // test php-src builtin operators as in version_compare() - if (preg_match(self::VERSION_COMPARISON, $args[0], $matches) <= 0) { + if (preg_match(self::VERSION_COMPARISON, $versionRequirement, $matches) <= 0) { $errors[] = RuleErrorBuilder::message( sprintf($e->getMessage()), ) From 6a07cdb62f01412042b196e65a5ed54fdd9585f9 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:04:49 +0200 Subject: [PATCH 09/13] Update AttributeRequiresPhpVersionRuleTest.php --- .../AttributeRequiresPhpVersionRuleTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index 4d300e0..f7d96e1 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -104,6 +104,22 @@ public function testPhpVersionMismatch(): void 'Version requirement will always evaluate to false.', 36, ], + [ + 'Version requirement will always evaluate to false.', + 44, + ], + [ + 'Version requirement will always evaluate to false.', + 52, + ], + [ + 'Version requirement will always evaluate to false.', + 60, + ], + [ + 'Version requirement will always evaluate to false.', + 68, + ], ]); } From 03cd4342bd613371d8343e4dd141bdedcfe35271 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:06:46 +0200 Subject: [PATCH 10/13] cs --- src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 55244c5..d9410d8 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -14,7 +14,6 @@ use PHPUnit\Framework\TestCase; use function count; use function is_numeric; -use function method_exists; use function preg_match; use function sprintf; use function version_compare; From d42f8a22ec088d1fb86a35908b2d0ab667f71f11 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:11:04 +0200 Subject: [PATCH 11/13] Update AttributeRequiresPhpVersionRule.php --- src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index d9410d8..5666dbc 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -76,6 +76,9 @@ public function processNode(Node $node, Scope $scope): array continue; } + // the following block is mimicing PHPUnit version parsing + // see https://github.com/sebastianbergmann/phpunit/blob/43c2cd7b96ee1e800b35e4df23b419a88b53111d/src/Metadata/Version/Requirement.php + $versionRequirement = $args[0]; if ( !is_numeric($versionRequirement) From e53e34b96daeb97d072e81f7160a9609ba0df1aa Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:46:03 +0200 Subject: [PATCH 12/13] Warn about incomplete versions in `#[RequiresPhp]` --- .../AttributeRequiresPhpVersionRule.php | 35 +++++++++++++++++-- src/Rules/PHPUnit/PHPUnitVersion.php | 5 +++ .../AttributeRequiresPhpVersionRuleTest.php | 32 +++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 5666dbc..4739314 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -33,22 +33,28 @@ class AttributeRequiresPhpVersionRule implements Rule private TestMethodsHelper $testMethodsHelper; /** - * When phpstan-deprecation-rules is installed, it reports deprecated usages. + * When phpstan-deprecation-rules is installed, rule reports deprecated usages. */ private bool $deprecationRulesInstalled; + /** + * Whether warnings about incomplete versions are allowed to be emitted + */ + private bool $warnAboutIncompleteVersion; + public function __construct( PHPUnitVersion $PHPUnitVersion, TestMethodsHelper $testMethodsHelper, bool $deprecationRulesInstalled, - PhpVersion $phpVersion + PhpVersion $phpVersion, + bool $warnAboutIncompleteVersion = true ) { $this->PHPUnitVersion = $PHPUnitVersion; $this->testMethodsHelper = $testMethodsHelper; $this->deprecationRulesInstalled = $deprecationRulesInstalled; - $this->phpstanPhpVersion = new Version($phpVersion->getVersionString()); + $this->warnAboutIncompleteVersion = $warnAboutIncompleteVersion; } public function getNodeType(): string @@ -80,6 +86,15 @@ public function processNode(Node $node, Scope $scope): array // see https://github.com/sebastianbergmann/phpunit/blob/43c2cd7b96ee1e800b35e4df23b419a88b53111d/src/Metadata/Version/Requirement.php $versionRequirement = $args[0]; + + if ($this->warnAboutIncompleteVersion($versionRequirement)) { + $errors[] = RuleErrorBuilder::message( + sprintf('Version requirement is incomplete.'), + ) + ->identifier('phpunit.incompletePhpVersion') + ->build(); + } + if ( !is_numeric($versionRequirement) ) { @@ -139,4 +154,18 @@ public function processNode(Node $node, Scope $scope): array return $errors; } + // see https://github.com/sebastianbergmann/phpunit/issues/6451 + function warnAboutIncompleteVersion(string $versionRequirement): bool + { + if (!$this->warnAboutIncompleteVersion) { + return false; + } + + if (!$this->PHPUnitVersion->warnsAboutIncompleteVersion()->yes()) { + return false; + } + + return substr_count($versionRequirement, '.') !== 2; + } + } diff --git a/src/Rules/PHPUnit/PHPUnitVersion.php b/src/Rules/PHPUnit/PHPUnitVersion.php index 56eb755..f614585 100644 --- a/src/Rules/PHPUnit/PHPUnitVersion.php +++ b/src/Rules/PHPUnit/PHPUnitVersion.php @@ -62,6 +62,11 @@ public function deprecatesPhpversionAttributeWithoutOperator(): TrinaryLogic return $this->minVersion(12, 4); } + public function warnsAboutIncompleteVersion(): TrinaryLogic + { + return $this->minVersion(12, 5); + } + private function minVersion(int $major, int $minor): TrinaryLogic { if ($this->majorVersion === null || $this->minorVersion === null) { diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index f7d96e1..fa35f11 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -21,6 +21,8 @@ final class AttributeRequiresPhpVersionRuleTest extends RuleTestCase private bool $deprecationRulesInstalled = true; + private bool $warnAboutIncompleteVersion = false; + public function testRuleOnPHPUnitUnknown(): void { $this->phpunitMajorVersion = null; @@ -137,6 +139,35 @@ public function testInvalidPhpVersion(): void ]); } + public function testNoWarnAboutIncompleteVersionInOldPhpunit(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 0; + $this->deprecationRulesInstalled = false; + $this->warnAboutIncompleteVersion = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], []); + } + + public function testWarnAboutIncompleteVersion(): void + { + $this->phpunitMajorVersion = 12; + $this->phpunitMinorVersion = 5; + $this->deprecationRulesInstalled = false; + $this->warnAboutIncompleteVersion = true; + + $this->analyse([__DIR__ . '/data/requires-php-version.php'], [ + [ + 'Version requirement is incomplete.', + 12, + ], + [ + 'Version requirement is incomplete.', + 20, + ], + ]); + } + protected function getRule(): Rule { $phpunitVersion = new PHPUnitVersion($this->phpunitMajorVersion, $this->phpunitMinorVersion); @@ -149,6 +180,7 @@ protected function getRule(): Rule ), $this->deprecationRulesInstalled, new PhpVersion($this->phpVersion), + $this->warnAboutIncompleteVersion ); } From 40895e3b135917e10eaaf0dec78cd2078a0a7ef2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 17:47:36 +0200 Subject: [PATCH 13/13] cs --- src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php | 1 + tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 4739314..fe388b6 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -16,6 +16,7 @@ use function is_numeric; use function preg_match; use function sprintf; +use function substr_count; use function version_compare; /** diff --git a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php index fa35f11..3dc7cc7 100644 --- a/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php +++ b/tests/Rules/PHPUnit/AttributeRequiresPhpVersionRuleTest.php @@ -180,7 +180,7 @@ protected function getRule(): Rule ), $this->deprecationRulesInstalled, new PhpVersion($this->phpVersion), - $this->warnAboutIncompleteVersion + $this->warnAboutIncompleteVersion, ); }