diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 900c3f3fff1..77476522f7d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3656,6 +3656,66 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } + public function invalidateStaticMembers(Expr $var): self + { + $classReflections = $this->getType($var)->getObjectClassReflections(); + $classNamesToInvalidate = []; + foreach ($classReflections as $classReflection) { + $classNamesToInvalidate[] = strtolower($classReflection->getName()); + foreach ($classReflection->getParents() as $parentClass) { + $classNamesToInvalidate[] = strtolower($parentClass->getName()); + } + } + + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $invalidated = false; + $nodeFinder = new NodeFinder(); + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + $expr = $exprTypeHolder->getExpr(); + $found = $nodeFinder->findFirst([$expr], static function (Node $node) use ($classNamesToInvalidate): bool { + if (!$node instanceof Expr\StaticCall && !$node instanceof Expr\StaticPropertyFetch) { + return false; + } + if (!$node->class instanceof Name || !$node->class->isFullyQualified()) { + return false; + } + + return in_array($node->class->toLowerString(), $classNamesToInvalidate, true); + }); + if ($found === null) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; + } + + if (!$invalidated) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): self { if ($this->hasExpressionType($expr)->no()) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 677aa31845f..768d778a05d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -291,6 +291,8 @@ public function __construct( private readonly bool $implicitThrows, #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, + #[AutowiredParameter] + private readonly bool $rememberPossiblyImpureFunctionValues, ) { $earlyTerminatingMethodNames = []; @@ -3215,9 +3217,15 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $scope = $result->getScope(); if ($methodReflection !== null) { - if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { + $shouldInvalidateExpr = $this->rememberPossiblyImpureFunctionValues + ? $methodReflection->hasSideEffects()->yes() + : !$methodReflection->hasSideEffects()->no(); + if ($shouldInvalidateExpr || $methodReflection->getName() === '__construct') { $this->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); + if ($shouldInvalidateExpr) { + $scope = $scope->invalidateStaticMembers($normalizedExpr->var); + } } if ($parametersAcceptor !== null && !$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 81022f20d11..d6eb9665569 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -119,6 +119,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getParameter('rememberPossiblyImpureFunctionValues'), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index b90da3b1c83..7dcecc1b269 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -94,6 +94,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getParameter('rememberPossiblyImpureFunctionValues'), ); } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 14eea964ee8..ce82d639a67 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -835,6 +835,7 @@ private function createAnalyser(): Analyser [], true, $this->shouldTreatPhpDocTypesAsCertain(), + true, ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 5f0fe26c4af..055471055e6 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -139,6 +139,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), + self::getContainer()->getParameter('rememberPossiblyImpureFunctionValues'), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index c47ffc58304..d5283714434 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -72,6 +72,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), + $container->getParameter('rememberPossiblyImpureFunctionValues'), ); } diff --git a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php index 5adbbc52200..d7915ed2cf8 100644 --- a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php +++ b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php @@ -6,6 +6,7 @@ class Foo { + private static int $counter = 0; /** @phpstan-pure */ public function pure(): int @@ -24,6 +25,12 @@ public function impure(): int return rand(0, 1); } + /** @phpstan-pure */ + public static function getCounter(): int + { + return self::$counter; + } + public function test(): void { if ($this->pure() === 1) { @@ -39,6 +46,24 @@ public function test(): void } } + public function testStatic(): void + { + if (self::getCounter() === 1) { + $this->pure(); + assertType('1', self::getCounter()); + } + + if (self::getCounter() === 1) { + $this->maybePure(); + assertType('int', self::getCounter()); + } + + if (self::getCounter() === 1) { + $this->impure(); + assertType('int', self::getCounter()); + } + } + } class FooStatic diff --git a/tests/PHPStan/Analyser/nsrt/remember-possibly-impure-function-values.php b/tests/PHPStan/Analyser/nsrt/remember-possibly-impure-function-values.php index 979a2d7d914..fb3d9448c3c 100644 --- a/tests/PHPStan/Analyser/nsrt/remember-possibly-impure-function-values.php +++ b/tests/PHPStan/Analyser/nsrt/remember-possibly-impure-function-values.php @@ -6,6 +6,7 @@ class Foo { + private static int $counter = 0; /** @phpstan-pure */ public function pure(): int @@ -24,6 +25,11 @@ public function impure(): int return rand(0, 1); } + public static function getCounter(): int + { + return self::$counter; + } + public function test(): void { if ($this->pure() === 1) { @@ -39,6 +45,24 @@ public function test(): void } } + public function testStatic(): void + { + if (self::getCounter() === 1) { + $this->pure(); + assertType('1', self::getCounter()); + } + + if (self::getCounter() === 1) { + $this->maybePure(); + assertType('1', self::getCounter()); + } + + if (self::getCounter() === 1) { + $this->impure(); + assertType('int', self::getCounter()); + } + } + } class FooStatic diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 00fd8dc8a4d..4b28730cf60 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1056,4 +1056,9 @@ public function testBug11609(): void ]); } + public function testBug13416(): void + { + $this->analyse([__DIR__ . '/data/bug-13416.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13416.php b/tests/PHPStan/Rules/Comparison/data/bug-13416.php new file mode 100644 index 00000000000..007eb5dfbc6 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13416.php @@ -0,0 +1,61 @@ + */ + private static array $storage = []; + + /** @phpstan-impure */ + public function insert(): void { + self::$storage[] = $this; + } + + /** + * @return array + * @phpstan-impure + */ + public static function find(): array { + return self::$storage; + } +} + +class AnotherRecord extends MyRecord {} + +class PHPStanMinimalBug { + public function testMinimalBug(): void { + $msg1 = new MyRecord(); + $msg1->insert(); + + assert( + count(MyRecord::find()) === 1, + 'should have 1 record initially' + ); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assert( + count(MyRecord::find()) === 2, + 'should have 2 messages after adding one' + ); + } + + public function testMinimalBugChildClass(): void { + $msg1 = new AnotherRecord(); + $msg1->insert(); + + assert( + count(MyRecord::find()) === 1, + 'should have 1 record initially' + ); + + $msg2 = new AnotherRecord(); + $msg2->insert(); + + assert( + count(MyRecord::find()) === 2, + 'should have 2 messages after adding one' + ); + } +}