From 0aed75ec7d8c88292453adb57c33985845b6de55 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 8 Oct 2025 21:24:56 +0200 Subject: [PATCH 1/8] Introduce all-methods-are-(im)pure --- bin/functionMetadata_original.php | 162 ------------------ conf/config.neon | 1 + resources/functionMetadata.php | 161 ----------------- src/PhpDoc/PhpDocNodeResolver.php | 10 ++ src/PhpDoc/ResolvedPhpDocBlock.php | 31 ++++ src/Reflection/ClassReflection.php | 17 ++ .../Php/PhpClassReflectionExtension.php | 2 +- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 2 + stubs/Redis.stub | 11 ++ ...rictComparisonOfDifferentTypesRuleTest.php | 5 + .../Rules/Comparison/data/bug-10215.php | 23 +++ 11 files changed, 101 insertions(+), 324 deletions(-) create mode 100644 stubs/Redis.stub create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-10215.php diff --git a/bin/functionMetadata_original.php b/bin/functionMetadata_original.php index 3627f1b0ff..d429d11b59 100644 --- a/bin/functionMetadata_original.php +++ b/bin/functionMetadata_original.php @@ -170,168 +170,6 @@ 'DateTimeImmutable::getTimestamp' => ['hasSideEffects' => false], 'DateTimeImmutable::getTimezone' => ['hasSideEffects' => false], - 'Redis::append' => ['hasSideEffects' => true], - 'Redis::bitcount' => ['hasSideEffects' => true], - 'Redis::bitop' => ['hasSideEffects' => true], - 'Redis::bitpos' => ['hasSideEffects' => true], - 'Redis::blPop' => ['hasSideEffects' => true], - 'Redis::blmove' => ['hasSideEffects' => true], - 'Redis::blmpop' => ['hasSideEffects' => true], - 'Redis::brPop' => ['hasSideEffects' => true], - 'Redis::brpoplpush' => ['hasSideEffects' => true], - 'Redis::bzmpop' => ['hasSideEffects' => true], - 'Redis::bzPopMax' => ['hasSideEffects' => true], - 'Redis::bzPopMin' => ['hasSideEffects' => true], - 'Redis::connect' => ['hasSideEffects' => true], - 'Redis::dbSize' => ['hasSideEffects' => true], - 'Redis::decr' => ['hasSideEffects' => true], - 'Redis::decrBy' => ['hasSideEffects' => true], - 'Redis::del' => ['hasSideEffects' => true], - 'Redis::delete' => ['hasSideEffects' => true], - 'Redis::exists' => ['hasSideEffects' => true], - 'Redis::expire' => ['hasSideEffects' => true], - 'Redis::expireAt' => ['hasSideEffects' => true], - 'Redis::expiretime' => ['hasSideEffects' => true], - 'Redis::flushAll' => ['hasSideEffects' => true], - 'Redis::flushDB' => ['hasSideEffects' => true], - 'Redis::function' => ['hasSideEffects' => true], - 'Redis::geoadd' => ['hasSideEffects' => true], - 'Redis::geodist' => ['hasSideEffects' => true], - 'Redis::geohash' => ['hasSideEffects' => true], - 'Redis::geopos' => ['hasSideEffects' => true], - 'Redis::georadius' => ['hasSideEffects' => true], - 'Redis::georadiusbymember' => ['hasSideEffects' => true], - 'Redis::georadiusbymember_ro' => ['hasSideEffects' => true], - 'Redis::geosearch' => ['hasSideEffects' => true], - 'Redis::geosearchstore' => ['hasSideEffects' => true], - 'Redis::get' => ['hasSideEffects' => true], - 'Redis::getBit' => ['hasSideEffects' => true], - 'Redis::getEx' => ['hasSideEffects' => true], - 'Redis::getDBNum' => ['hasSideEffects' => true], - 'Redis::getDel' => ['hasSideEffects' => true], - 'Redis::getLastError' => ['hasSideEffects' => true], - 'Redis::getMode' => ['hasSideEffects' => true], - 'Redis::getOption' => ['hasSideEffects' => true], - 'Redis::getPersistentID' => ['hasSideEffects' => true], - 'Redis::getRange' => ['hasSideEffects' => true], - 'Redis::lcs' => ['hasSideEffects' => true], - 'Redis::lmpop' => ['hasSideEffects' => true], - 'Redis::getReadTimeout' => ['hasSideEffects' => true], - 'Redis::getset' => ['hasSideEffects' => true], - 'Redis::getTimeout' => ['hasSideEffects' => true], - 'Redis::getTransferredBytes' => ['hasSideEffects' => true], - 'Redis::hDel' => ['hasSideEffects' => true], - 'Redis::hExists' => ['hasSideEffects' => true], - 'Redis::hGet' => ['hasSideEffects' => true], - 'Redis::hGetAll' => ['hasSideEffects' => true], - 'Redis::hIncrBy' => ['hasSideEffects' => true], - 'Redis::hIncrByFloat' => ['hasSideEffects' => true], - 'Redis::hKeys' => ['hasSideEffects' => true], - 'Redis::hLen' => ['hasSideEffects' => true], - 'Redis::hMget' => ['hasSideEffects' => true], - 'Redis::hMset' => ['hasSideEffects' => true], - 'Redis::hRandField' => ['hasSideEffects' => true], - 'Redis::hscan' => ['hasSideEffects' => true], - 'Redis::hSet' => ['hasSideEffects' => true], - 'Redis::hSetNx' => ['hasSideEffects' => true], - 'Redis::hStrLen' => ['hasSideEffects' => true], - 'Redis::hVals' => ['hasSideEffects' => true], - 'Redis::incr' => ['hasSideEffects' => true], - 'Redis::incrBy' => ['hasSideEffects' => true], - 'Redis::incrByFloat' => ['hasSideEffects' => true], - 'Redis::isConnected' => ['hasSideEffects' => true], - 'Redis::keys' => ['hasSideEffects' => true], - 'Redis::lastSave' => ['hasSideEffects' => true], - 'Redis::lInsert' => ['hasSideEffects' => true], - 'Redis::lLen' => ['hasSideEffects' => true], - 'Redis::lMove' => ['hasSideEffects' => true], - 'Redis::lPop' => ['hasSideEffects' => true], - 'Redis::lPos' => ['hasSideEffects' => true], - 'Redis::lPush' => ['hasSideEffects' => true], - 'Redis::lPushx' => ['hasSideEffects' => true], - 'Redis::lSet' => ['hasSideEffects' => true], - 'Redis::lindex' => ['hasSideEffects' => true], - 'Redis::lrange' => ['hasSideEffects' => true], - 'Redis::lrem' => ['hasSideEffects' => true], - 'Redis::ltrim' => ['hasSideEffects' => true], - 'Redis::mget' => ['hasSideEffects' => true], - 'Redis::move' => ['hasSideEffects' => true], - 'Redis::mset' => ['hasSideEffects' => true], - 'Redis::msetnx' => ['hasSideEffects' => true], - 'Redis::pconnect' => ['hasSideEffects' => true], - 'Redis::persist' => ['hasSideEffects' => true], - 'Redis::pexpire' => ['hasSideEffects' => true], - 'Redis::pexpireAt' => ['hasSideEffects' => true], - 'Redis::pexpiretime' => ['hasSideEffects' => true], - 'Redis::rpoplpush' => ['hasSideEffects' => true], - 'Redis::rPush' => ['hasSideEffects' => true], - 'Redis::rPushx' => ['hasSideEffects' => true], - 'Redis::sAdd' => ['hasSideEffects' => true], - 'Redis::sAddArray' => ['hasSideEffects' => true], - 'Redis::scan' => ['hasSideEffects' => true], - 'Redis::scard' => ['hasSideEffects' => true], - 'Redis::script' => ['hasSideEffects' => true], - 'Redis::sDiff' => ['hasSideEffects' => true], - 'Redis::sDiffStore' => ['hasSideEffects' => true], - 'Redis::set' => ['hasSideEffects' => true], - 'Redis::setBit' => ['hasSideEffects' => true], - 'Redis::setRange' => ['hasSideEffects' => true], - 'Redis::setOption' => ['hasSideEffects' => true], - 'Redis::setex' => ['hasSideEffects' => true], - 'Redis::setnx' => ['hasSideEffects' => true], - 'Redis::sInter' => ['hasSideEffects' => true], - 'Redis::sintercard' => ['hasSideEffects' => true], - 'Redis::sInterStore' => ['hasSideEffects' => true], - 'Redis::sismember' => ['hasSideEffects' => true], - 'Redis::sMembers' => ['hasSideEffects' => true], - 'Redis::sMisMember' => ['hasSideEffects' => true], - 'Redis::sMove' => ['hasSideEffects' => true], - 'Redis::sPop' => ['hasSideEffects' => true], - 'Redis::sort' => ['hasSideEffects' => true], - 'Redis::sort_ro' => ['hasSideEffects' => true], - 'Redis::sRandMember' => ['hasSideEffects' => true], - 'Redis::srem' => ['hasSideEffects' => true], - 'Redis::sscan' => ['hasSideEffects' => true], - 'Redis::sUnion' => ['hasSideEffects' => true], - 'Redis::sUnionStore' => ['hasSideEffects' => true], - 'Redis::time' => ['hasSideEffects' => true], - 'Redis::touch' => ['hasSideEffects' => true], - 'Redis::ttl' => ['hasSideEffects' => true], - 'Redis::type' => ['hasSideEffects' => true], - 'Redis::unlink' => ['hasSideEffects' => true], - 'Redis::zAdd' => ['hasSideEffects' => true], - 'Redis::zCard' => ['hasSideEffects' => true], - 'Redis::zCount' => ['hasSideEffects' => true], - 'Redis::zdiff' => ['hasSideEffects' => true], - 'Redis::zdiffstore' => ['hasSideEffects' => true], - 'Redis::zIncrBy' => ['hasSideEffects' => true], - 'Redis::zinter' => ['hasSideEffects' => true], - 'Redis::zintercard' => ['hasSideEffects' => true], - 'Redis::zinterstore' => ['hasSideEffects' => true], - 'Redis::zLexCount' => ['hasSideEffects' => true], - 'Redis::zmpop' => ['hasSideEffects' => true], - 'Redis::zMscore' => ['hasSideEffects' => true], - 'Redis::zPopMax' => ['hasSideEffects' => true], - 'Redis::zPopMin' => ['hasSideEffects' => true], - 'Redis::zRange' => ['hasSideEffects' => true], - 'Redis::zRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRangeByScore' => ['hasSideEffects' => true], - 'Redis::zrangestore' => ['hasSideEffects' => true], - 'Redis::zRandMember' => ['hasSideEffects' => true], - 'Redis::zRank' => ['hasSideEffects' => true], - 'Redis::zRem' => ['hasSideEffects' => true], - 'Redis::zRemRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRemRangeByRank' => ['hasSideEffects' => true], - 'Redis::zRemRangeByScore' => ['hasSideEffects' => true], - 'Redis::zRevRange' => ['hasSideEffects' => true], - 'Redis::zRevRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRevRangeByScore' => ['hasSideEffects' => true], - 'Redis::zRevRank' => ['hasSideEffects' => true], - 'Redis::zscan' => ['hasSideEffects' => true], - 'Redis::zScore' => ['hasSideEffects' => true], - 'Redis::zunion' => ['hasSideEffects' => true], - 'Redis::zunionstore' => ['hasSideEffects' => true], - 'SplDoublyLinkedList::pop' => ['hasSideEffects' => true], 'SplDoublyLinkedList::shift' => ['hasSideEffects' => true], diff --git a/conf/config.neon b/conf/config.neon index 8a5053a750..4b2700e651 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -114,6 +114,7 @@ parameters: universalObjectCratesClasses: - stdClass stubFiles: + - ../stubs/Redis.stub - ../stubs/ReflectionAttribute.stub - ../stubs/ReflectionClassConstant.stub - ../stubs/ReflectionFunctionAbstract.stub diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index 67315e311c..073ac1050f 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -452,167 +452,6 @@ 'NumberFormatter::getPattern' => ['hasSideEffects' => false], 'NumberFormatter::getSymbol' => ['hasSideEffects' => false], 'NumberFormatter::getTextAttribute' => ['hasSideEffects' => false], - 'Redis::append' => ['hasSideEffects' => true], - 'Redis::bitcount' => ['hasSideEffects' => true], - 'Redis::bitop' => ['hasSideEffects' => true], - 'Redis::bitpos' => ['hasSideEffects' => true], - 'Redis::blPop' => ['hasSideEffects' => true], - 'Redis::blmove' => ['hasSideEffects' => true], - 'Redis::blmpop' => ['hasSideEffects' => true], - 'Redis::brPop' => ['hasSideEffects' => true], - 'Redis::brpoplpush' => ['hasSideEffects' => true], - 'Redis::bzPopMax' => ['hasSideEffects' => true], - 'Redis::bzPopMin' => ['hasSideEffects' => true], - 'Redis::bzmpop' => ['hasSideEffects' => true], - 'Redis::connect' => ['hasSideEffects' => true], - 'Redis::dbSize' => ['hasSideEffects' => true], - 'Redis::decr' => ['hasSideEffects' => true], - 'Redis::decrBy' => ['hasSideEffects' => true], - 'Redis::del' => ['hasSideEffects' => true], - 'Redis::delete' => ['hasSideEffects' => true], - 'Redis::exists' => ['hasSideEffects' => true], - 'Redis::expire' => ['hasSideEffects' => true], - 'Redis::expireAt' => ['hasSideEffects' => true], - 'Redis::expiretime' => ['hasSideEffects' => true], - 'Redis::flushAll' => ['hasSideEffects' => true], - 'Redis::flushDB' => ['hasSideEffects' => true], - 'Redis::function' => ['hasSideEffects' => true], - 'Redis::geoadd' => ['hasSideEffects' => true], - 'Redis::geodist' => ['hasSideEffects' => true], - 'Redis::geohash' => ['hasSideEffects' => true], - 'Redis::geopos' => ['hasSideEffects' => true], - 'Redis::georadius' => ['hasSideEffects' => true], - 'Redis::georadiusbymember' => ['hasSideEffects' => true], - 'Redis::georadiusbymember_ro' => ['hasSideEffects' => true], - 'Redis::geosearch' => ['hasSideEffects' => true], - 'Redis::geosearchstore' => ['hasSideEffects' => true], - 'Redis::get' => ['hasSideEffects' => true], - 'Redis::getBit' => ['hasSideEffects' => true], - 'Redis::getDBNum' => ['hasSideEffects' => true], - 'Redis::getDel' => ['hasSideEffects' => true], - 'Redis::getEx' => ['hasSideEffects' => true], - 'Redis::getLastError' => ['hasSideEffects' => true], - 'Redis::getMode' => ['hasSideEffects' => true], - 'Redis::getOption' => ['hasSideEffects' => true], - 'Redis::getPersistentID' => ['hasSideEffects' => true], - 'Redis::getRange' => ['hasSideEffects' => true], - 'Redis::getReadTimeout' => ['hasSideEffects' => true], - 'Redis::getTimeout' => ['hasSideEffects' => true], - 'Redis::getTransferredBytes' => ['hasSideEffects' => true], - 'Redis::getset' => ['hasSideEffects' => true], - 'Redis::hDel' => ['hasSideEffects' => true], - 'Redis::hExists' => ['hasSideEffects' => true], - 'Redis::hGet' => ['hasSideEffects' => true], - 'Redis::hGetAll' => ['hasSideEffects' => true], - 'Redis::hIncrBy' => ['hasSideEffects' => true], - 'Redis::hIncrByFloat' => ['hasSideEffects' => true], - 'Redis::hKeys' => ['hasSideEffects' => true], - 'Redis::hLen' => ['hasSideEffects' => true], - 'Redis::hMget' => ['hasSideEffects' => true], - 'Redis::hMset' => ['hasSideEffects' => true], - 'Redis::hRandField' => ['hasSideEffects' => true], - 'Redis::hSet' => ['hasSideEffects' => true], - 'Redis::hSetNx' => ['hasSideEffects' => true], - 'Redis::hStrLen' => ['hasSideEffects' => true], - 'Redis::hVals' => ['hasSideEffects' => true], - 'Redis::hscan' => ['hasSideEffects' => true], - 'Redis::incr' => ['hasSideEffects' => true], - 'Redis::incrBy' => ['hasSideEffects' => true], - 'Redis::incrByFloat' => ['hasSideEffects' => true], - 'Redis::isConnected' => ['hasSideEffects' => true], - 'Redis::keys' => ['hasSideEffects' => true], - 'Redis::lInsert' => ['hasSideEffects' => true], - 'Redis::lLen' => ['hasSideEffects' => true], - 'Redis::lMove' => ['hasSideEffects' => true], - 'Redis::lPop' => ['hasSideEffects' => true], - 'Redis::lPos' => ['hasSideEffects' => true], - 'Redis::lPush' => ['hasSideEffects' => true], - 'Redis::lPushx' => ['hasSideEffects' => true], - 'Redis::lSet' => ['hasSideEffects' => true], - 'Redis::lastSave' => ['hasSideEffects' => true], - 'Redis::lcs' => ['hasSideEffects' => true], - 'Redis::lindex' => ['hasSideEffects' => true], - 'Redis::lmpop' => ['hasSideEffects' => true], - 'Redis::lrange' => ['hasSideEffects' => true], - 'Redis::lrem' => ['hasSideEffects' => true], - 'Redis::ltrim' => ['hasSideEffects' => true], - 'Redis::mget' => ['hasSideEffects' => true], - 'Redis::move' => ['hasSideEffects' => true], - 'Redis::mset' => ['hasSideEffects' => true], - 'Redis::msetnx' => ['hasSideEffects' => true], - 'Redis::pconnect' => ['hasSideEffects' => true], - 'Redis::persist' => ['hasSideEffects' => true], - 'Redis::pexpire' => ['hasSideEffects' => true], - 'Redis::pexpireAt' => ['hasSideEffects' => true], - 'Redis::pexpiretime' => ['hasSideEffects' => true], - 'Redis::rPush' => ['hasSideEffects' => true], - 'Redis::rPushx' => ['hasSideEffects' => true], - 'Redis::rpoplpush' => ['hasSideEffects' => true], - 'Redis::sAdd' => ['hasSideEffects' => true], - 'Redis::sAddArray' => ['hasSideEffects' => true], - 'Redis::sDiff' => ['hasSideEffects' => true], - 'Redis::sDiffStore' => ['hasSideEffects' => true], - 'Redis::sInter' => ['hasSideEffects' => true], - 'Redis::sInterStore' => ['hasSideEffects' => true], - 'Redis::sMembers' => ['hasSideEffects' => true], - 'Redis::sMisMember' => ['hasSideEffects' => true], - 'Redis::sMove' => ['hasSideEffects' => true], - 'Redis::sPop' => ['hasSideEffects' => true], - 'Redis::sRandMember' => ['hasSideEffects' => true], - 'Redis::sUnion' => ['hasSideEffects' => true], - 'Redis::sUnionStore' => ['hasSideEffects' => true], - 'Redis::scan' => ['hasSideEffects' => true], - 'Redis::scard' => ['hasSideEffects' => true], - 'Redis::script' => ['hasSideEffects' => true], - 'Redis::set' => ['hasSideEffects' => true], - 'Redis::setBit' => ['hasSideEffects' => true], - 'Redis::setOption' => ['hasSideEffects' => true], - 'Redis::setRange' => ['hasSideEffects' => true], - 'Redis::setex' => ['hasSideEffects' => true], - 'Redis::setnx' => ['hasSideEffects' => true], - 'Redis::sintercard' => ['hasSideEffects' => true], - 'Redis::sismember' => ['hasSideEffects' => true], - 'Redis::sort' => ['hasSideEffects' => true], - 'Redis::sort_ro' => ['hasSideEffects' => true], - 'Redis::srem' => ['hasSideEffects' => true], - 'Redis::sscan' => ['hasSideEffects' => true], - 'Redis::time' => ['hasSideEffects' => true], - 'Redis::touch' => ['hasSideEffects' => true], - 'Redis::ttl' => ['hasSideEffects' => true], - 'Redis::type' => ['hasSideEffects' => true], - 'Redis::unlink' => ['hasSideEffects' => true], - 'Redis::zAdd' => ['hasSideEffects' => true], - 'Redis::zCard' => ['hasSideEffects' => true], - 'Redis::zCount' => ['hasSideEffects' => true], - 'Redis::zIncrBy' => ['hasSideEffects' => true], - 'Redis::zLexCount' => ['hasSideEffects' => true], - 'Redis::zMscore' => ['hasSideEffects' => true], - 'Redis::zPopMax' => ['hasSideEffects' => true], - 'Redis::zPopMin' => ['hasSideEffects' => true], - 'Redis::zRandMember' => ['hasSideEffects' => true], - 'Redis::zRange' => ['hasSideEffects' => true], - 'Redis::zRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRangeByScore' => ['hasSideEffects' => true], - 'Redis::zRank' => ['hasSideEffects' => true], - 'Redis::zRem' => ['hasSideEffects' => true], - 'Redis::zRemRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRemRangeByRank' => ['hasSideEffects' => true], - 'Redis::zRemRangeByScore' => ['hasSideEffects' => true], - 'Redis::zRevRange' => ['hasSideEffects' => true], - 'Redis::zRevRangeByLex' => ['hasSideEffects' => true], - 'Redis::zRevRangeByScore' => ['hasSideEffects' => true], - 'Redis::zRevRank' => ['hasSideEffects' => true], - 'Redis::zScore' => ['hasSideEffects' => true], - 'Redis::zdiff' => ['hasSideEffects' => true], - 'Redis::zdiffstore' => ['hasSideEffects' => true], - 'Redis::zinter' => ['hasSideEffects' => true], - 'Redis::zintercard' => ['hasSideEffects' => true], - 'Redis::zinterstore' => ['hasSideEffects' => true], - 'Redis::zmpop' => ['hasSideEffects' => true], - 'Redis::zrangestore' => ['hasSideEffects' => true], - 'Redis::zscan' => ['hasSideEffects' => true], - 'Redis::zunion' => ['hasSideEffects' => true], - 'Redis::zunionstore' => ['hasSideEffects' => true], 'ReflectionAttribute::getArguments' => ['hasSideEffects' => false], 'ReflectionAttribute::getName' => ['hasSideEffects' => false], 'ReflectionAttribute::getTarget' => ['hasSideEffects' => false], diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index ff91a44225..a7540bd30e 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -705,6 +705,16 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool return false; } + public function resolveAllMethodsArePure(PhpDocNode $phpDocNode): bool + { + return count($phpDocNode->getTagsByName('@phpstan-all-methods-pure')) > 0; + } + + public function resolveAllMethodsAreImpure(PhpDocNode $phpDocNode): bool + { + return count($phpDocNode->getTagsByName('@phpstan-all-methods-impure')) > 0; + } + public function resolveIsReadOnly(PhpDocNode $phpDocNode): bool { foreach (['@readonly', '@phan-read-only', '@psalm-readonly', '@phpstan-readonly', '@phpstan-readonly-allow-private-mutation', '@psalm-readonly-allow-private-mutation'] as $tagName) { diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index dc43e9af5e..8e60d513b6 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -139,6 +139,9 @@ final class ResolvedPhpDocBlock /** @var bool|'notLoaded'|null */ private bool|string|null $isPure = 'notLoaded'; + /** @var bool|'notLoaded'|null */ + private bool|string|null $defaultMethodPurity = 'notLoaded'; + private ?bool $isReadOnly = null; private ?bool $isImmutable = null; @@ -233,6 +236,7 @@ public static function createEmpty(): self $self->isInternal = false; $self->isFinal = false; $self->isPure = null; + $self->defaultMethodPurity = null; $self->isReadOnly = false; $self->isImmutable = false; $self->isAllowedPrivateMutation = false; @@ -298,6 +302,7 @@ public function merge(array $parents, array $parentPhpDocBlocks): self $result->isInternal = $this->isInternal(); $result->isFinal = $this->isFinal(); $result->isPure = self::mergePureTags($this->isPure(), $parents); + $result->defaultMethodPurity = $this->getDefaultMethodPurity(); $result->isReadOnly = $this->isReadOnly(); $result->isImmutable = $this->isImmutable(); $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); @@ -418,6 +423,7 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $self->isInternal = $this->isInternal; $self->isFinal = $this->isFinal; $self->isPure = $this->isPure; + $self->defaultMethodPurity = $this->defaultMethodPurity; return $self; } @@ -827,6 +833,31 @@ public function isPure(): ?bool return $this->isPure; } + public function getDefaultMethodPurity(): ?bool + { + if ($this->defaultMethodPurity === 'notLoaded') { + $pure = $this->phpDocNodeResolver->resolveAllMethodsArePure( + $this->phpDocNode, + ); + if ($pure) { + $this->defaultMethodPurity = true; + return $this->defaultMethodPurity; + } + + $impure = $this->phpDocNodeResolver->resolveAllMethodsAreImpure( + $this->phpDocNode, + ); + if ($impure) { + $this->defaultMethodPurity = false; + return $this->defaultMethodPurity; + } + + $this->defaultMethodPurity = null; + } + + return $this->defaultMethodPurity; + } + public function isReadOnly(): bool { return $this->isReadOnly ??= $this->phpDocNodeResolver->resolveIsReadOnly( diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 256dcdd69b..cdd48f5283 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -113,6 +113,9 @@ final class ClassReflection private ?bool $hasConsistentConstructor = null; + /** @var bool|'notLoaded'|null */ + private bool|string|null $defaultMethodPurity = 'notLoaded'; + private ?bool $acceptsNamedArguments = null; private ?TemplateTypeMap $templateTypeMap = null; @@ -1492,6 +1495,20 @@ public function hasConsistentConstructor(): bool return $this->hasConsistentConstructor; } + public function getDefaultMethodPurity(): ?bool + { + if ($this->defaultMethodPurity === 'notLoaded') { + $resolvedPhpDoc = $this->getResolvedPhpDoc(); + if ($resolvedPhpDoc === null) { + $this->defaultMethodPurity = null; + } else { + $this->defaultMethodPurity = $resolvedPhpDoc->getDefaultMethodPurity(); + } + } + + return $this->defaultMethodPurity; + } + public function acceptsNamedArguments(): bool { if ($this->acceptsNamedArguments === null) { diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 37959a7a64..0e9d0b2977 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -893,7 +893,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } - $isPure ??= $resolvedPhpDoc->isPure(); + $isPure ??= $resolvedPhpDoc->isPure() ?? $phpDocBlockClassReflection->getDefaultMethodPurity(); $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index d909dfab72..2a75a6c8c6 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -62,6 +62,8 @@ final class InvalidPHPStanDocTagRule implements Rule '@phpstan-param-immediately-invoked-callable', '@phpstan-param-later-invoked-callable', '@phpstan-param-closure-this', + '@phpstan-all-methods-pure', + '@phpstan-all-methods-impure', ]; public function __construct( diff --git a/stubs/Redis.stub b/stubs/Redis.stub new file mode 100644 index 0000000000..072475482f --- /dev/null +++ b/stubs/Redis.stub @@ -0,0 +1,11 @@ +analyse([__DIR__ . '/data/bug-11019.php'], []); } + public function testBug10215(): void + { + $this->analyse([__DIR__ . '/data/bug-10215.php'], []); + } + public function testBug12946(): void { $this->analyse([__DIR__ . '/data/bug-12946.php'], []); diff --git a/tests/PHPStan/Rules/Comparison/data/bug-10215.php b/tests/PHPStan/Rules/Comparison/data/bug-10215.php new file mode 100644 index 0000000000..50accb912c --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-10215.php @@ -0,0 +1,23 @@ += 8.1 + +namespace Bug10215; + +class CacheManager +{ + public function __construct(private readonly \Redis $redis) + { + } + + public function getCachedValue(string $key, callable $callback): int + { + if (false !== ($value = $this->redis->get($key))) { + return (int) $value; + } + $callback(); + if (false !== ($value = $this->redis->get($key))) { + return (int) $value; + } + + throw new \LogicException('Cache was not filled by callback'); + } +} From 6c1b8a867d661ef82aaeeb04fcf2f13af1ffb3a5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 8 Oct 2025 22:03:18 +0200 Subject: [PATCH 2/8] Rework --- src/PhpDoc/ResolvedPhpDocBlock.php | 23 +++++++++++++++---- .../Php/PhpClassReflectionExtension.php | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 8e60d513b6..5efd3e889f 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -252,10 +252,7 @@ public static function createEmpty(): self */ public function merge(array $parents, array $parentPhpDocBlocks): self { - $className = $this->nameScope !== null ? $this->nameScope->getClassName() : null; - $classReflection = $className !== null && $this->reflectionProvider->hasClass($className) - ? $this->reflectionProvider->getClass($className) - : null; + $classReflection = $this->getClassReflection(); // new property also needs to be added to createEmpty() $result = new self(); @@ -461,6 +458,16 @@ public function getNullableNameScope(): ?NameScope return $this->nameScope; } + private function getClassReflection(): ?ClassReflection + { + $className = $this->nameScope?->getClassName(); + if ($className === null || !$this->reflectionProvider->hasClass($className)) { + return null; + } + + return $this->reflectionProvider->getClass($className); + } + /** * @return array<(string|int), VarTag> */ @@ -827,6 +834,14 @@ public function isPure(): ?bool return $this->isPure; } + if ($this->nameScope !== null) { + $classReflection = $this->getClassReflection(); + if ($classReflection !== null) { + $this->isPure = $classReflection->getDefaultMethodPurity(); + return $this->isPure; + } + } + $this->isPure = null; } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 0e9d0b2977..37959a7a64 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -893,7 +893,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } - $isPure ??= $resolvedPhpDoc->isPure() ?? $phpDocBlockClassReflection->getDefaultMethodPurity(); + $isPure ??= $resolvedPhpDoc->isPure(); $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; From 4145068b1baf3d7b1a497b5b2d1c697672eba2b1 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 9 Oct 2025 09:12:52 +0200 Subject: [PATCH 3/8] Revert "Rework" This reverts commit 9b80946e49e901dcf4d1057af3f9a2133a146952. --- src/PhpDoc/ResolvedPhpDocBlock.php | 23 ++++--------------- .../Php/PhpClassReflectionExtension.php | 2 +- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 5efd3e889f..8e60d513b6 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -252,7 +252,10 @@ public static function createEmpty(): self */ public function merge(array $parents, array $parentPhpDocBlocks): self { - $classReflection = $this->getClassReflection(); + $className = $this->nameScope !== null ? $this->nameScope->getClassName() : null; + $classReflection = $className !== null && $this->reflectionProvider->hasClass($className) + ? $this->reflectionProvider->getClass($className) + : null; // new property also needs to be added to createEmpty() $result = new self(); @@ -458,16 +461,6 @@ public function getNullableNameScope(): ?NameScope return $this->nameScope; } - private function getClassReflection(): ?ClassReflection - { - $className = $this->nameScope?->getClassName(); - if ($className === null || !$this->reflectionProvider->hasClass($className)) { - return null; - } - - return $this->reflectionProvider->getClass($className); - } - /** * @return array<(string|int), VarTag> */ @@ -834,14 +827,6 @@ public function isPure(): ?bool return $this->isPure; } - if ($this->nameScope !== null) { - $classReflection = $this->getClassReflection(); - if ($classReflection !== null) { - $this->isPure = $classReflection->getDefaultMethodPurity(); - return $this->isPure; - } - } - $this->isPure = null; } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 37959a7a64..0e9d0b2977 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -893,7 +893,7 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } - $isPure ??= $resolvedPhpDoc->isPure(); + $isPure ??= $resolvedPhpDoc->isPure() ?? $phpDocBlockClassReflection->getDefaultMethodPurity(); $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; From 0987654cc0861f6c41f422419e2b19862c8b7fb7 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 9 Oct 2025 09:22:03 +0200 Subject: [PATCH 4/8] Try --- src/PhpDoc/PhpDocNodeResolver.php | 4 +- src/PhpDoc/ResolvedPhpDocBlock.php | 43 ++++++++----------- src/Reflection/ClassReflection.php | 17 -------- .../Php/PhpClassReflectionExtension.php | 11 ++++- 4 files changed, 29 insertions(+), 46 deletions(-) diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index a7540bd30e..8159733e21 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -705,12 +705,12 @@ public function resolveIsImpure(PhpDocNode $phpDocNode): bool return false; } - public function resolveAllMethodsArePure(PhpDocNode $phpDocNode): bool + public function resolveAllMethodsPure(PhpDocNode $phpDocNode): bool { return count($phpDocNode->getTagsByName('@phpstan-all-methods-pure')) > 0; } - public function resolveAllMethodsAreImpure(PhpDocNode $phpDocNode): bool + public function resolveAllMethodsImpure(PhpDocNode $phpDocNode): bool { return count($phpDocNode->getTagsByName('@phpstan-all-methods-impure')) > 0; } diff --git a/src/PhpDoc/ResolvedPhpDocBlock.php b/src/PhpDoc/ResolvedPhpDocBlock.php index 8e60d513b6..59b4bc6422 100644 --- a/src/PhpDoc/ResolvedPhpDocBlock.php +++ b/src/PhpDoc/ResolvedPhpDocBlock.php @@ -139,8 +139,9 @@ final class ResolvedPhpDocBlock /** @var bool|'notLoaded'|null */ private bool|string|null $isPure = 'notLoaded'; - /** @var bool|'notLoaded'|null */ - private bool|string|null $defaultMethodPurity = 'notLoaded'; + private ?bool $areAllMethodsPure = null; + + private ?bool $areAllMethodsImpure = null; private ?bool $isReadOnly = null; @@ -236,7 +237,8 @@ public static function createEmpty(): self $self->isInternal = false; $self->isFinal = false; $self->isPure = null; - $self->defaultMethodPurity = null; + $self->areAllMethodsPure = false; + $self->areAllMethodsImpure = false; $self->isReadOnly = false; $self->isImmutable = false; $self->isAllowedPrivateMutation = false; @@ -302,7 +304,8 @@ public function merge(array $parents, array $parentPhpDocBlocks): self $result->isInternal = $this->isInternal(); $result->isFinal = $this->isFinal(); $result->isPure = self::mergePureTags($this->isPure(), $parents); - $result->defaultMethodPurity = $this->getDefaultMethodPurity(); + $result->areAllMethodsPure = $this->areAllMethodsPure(); + $result->areAllMethodsImpure = $this->areAllMethodsImpure(); $result->isReadOnly = $this->isReadOnly(); $result->isImmutable = $this->isImmutable(); $result->isAllowedPrivateMutation = $this->isAllowedPrivateMutation(); @@ -423,7 +426,6 @@ public function changeParameterNamesByMapping(array $parameterNameMapping): self $self->isInternal = $this->isInternal; $self->isFinal = $this->isFinal; $self->isPure = $this->isPure; - $self->defaultMethodPurity = $this->defaultMethodPurity; return $self; } @@ -833,29 +835,18 @@ public function isPure(): ?bool return $this->isPure; } - public function getDefaultMethodPurity(): ?bool + public function areAllMethodsPure(): bool { - if ($this->defaultMethodPurity === 'notLoaded') { - $pure = $this->phpDocNodeResolver->resolveAllMethodsArePure( - $this->phpDocNode, - ); - if ($pure) { - $this->defaultMethodPurity = true; - return $this->defaultMethodPurity; - } - - $impure = $this->phpDocNodeResolver->resolveAllMethodsAreImpure( - $this->phpDocNode, - ); - if ($impure) { - $this->defaultMethodPurity = false; - return $this->defaultMethodPurity; - } - - $this->defaultMethodPurity = null; - } + return $this->areAllMethodsPure ??= $this->phpDocNodeResolver->resolveAllMethodsPure( + $this->phpDocNode, + ); + } - return $this->defaultMethodPurity; + public function areAllMethodsImpure(): bool + { + return $this->areAllMethodsImpure ??= $this->phpDocNodeResolver->resolveAllMethodsImpure( + $this->phpDocNode, + ); } public function isReadOnly(): bool diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index cdd48f5283..256dcdd69b 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -113,9 +113,6 @@ final class ClassReflection private ?bool $hasConsistentConstructor = null; - /** @var bool|'notLoaded'|null */ - private bool|string|null $defaultMethodPurity = 'notLoaded'; - private ?bool $acceptsNamedArguments = null; private ?TemplateTypeMap $templateTypeMap = null; @@ -1495,20 +1492,6 @@ public function hasConsistentConstructor(): bool return $this->hasConsistentConstructor; } - public function getDefaultMethodPurity(): ?bool - { - if ($this->defaultMethodPurity === 'notLoaded') { - $resolvedPhpDoc = $this->getResolvedPhpDoc(); - if ($resolvedPhpDoc === null) { - $this->defaultMethodPurity = null; - } else { - $this->defaultMethodPurity = $resolvedPhpDoc->getDefaultMethodPurity(); - } - } - - return $this->defaultMethodPurity; - } - public function acceptsNamedArguments(): bool { if ($this->acceptsNamedArguments === null) { diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 0e9d0b2977..7320eb3a17 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -893,7 +893,16 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } - $isPure ??= $resolvedPhpDoc->isPure() ?? $phpDocBlockClassReflection->getDefaultMethodPurity(); + $isPure ??= $resolvedPhpDoc->isPure(); + if ($isPure === null) { + $classResolvedPhpDoc = $phpDocBlockClassReflection->getResolvedPhpDoc(); + if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { + $isPure = true; + } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { + $isPure = false; + } + } + $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; From eeaeb3b5bd026231f03a0c0105f4802ff1ffefd3 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Thu, 9 Oct 2025 20:38:38 +0200 Subject: [PATCH 5/8] Add test --- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 31 +++++++++++ .../Rules/Pure/data/all-methods-are-pure.php | 51 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index b5a44ee822..16ce829d02 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -216,6 +216,37 @@ public function testBug12224(): void ]); } + public function testAllMethodsArePure(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/all-methods-are-pure.php'], [ + [ + 'Method AllMethodsArePure\Foo::test() is marked as pure but returns void.', + 10, + ], + [ + 'Method AllMethodsArePure\Foo::pure() is marked as pure but returns void.', + 17, + ], + [ + 'Method AllMethodsArePure\Foo::impure() is marked as impure but does not have any side effects.', + 24, + ], + [ + 'Method AllMethodsArePure\Bar::test() is marked as impure but does not have any side effects.', + 34, + ], + [ + 'Method AllMethodsArePure\Bar::pure() is marked as pure but returns void.', + 41, + ], + [ + 'Method AllMethodsArePure\Bar::impure() is marked as impure but does not have any side effects.', + 48, + ], + ]); + } + public function testBug12382(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php new file mode 100644 index 0000000000..ee400cfe6f --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php @@ -0,0 +1,51 @@ + Date: Thu, 9 Oct 2025 20:44:32 +0200 Subject: [PATCH 6/8] Fix --- src/Analyser/NodeScopeResolver.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 603035e2d1..ecd613ffed 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6993,6 +6993,15 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $varTags = $resolvedPhpDoc->getVarTags(); } + if ($isPure === null && $scope->isInClass()) { + $classResolvedPhpDoc = $scope->getClassReflection()->getResolvedPhpDoc(); + if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { + $isPure = true; + } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { + $isPure = false; + } + } + return [$templateTypeMap, $phpDocParameterTypes, $phpDocImmediatelyInvokedCallableParameters, $phpDocClosureThisTypeParameters, $phpDocReturnType, $phpDocThrowType, $deprecatedDescription, $isDeprecated, $isInternal, $isFinal, $isPure, $acceptsNamedArguments, $isReadOnly, $docComment, $asserts, $selfOutType, $phpDocParameterOutTypes, $varTags, $isAllowedPrivateMutation]; } From 7b8891179e69624d849f6981a36dbb07b3105038 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 10 Oct 2025 16:34:21 +0200 Subject: [PATCH 7/8] Add tests --- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 16 +++++ .../Rules/Pure/data/all-methods-are-pure.php | 61 +++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 16ce829d02..ef5d0fd70f 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -244,6 +244,22 @@ public function testAllMethodsArePure(): void 'Method AllMethodsArePure\Bar::impure() is marked as impure but does not have any side effects.', 48, ], + [ + 'Impure call to method AllMethodsArePure\Test::impure() in pure method AllMethodsArePure\SideEffectPure::nothingWithImpure().', + 78, + ], + [ + 'Method AllMethodsArePure\SideEffectPure::impureWithPure() is marked as impure but does not have any side effects.', + 88, + ], + [ + 'Method AllMethodsArePure\SideEffectImpure::nothingWithPure() is marked as impure but does not have any side effects.', + 101, + ], + [ + 'Impure call to method AllMethodsArePure\Test::impure() in pure method AllMethodsArePure\SideEffectImpure::pureWithImpure().', + 106, + ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php index ee400cfe6f..60541007c2 100644 --- a/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php +++ b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php @@ -49,3 +49,64 @@ function impure(): void { } } + +class Test +{ + /** + * @phpstan-impure + */ + public static function impure(): void + { + } + + /** + * @phpstan-pure + * @throws void + */ + public static function pure(): int + { + return 1; + } +} + +/** + * @phpstan-all-methods-pure + */ +final class SideEffectPure +{ + public function nothingWithImpure(): int { + Test::impure(); + } + public function nothingWithPure(): int { + Test::pure(); + } + /** @phpstan-impure */ + public function impureWithImpure(): int { + Test::impure(); + } + /** @phpstan-impure */ + public function impureWithPure(): int { + Test::pure(); + } +} + +/** + * @phpstan-all-methods-impure + */ +final class SideEffectImpure +{ + public function nothingWithImpure(): int { + Test::impure(); + } + public function nothingWithPure(): int { + Test::pure(); + } + /** @phpstan-pure */ + public function pureWithImpure(): int { + Test::impure(); + } + /** @phpstan-pure */ + public function pureWithPure(): int { + Test::pure(); + } +} From 82c591ae226ba5802a7f03e7d0359a733cd19c5a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 10 Oct 2025 16:56:05 +0200 Subject: [PATCH 8/8] Void --- src/Analyser/NodeScopeResolver.php | 12 ++++- .../Php/PhpClassReflectionExtension.php | 10 +++- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 36 ++++++++----- .../Rules/Pure/data/all-methods-are-pure.php | 54 ++++++++++++++++--- 4 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ecd613ffed..6de50458cd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6993,10 +6993,18 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n $varTags = $resolvedPhpDoc->getVarTags(); } - if ($isPure === null && $scope->isInClass()) { + if ($isPure === null && $node instanceof Node\FunctionLike && $scope->isInClass()) { $classResolvedPhpDoc = $scope->getClassReflection()->getResolvedPhpDoc(); if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { - $isPure = true; + if ( + strtolower($functionName ?? '') === '__construct' + || ( + ($phpDocReturnType === null || !$phpDocReturnType->isVoid()->yes()) + && !$scope->getFunctionType($node->getReturnType(), false, false)->isVoid()->yes() + ) + ) { + $isPure = true; + } } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { $isPure = false; } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 7320eb3a17..0189b64813 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -897,7 +897,15 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla if ($isPure === null) { $classResolvedPhpDoc = $phpDocBlockClassReflection->getResolvedPhpDoc(); if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { - $isPure = true; + if ( + strtolower($methodReflection->getName()) === '__construct' + || ( + ($phpDocReturnType === null || !$phpDocReturnType->isVoid()->yes()) + && !$nativeReturnType->isVoid()->yes() + ) + ) { + $isPure = true; + } } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { $isPure = false; } diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index ef5d0fd70f..ec4d82b466 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -221,44 +221,52 @@ public function testAllMethodsArePure(): void $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/all-methods-are-pure.php'], [ [ - 'Method AllMethodsArePure\Foo::test() is marked as pure but returns void.', - 10, + 'Method AllMethodsArePure\Foo::pureVoid() is marked as pure but returns void.', + 30, ], [ - 'Method AllMethodsArePure\Foo::pure() is marked as pure but returns void.', - 17, + 'Method AllMethodsArePure\Foo::impure() is marked as impure but does not have any side effects.', + 37, ], [ - 'Method AllMethodsArePure\Foo::impure() is marked as impure but does not have any side effects.', - 24, + 'Method AllMethodsArePure\Foo::impureVoid() is marked as impure but does not have any side effects.', + 45, ], [ 'Method AllMethodsArePure\Bar::test() is marked as impure but does not have any side effects.', - 34, + 55, ], [ - 'Method AllMethodsArePure\Bar::pure() is marked as pure but returns void.', - 41, + 'Method AllMethodsArePure\Bar::testVoid() is marked as impure but does not have any side effects.', + 60, + ], + [ + 'Method AllMethodsArePure\Bar::pureVoid() is marked as pure but returns void.', + 75, ], [ 'Method AllMethodsArePure\Bar::impure() is marked as impure but does not have any side effects.', - 48, + 82, + ], + [ + 'Method AllMethodsArePure\Bar::impureVoid() is marked as impure but does not have any side effects.', + 90, ], [ 'Impure call to method AllMethodsArePure\Test::impure() in pure method AllMethodsArePure\SideEffectPure::nothingWithImpure().', - 78, + 120, ], [ 'Method AllMethodsArePure\SideEffectPure::impureWithPure() is marked as impure but does not have any side effects.', - 88, + 130, ], [ 'Method AllMethodsArePure\SideEffectImpure::nothingWithPure() is marked as impure but does not have any side effects.', - 101, + 143, ], [ 'Impure call to method AllMethodsArePure\Test::impure() in pure method AllMethodsArePure\SideEffectImpure::pureWithImpure().', - 106, + 148, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php index 60541007c2..fd172def4e 100644 --- a/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php +++ b/tests/PHPStan/Rules/Pure/data/all-methods-are-pure.php @@ -7,21 +7,42 @@ */ final class Foo { - function test(): void + function test(): int { + return 1; + } + + function testVoid(): void + { + } + + /** + * @phpstan-pure + */ + function pure(): int + { + return 1; } /** * @phpstan-pure */ - function pure(): void + function pureVoid(): void { } /** * @phpstan-impure */ - function impure(): void + function impure(): int + { + return 1; + } + + /** + * @phpstan-impure + */ + function impureVoid(): void { } } @@ -31,21 +52,42 @@ function impure(): void */ final class Bar { - function test(): void + function test(): int { + return 1; + } + + function testVoid(): void + { + } + + /** + * @phpstan-pure + */ + function pure(): int + { + return 1; } /** * @phpstan-pure */ - function pure(): void + function pureVoid(): void { } /** * @phpstan-impure */ - function impure(): void + function impure(): int + { + return 1; + } + + /** + * @phpstan-impure + */ + function impureVoid(): void { } }