From 8176381681e0030d245812db1eb5a59feb139851 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 4 Oct 2026 03:25:09 +0900 Subject: [PATCH 1/2] Do not check the body of an impure method against an inherited @pure-unless-* tag --- src/Rules/Pure/FunctionPurityCheck.php | 5 +- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 23 +++++ .../Pure/data/pure-unless-impure-override.php | 90 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php index 94d265f6732..912fa7510f7 100644 --- a/src/Rules/Pure/FunctionPurityCheck.php +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -154,13 +154,14 @@ public function check( } $errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription)); - } elseif ($pureUnlessCallableParamNames !== [] || $pureUnlessParameterPassedParamNames !== []) { + } elseif (!$isPure->no() && ($pureUnlessCallableParamNames !== [] || $pureUnlessParameterPassedParamNames !== [])) { // A function declared @pure-unless-callable-is-impure is pure except // for the flagged callables, so its body is checked for purity while // the flagged callables' own invocations are exempt. The same holds for // @pure-unless-parameter-passed: writing through the flagged by-ref // parameter is not an impure point, so the rest of the body still has - // to be pure for the conditional verdict to hold. + // to be pure for the conditional verdict to hold. A method marked + // @phpstan-impure that inherits either tag makes no such promise. $errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription)); } elseif ($isPure->no()) { if ( diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index f67e9172b60..8f4f525d27e 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -438,4 +438,27 @@ public function testPureUnlessParameterPassed(): void ]); } + public function testPureUnlessImpureOverride(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/pure-unless-impure-override.php'], [ + [ + 'Method PureUnlessImpureOverride\ImpureReplacerWithoutSideEffects::replace() is marked as impure but does not have any side effects.', + 57, + ], + [ + 'Method PureUnlessImpureOverride\ImpureReplacerWithoutSideEffects::map() is marked as impure but does not have any side effects.', + 65, + ], + [ + 'Impure echo in pure method PureUnlessImpureOverride\InheritingReplacer::replace().', + 77, + ], + [ + 'Impure echo in pure method PureUnlessImpureOverride\InheritingReplacer::map().', + 85, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php b/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php new file mode 100644 index 00000000000..fe275ad9116 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php @@ -0,0 +1,90 @@ + Date: Sun, 4 Oct 2026 14:32:40 +0900 Subject: [PATCH 2/2] Do not inherit @pure-unless-* tags into a method marked @phpstan-pure or @phpstan-impure --- src/PhpDoc/ResolvedPhpDocBlock.php | 11 +++++-- src/Rules/Pure/FunctionPurityCheck.php | 5 ++- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 16 +++++++--- .../Pure/data/pure-unless-impure-override.php | 32 +++++++++++++++++-- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index e1026770cef..8c05f711ab7 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -287,8 +287,15 @@ public function merge(ResolvedPhpDocBlock $parent, InheritedPhpDocParameterMappi $result->paramTags = self::mergeParamTags($this->getParamTags(), $parent, $parameterMapping, $parentClass); $result->paramOutTags = self::mergeParamOutTags($this->getParamOutTags(), $parent, $parameterMapping, $parentClass); $result->paramsImmediatelyInvokedCallable = self::mergeParamsImmediatelyInvokedCallable($this->getParamsImmediatelyInvokedCallable(), $parent, $parameterMapping); - $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); - $result->paramsPureUnlessParameterPassed = self::mergeParamsPureUnlessParameterPassed($this->getParamsPureUnlessParameterPassed(), $parent, $parameterMapping); + // @phpstan-pure / @phpstan-impure on the method itself replaces the parent's + // conditional purity, so the @pure-unless-* tags are inherited only without them. + if ($this->isPure() === null) { + $result->paramsPureUnlessCallableIsImpure = self::mergeParamsPureUnlessCallableIsImpure($this->getParamsPureUnlessCallableIsImpure(), $parent, $parameterMapping); + $result->paramsPureUnlessParameterPassed = self::mergeParamsPureUnlessParameterPassed($this->getParamsPureUnlessParameterPassed(), $parent, $parameterMapping); + } else { + $result->paramsPureUnlessCallableIsImpure = $this->getParamsPureUnlessCallableIsImpure(); + $result->paramsPureUnlessParameterPassed = $this->getParamsPureUnlessParameterPassed(); + } $result->paramClosureThisTags = self::mergeParamClosureThisTags($this->getParamClosureThisTags(), $parent, $parameterMapping, $parentClass); $result->returnTag = self::mergeReturnTags($this->getReturnTag(), $declaringClass, $parent, $parameterMapping, $parentClass); $result->throwsTag = self::mergeThrowsTags($this->getThrowsTag(), $parent, $parameterMapping); diff --git a/src/Rules/Pure/FunctionPurityCheck.php b/src/Rules/Pure/FunctionPurityCheck.php index 912fa7510f7..94d265f6732 100644 --- a/src/Rules/Pure/FunctionPurityCheck.php +++ b/src/Rules/Pure/FunctionPurityCheck.php @@ -154,14 +154,13 @@ public function check( } $errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription)); - } elseif (!$isPure->no() && ($pureUnlessCallableParamNames !== [] || $pureUnlessParameterPassedParamNames !== [])) { + } elseif ($pureUnlessCallableParamNames !== [] || $pureUnlessParameterPassedParamNames !== []) { // A function declared @pure-unless-callable-is-impure is pure except // for the flagged callables, so its body is checked for purity while // the flagged callables' own invocations are exempt. The same holds for // @pure-unless-parameter-passed: writing through the flagged by-ref // parameter is not an impure point, so the rest of the body still has - // to be pure for the conditional verdict to hold. A method marked - // @phpstan-impure that inherits either tag makes no such promise. + // to be pure for the conditional verdict to hold. $errors = array_merge($errors, $this->reportImpurePoints($impurePoints, $pureUnlessCallableParamNames, $functionDescription)); } elseif ($isPure->no()) { if ( diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 8f4f525d27e..30801ca2cdf 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -444,19 +444,27 @@ public function testPureUnlessImpureOverride(): void $this->analyse([__DIR__ . '/data/pure-unless-impure-override.php'], [ [ 'Method PureUnlessImpureOverride\ImpureReplacerWithoutSideEffects::replace() is marked as impure but does not have any side effects.', - 57, + 58, ], [ 'Method PureUnlessImpureOverride\ImpureReplacerWithoutSideEffects::map() is marked as impure but does not have any side effects.', - 65, + 66, ], [ 'Impure echo in pure method PureUnlessImpureOverride\InheritingReplacer::replace().', - 77, + 78, ], [ 'Impure echo in pure method PureUnlessImpureOverride\InheritingReplacer::map().', - 85, + 86, + ], + [ + 'Method PureUnlessImpureOverride\PureReplacer::replace() is marked as pure but parameter $count is passed by reference.', + 102, + ], + [ + 'Possibly impure call to a callable in pure method PureUnlessImpureOverride\PureReplacer::map().', + 115, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php b/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php index fe275ad9116..cf15156d48c 100644 --- a/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php +++ b/tests/PHPStan/Rules/Pure/data/pure-unless-impure-override.php @@ -23,8 +23,9 @@ final class ImpureReplacer implements Replacer { /** - * The method is marked impure, so its body is not checked for purity - * although it inherits @pure-unless-parameter-passed. + * @phpstan-impure on the method replaces the inherited + * @pure-unless-parameter-passed, so the body is not checked for purity. + * MethodSignatureRule reports the override instead. * * @phpstan-impure */ @@ -88,3 +89,30 @@ public function map(callable $cb, string $subject): string } } + +final class PureReplacer implements Replacer +{ + + /** + * @phpstan-pure on the method replaces the inherited + * @pure-unless-parameter-passed, so writing to $count is not exempt. + * + * @phpstan-pure + */ + public function replace(string $subject, int &$count = 0): string + { + return $subject; + } + + /** + * @phpstan-pure on the method replaces the inherited + * @pure-unless-callable-is-impure, so invoking $cb is not exempt. + * + * @phpstan-pure + */ + public function map(callable $cb, string $subject): string + { + return $cb($subject); + } + +}