From d02ed5c43e3d261913d3f29bd0830398e76cc83c Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 4 Oct 2026 14:57:44 +0900 Subject: [PATCH] Report a docblock that combines a purity tag with a @pure-unless-* tag --- conf/bleedingEdge.neon | 1 + conf/config.level2.neon | 5 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + src/Rules/Pure/ConflictingPurityTagsRule.php | 87 ++++++++++++++ .../Pure/ConflictingPurityTagsRuleTest.php | 54 +++++++++ .../Pure/data/conflicting-purity-tags.php | 110 ++++++++++++++++++ 7 files changed, 259 insertions(+) create mode 100644 src/Rules/Pure/ConflictingPurityTagsRule.php create mode 100644 tests/PHPStan/Rules/Pure/ConflictingPurityTagsRuleTest.php create mode 100644 tests/PHPStan/Rules/Pure/data/conflicting-purity-tags.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 91201231116..07279dcc11d 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -32,3 +32,4 @@ parameters: staticVariablesFromUsages: true unusedVariable: true unusedParameters: true + reportConflictingPurityTags: true diff --git a/conf/config.level2.neon b/conf/config.level2.neon index bb615c07c34..d73bd5ad003 100644 --- a/conf/config.level2.neon +++ b/conf/config.level2.neon @@ -19,6 +19,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues% PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule: phpstan.rules.rule: %featureToggles.checkDynamicConstantNameValues% + PHPStan\Rules\Pure\ConflictingPurityTagsRule: + phpstan.rules.rule: %featureToggles.reportConflictingPurityTags% services: - @@ -32,3 +34,6 @@ services: - class: PHPStan\Rules\Constants\ValueAssignedToGlobalConstantRule + + - + class: PHPStan\Rules\Pure\ConflictingPurityTagsRule diff --git a/conf/config.neon b/conf/config.neon index 52860b21fe9..4f4a84abb61 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -64,6 +64,7 @@ parameters: staticVariablesFromUsages: false unusedVariable: false unusedParameters: false + reportConflictingPurityTags: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 3bae9593991..8cb06ded798 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -61,6 +61,7 @@ parametersSchema: staticVariablesFromUsages: bool() unusedVariable: bool() unusedParameters: bool() + reportConflictingPurityTags: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Rules/Pure/ConflictingPurityTagsRule.php b/src/Rules/Pure/ConflictingPurityTagsRule.php new file mode 100644 index 00000000000..5a157349739 --- /dev/null +++ b/src/Rules/Pure/ConflictingPurityTagsRule.php @@ -0,0 +1,87 @@ + + */ +final class ConflictingPurityTagsRule implements Rule +{ + + public function __construct(private FileTypeMapper $fileTypeMapper) + { + } + + public function getNodeType(): string + { + return Node\FunctionLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof Node\Stmt\ClassMethod) { + if (!$scope->isInClass()) { + return []; + } + $functionName = $node->name->name; + $description = sprintf('Method %s::%s()', $scope->getClassReflection()->getDisplayName(), $functionName); + $identifier = 'pureMethod.conflictingPurityTags'; + } elseif ($node instanceof Node\Stmt\Function_) { + $functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\'); + $description = sprintf('Function %s()', $functionName); + $identifier = 'pureFunction.conflictingPurityTags'; + } else { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $functionName, + $docComment->getText(), + ); + + $isPure = $resolvedPhpDoc->isPure(); + if ($isPure === null) { + return []; + } + + $errors = []; + foreach ([ + '@pure-unless-callable-is-impure' => $resolvedPhpDoc->getParamsPureUnlessCallableIsImpure(), + '@pure-unless-parameter-passed' => $resolvedPhpDoc->getParamsPureUnlessParameterPassed(), + ] as $tagName => $parameters) { + foreach (array_keys($parameters) as $parameterName) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s is marked as %s, which conflicts with %s for parameter $%s.', + $description, + $isPure ? 'pure' : 'impure', + $tagName, + $parameterName, + ))->identifier($identifier)->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Pure/ConflictingPurityTagsRuleTest.php b/tests/PHPStan/Rules/Pure/ConflictingPurityTagsRuleTest.php new file mode 100644 index 00000000000..a66f52d4461 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/ConflictingPurityTagsRuleTest.php @@ -0,0 +1,54 @@ + + */ +class ConflictingPurityTagsRuleTest extends RuleTestCase +{ + + public function getRule(): Rule + { + return new ConflictingPurityTagsRule(self::getContainer()->getByType(FileTypeMapper::class)); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/conflicting-purity-tags.php'], [ + [ + 'Function ConflictingPurityTags\pureWithParameterPassed() is marked as pure, which conflicts with @pure-unless-parameter-passed for parameter $count.', + 10, + ], + [ + 'Function ConflictingPurityTags\impureWithCallable() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.', + 20, + ], + [ + 'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.', + 46, + ], + [ + 'Method ConflictingPurityTags\Replacer::both() is marked as impure, which conflicts with @pure-unless-parameter-passed for parameter $count.', + 46, + ], + [ + 'Function ConflictingPurityTags\pureWithCallable() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.', + 86, + ], + [ + 'Function ConflictingPurityTags\prefixedTags() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.', + 96, + ], + [ + 'Function ConflictingPurityTags\pureAndImpure() is marked as pure, which conflicts with @pure-unless-callable-is-impure for parameter $cb.', + 107, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Pure/data/conflicting-purity-tags.php b/tests/PHPStan/Rules/Pure/data/conflicting-purity-tags.php new file mode 100644 index 00000000000..b4bd1e66d59 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/conflicting-purity-tags.php @@ -0,0 +1,110 @@ +