From 9461c7e2fa408434461a58996f5f9935c40887ae Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 15:33:40 +0100 Subject: [PATCH 1/4] Sanity check `#[RequiresPhp]` value and range --- composer.json | 1 + .../AttributeRequiresPhpVersionRule.php | 58 ++++++++++++++----- .../AttributeRequiresPhpVersionRuleTest.php | 32 ++++++++++ .../data/requires-php-version-invalid.php | 17 ++++++ .../data/requires-php-version-mismatch.php | 24 ++++++++ 5 files changed, 119 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/composer.json b/composer.json index 39d7a030..cb3ad850 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,7 @@ ], "require": { "php": "^7.4 || ^8.0", + "composer/semver": "^3.4", "phpstan/phpstan": "^2.1.32" }, "conflict": { diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index f106bf8a..57eedd52 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 method_exists; @@ -19,6 +23,8 @@ class AttributeRequiresPhpVersionRule implements Rule { + private ConstraintInterface $phpstanVersionConstraint; + private PHPUnitVersion $PHPUnitVersion; private TestMethodsHelper $testMethodsHelper; @@ -31,12 +37,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 @@ -62,6 +72,7 @@ public function processNode(Node $node, Scope $scope): array } $errors = []; + $parser = new VersionParser(); foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { @@ -69,28 +80,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 92d9715c..61100a85 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 00000000..a3d57b43 --- /dev/null +++ b/tests/Rules/PHPUnit/data/requires-php-version-invalid.php @@ -0,0 +1,17 @@ +=8.0')] + public function testFoo(): void { + + } +} From 03d1ecd88963ccbc311aa810b1eb71503c450216 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 5 Dec 2025 15:35:54 +0100 Subject: [PATCH 2/4] 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 57eedd52..7f6f5abf 100644 --- a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php +++ b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php @@ -80,33 +80,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(); @@ -114,15 +108,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 6983f956e71fe119f1abe19b4516806aeb0ff3e6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 8 Dec 2025 10:24:22 +0100 Subject: [PATCH 3/4] 1:1 reimplement php-version detection --- composer.json | 2 +- .../AttributeRequiresPhpVersionRule.php | 49 ++++++++++++------- .../AttributeRequiresPhpVersionRuleTest.php | 2 +- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index cb3ad850..20a78554 100644 --- a/composer.json +++ b/composer.json @@ -7,7 +7,7 @@ ], "require": { "php": "^7.4 || ^8.0", - "composer/semver": "^3.4", + "phar-io/version": "^3.2", "phpstan/phpstan": "^2.1.32" }, "conflict": { diff --git a/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php b/src/Rules/PHPUnit/AttributeRequiresPhpVersionRule.php index 7f6f5abf..0aa143be 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; @@ -15,6 +19,7 @@ use function count; use function is_numeric; use function method_exists; +use function preg_match; use function sprintf; /** @@ -22,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; @@ -45,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 @@ -72,7 +78,7 @@ public function processNode(Node $node, Scope $scope): array } $errors = []; - $parser = new VersionParser(); + $parser = new VersionConstraintParser(); foreach ($reflectionMethod->getAttributes('PHPUnit\Framework\Attributes\RequiresPhp') as $attr) { $args = $attr->getArguments(); if (count($args) !== 1) { @@ -82,21 +88,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 61100a85..259dcd11 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 3f7b29da4130a9e2e8b09f3881e429cb7fded580 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 8 Dec 2025 10:24:54 +0100 Subject: [PATCH 4/4] 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 0aa143be..a03a7c84 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; @@ -95,13 +92,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()), ) @@ -110,6 +101,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(