From 7fe129a58bfe1fda6129f63b58610adea586851a Mon Sep 17 00:00:00 2001 From: Takuya Aramaki Date: Mon, 13 Oct 2025 14:23:07 +0900 Subject: [PATCH 1/2] Invalidate static expressions when a non-static expression is called --- src/Analyser/MutatingScope.php | 61 +++++++++++++++++++ src/Analyser/NodeScopeResolver.php | 9 ++- ...rictComparisonOfDifferentTypesRuleTest.php | 5 ++ .../Rules/Comparison/data/bug-13416.php | 61 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13416.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 5af1a88189..3f824a3d53 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3608,6 +3608,67 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } + public function invalidateStaticMembers(string $className): self + { + if (!$this->reflectionProvider->hasClass($className)) { + return $this; + } + + $classReflection = $this->reflectionProvider->getClass($className); + $classNamesToInvalidate = [strtolower($className)]; + 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 8ec4836e4f..f1c2686539 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3173,9 +3173,16 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $scope = $result->getScope(); if ($methodReflection !== null) { - if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { + $hasSideEffects = $methodReflection->hasSideEffects()->yes(); + if ($hasSideEffects || $methodReflection->getName() === '__construct') { $this->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true); + if ($hasSideEffects) { + $classNames = $scope->getType($normalizedExpr->var)->getObjectClassNames(); + foreach ($classNames as $className) { + $scope = $scope->invalidateStaticMembers($className); + } + } } if ($parametersAcceptor !== null && !$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 9971f74981..0a43483051 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1045,4 +1045,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 0000000000..007eb5dfbc --- /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' + ); + } +} From 74fcfd718fbd7aed94ceb1dc6cb23ab4e86f54c0 Mon Sep 17 00:00:00 2001 From: Takuya Aramaki Date: Sun, 8 Feb 2026 21:07:10 +0900 Subject: [PATCH 2/2] Pass `Node\Expr` rather than class name to `invalidateStaticMembers` --- src/Analyser/MutatingScope.php | 17 ++++++++--------- src/Analyser/NodeScopeResolver.php | 5 +---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 3f824a3d53..ceecb7fa76 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3608,16 +3608,15 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } - public function invalidateStaticMembers(string $className): self + public function invalidateStaticMembers(Expr $var): self { - if (!$this->reflectionProvider->hasClass($className)) { - return $this; - } - - $classReflection = $this->reflectionProvider->getClass($className); - $classNamesToInvalidate = [strtolower($className)]; - foreach ($classReflection->getParents() as $parentClass) { - $classNamesToInvalidate[] = strtolower($parentClass->getName()); + $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; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index f1c2686539..2c16ea84ff 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3178,10 +3178,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $this->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true); if ($hasSideEffects) { - $classNames = $scope->getType($normalizedExpr->var)->getObjectClassNames(); - foreach ($classNames as $className) { - $scope = $scope->invalidateStaticMembers($className); - } + $scope = $scope->invalidateStaticMembers($normalizedExpr->var); } } if ($parametersAcceptor !== null && !$methodReflection->isStatic()) {