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
60 changes: 60 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

@staabm staabm Mar 1, 2026

Choose a reason for hiding this comment

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

I think we could return early with return $this; when $classNamesToInvalidate is empty

$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()) {
Expand Down
10 changes: 9 additions & 1 deletion src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ public function __construct(
private readonly bool $implicitThrows,
#[AutowiredParameter]
private readonly bool $treatPhpDocTypesAsCertain,
#[AutowiredParameter]
private readonly bool $rememberPossiblyImpureFunctionValues,
)
{
$earlyTerminatingMethodNames = [];
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe I misread the PR, but it seems the path where $this->rememberPossiblyImpureFunctionValues is false is not tested ?

? $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();
Expand Down
1 change: 1 addition & 0 deletions src/Testing/RuleTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
[],
self::getContainer()->getParameter('exceptions')['implicitThrows'],
$this->shouldTreatPhpDocTypesAsCertain(),
self::getContainer()->getParameter('rememberPossiblyImpureFunctionValues'),
);
}

Expand Down
1 change: 1 addition & 0 deletions src/Testing/TypeInferenceTestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
static::getEarlyTerminatingFunctionCalls(),
$container->getParameter('exceptions')['implicitThrows'],
$container->getParameter('treatPhpDocTypesAsCertain'),
$container->getParameter('rememberPossiblyImpureFunctionValues'),
);
}

Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/AnalyserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,7 @@ private function createAnalyser(): Analyser
[],
true,
$this->shouldTreatPhpDocTypesAsCertain(),
true,
);
$lexer = new Lexer();
$fileAnalyser = new FileAnalyser(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ protected function createNodeScopeResolver(): NodeScopeResolver
[],
self::getContainer()->getParameter('exceptions')['implicitThrows'],
$this->shouldTreatPhpDocTypesAsCertain(),
self::getContainer()->getParameter('rememberPossiblyImpureFunctionValues'),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ protected static function createNodeScopeResolver(): NodeScopeResolver
static::getEarlyTerminatingFunctionCalls(),
$container->getParameter('exceptions')['implicitThrows'],
$container->getParameter('treatPhpDocTypesAsCertain'),
$container->getParameter('rememberPossiblyImpureFunctionValues'),
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

class Foo
{
private static int $counter = 0;

/** @phpstan-pure */
public function pure(): int
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

class Foo
{
private static int $counter = 0;

/** @phpstan-pure */
public function pure(): int
Expand All @@ -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) {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1056,4 +1056,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