Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Contributor

@staabm staabm Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing which came to mind is, that we add another ast travsersal here.

Can this be combined with the already happening ast traversal ?

(Just thinking loud - might not be useful)

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()) {
Expand Down
9 changes: 8 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Copy link
Contributor

@staabm staabm Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might make sense to use getObjectClassReflections() here, so you don't need ReflectionProvider handling in the new invalidate-method.

Might also make sense to move the loop into the invalidate method, so you don't need to re-create several scope objects

foreach ($classNames as $className) {
$scope = $scope->invalidateStaticMembers($className);
}
}
}
if ($parametersAcceptor !== null && !$methodReflection->isStatic()) {
$selfOutType = $methodReflection->getSelfOutType();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1045,4 +1045,9 @@ public function testBug11609(): void
]);
}

public function testBug13416(): void
{
$this->analyse([__DIR__ . '/data/bug-13416.php'], []);
}

}
61 changes: 61 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-13416.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php declare(strict_types=1);

namespace Bug13416;

class MyRecord {
/** @var array<int, self> */
private static array $storage = [];

/** @phpstan-impure */
public function insert(): void {
self::$storage[] = $this;
}

/**
* @return array<int, self>
* @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'
);
}
}
Loading