Skip to content

Commit 70b43f2

Browse files
ondrejmirtesclaude
authored andcommitted
Don't invalidate private property expressions of different classes
- When a method with side effects is called, private properties that the method's declaring class cannot access should not be invalidated - Added invalidatingClass parameter to invalidateExpression() and shouldInvalidateExpression() in MutatingScope - Added isPrivatePropertyOfDifferentClass() to check if a property expression refers to a private property declared in a different class than the one whose method triggered the invalidation - New regression test in tests/PHPStan/Analyser/nsrt/bug-13736.php Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9f86e84 commit 70b43f2

File tree

5 files changed

+85
-11
lines changed

5 files changed

+85
-11
lines changed

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ When considering a bug fix that involves checking "is this type a Foo?", first c
236236

237237
When two scopes are merged (e.g. after if/else branches), `MutatingScope::generalizeWith()` must invalidate dependent expressions. If variable `$i` changes, then `$locations[$i]` must be invalidated too. Bugs arise when stale `ExpressionTypeHolder` entries survive scope merges. Fix pattern: in `MutatingScope`, when a root expression changes, skip/invalidate all deep expressions that depend on it.
238238

239+
### MutatingScope: expression invalidation after method calls and private property visibility
240+
241+
When a method with side effects is called, `invalidateExpression()` invalidates tracked expression types that depend on the call target. When `$this` is invalidated, `shouldInvalidateExpression()` also matches `self`, `static`, `parent`, and class name references — so `self::$prop`, `$this->prop`, etc. all get invalidated. However, private properties of the current class cannot be modified by methods declared in a different class (parent/other). The `invalidatingClass` parameter on `invalidateExpression()` and `shouldInvalidateExpression()` enables skipping invalidation for private properties whose declaring class differs from the method's declaring class. This is checked via `isPrivatePropertyOfDifferentClass()`. The pattern mirrors the existing readonly property protection (`isReadonlyPropertyFetch`). Both `NodeScopeResolver` call sites (instance method calls at ~line 3188, static method calls at ~line 3398) pass `$methodReflection->getDeclaringClass()` as the invalidating class.
242+
239243
### Closure::bind() scope leaking into argument evaluation
240244

241245
`NodeScopeResolver::processArgs()` has special handling for `Closure::bind()` and `Closure::bindTo()` calls. When the first argument is a closure/arrow function literal, a `$closureBindScope` is created with `$this` rebound to the second argument's type, and this scope is used to process the closure body. However, this `$closureBindScope` must ONLY be applied when the first argument is actually an `Expr\Closure` or `Expr\ArrowFunction`. If the first argument is a general expression that returns a closure (e.g. `$this->hydrate()`), the expression itself must be evaluated in the original scope — otherwise `$this` in the expression gets incorrectly resolved as the bound object type instead of the current class. The condition at the `$scopeToPass` assignment must check the argument node type.

src/Analyser/MutatingScope.php

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3460,7 +3460,7 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN
34603460
return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType());
34613461
}
34623462

3463-
public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false): self
3463+
public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self
34643464
{
34653465
$expressionTypes = $this->expressionTypes;
34663466
$nativeExpressionTypes = $this->nativeExpressionTypes;
@@ -3469,7 +3469,7 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require
34693469

34703470
foreach ($expressionTypes as $exprString => $exprTypeHolder) {
34713471
$exprExpr = $exprTypeHolder->getExpr();
3472-
if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters)) {
3472+
if (!$this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters, $invalidatingClass)) {
34733473
continue;
34743474
}
34753475

@@ -3484,14 +3484,14 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require
34843484
continue;
34853485
}
34863486
$firstExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr();
3487-
if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $firstExpr, $this->getNodeKey($firstExpr))) {
3487+
if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $firstExpr, $this->getNodeKey($firstExpr), false, $invalidatingClass)) {
34883488
$invalidated = true;
34893489
continue;
34903490
}
34913491
foreach ($holders as $holder) {
34923492
$conditionalTypeHolders = $holder->getConditionExpressionTypeHolders();
34933493
foreach ($conditionalTypeHolders as $conditionalTypeHolderExprString => $conditionalTypeHolder) {
3494-
if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr(), $conditionalTypeHolderExprString)) {
3494+
if ($this->shouldInvalidateExpression($exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr(), $conditionalTypeHolderExprString, false, $invalidatingClass)) {
34953495
$invalidated = true;
34963496
continue 3;
34973497
}
@@ -3524,7 +3524,7 @@ public function invalidateExpression(Expr $expressionToInvalidate, bool $require
35243524
);
35253525
}
35263526

3527-
private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false): bool
3527+
private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool
35283528
{
35293529
if ($requireMoreCharacters && $exprStringToInvalidate === $exprString) {
35303530
return false;
@@ -3570,9 +3570,33 @@ private function shouldInvalidateExpression(string $exprStringToInvalidate, Expr
35703570
return false;
35713571
}
35723572

3573+
if (
3574+
$invalidatingClass !== null
3575+
&& $requireMoreCharacters
3576+
&& $this->isPrivatePropertyOfDifferentClass($expr, $invalidatingClass)
3577+
) {
3578+
return false;
3579+
}
3580+
35733581
return true;
35743582
}
35753583

3584+
private function isPrivatePropertyOfDifferentClass(Expr $expr, ClassReflection $invalidatingClass): bool
3585+
{
3586+
if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof PropertyFetch) {
3587+
$propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this);
3588+
if ($propertyReflection === null) {
3589+
return false;
3590+
}
3591+
if (!$propertyReflection->isPrivate()) {
3592+
return false;
3593+
}
3594+
return $propertyReflection->getDeclaringClass()->getName() !== $invalidatingClass->getName();
3595+
}
3596+
3597+
return false;
3598+
}
3599+
35763600
private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): self
35773601
{
35783602
$exprStringToInvalidate = null;

src/Analyser/NodeScopeResolver.php

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3185,7 +3185,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
31853185
if ($methodReflection !== null) {
31863186
if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) {
31873187
$this->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage);
3188-
$scope = $scope->invalidateExpression($normalizedExpr->var, true);
3188+
$scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass());
31893189
}
31903190
if ($parametersAcceptor !== null && !$methodReflection->isStatic()) {
31913191
$selfOutType = $methodReflection->getSelfOutType();
@@ -3395,7 +3395,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
33953395
&& $scope->isInClass()
33963396
&& $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName())
33973397
) {
3398-
$scope = $scope->invalidateExpression(new Variable('this'), true);
3398+
$scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass());
33993399
}
34003400

34013401
if (
@@ -7615,9 +7615,11 @@ private function isScopeConditionallyImpossible(MutatingScope $scope): bool
76157615
$boolVars = [];
76167616
foreach ($scope->getDefinedVariables() as $varName) {
76177617
$varType = $scope->getVariableType($varName);
7618-
if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) {
7619-
$boolVars[] = $varName;
7618+
if (!$varType->isBoolean()->yes() || $varType->isConstantScalarValue()->yes()) {
7619+
continue;
76207620
}
7621+
7622+
$boolVars[] = $varName;
76217623
}
76227624

76237625
if ($boolVars === []) {
@@ -7627,13 +7629,13 @@ private function isScopeConditionallyImpossible(MutatingScope $scope): bool
76277629
// Check if any boolean variable's both truth values lead to contradictions
76287630
foreach ($boolVars as $varName) {
76297631
$varExpr = new Variable($varName);
7630-
7632+
76317633
$truthyScope = $scope->filterByTruthyValue($varExpr);
76327634
$truthyContradiction = $this->scopeHasNeverVariable($truthyScope, $boolVars);
76337635
if (!$truthyContradiction) {
76347636
continue;
76357637
}
7636-
7638+
76377639
$falseyScope = $scope->filterByFalseyValue($varExpr);
76387640
$falseyContradiction = $this->scopeHasNeverVariable($falseyScope, $boolVars);
76397641
if ($falseyContradiction) {

src/Analyser/SpecifiedTypes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use PhpParser\Node\Expr;
66
use PHPStan\Type\Type;
77
use PHPStan\Type\TypeCombinator;
8+
use function array_key_exists;
9+
use function array_merge;
810

911
final class SpecifiedTypes
1012
{
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13736;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class BaseClass {
8+
final public static function assertTrue(mixed $condition, string $message = ''): void
9+
{
10+
11+
}
12+
13+
public function doSomething(): void
14+
{
15+
16+
}
17+
}
18+
19+
class Bug13736Test extends BaseClass
20+
{
21+
private static ?Foo $foo = null;
22+
private ?Foo $instanceFoo = null;
23+
24+
public function testStaticProperty(): void
25+
{
26+
self::$foo = new Foo();
27+
assertType('Bug13736\Foo', self::$foo);
28+
self::assertTrue(true);
29+
assertType('Bug13736\Foo', self::$foo);
30+
}
31+
32+
public function testInstanceProperty(): void
33+
{
34+
$this->instanceFoo = new Foo();
35+
assertType('Bug13736\Foo', $this->instanceFoo);
36+
parent::doSomething();
37+
assertType('Bug13736\Foo', $this->instanceFoo);
38+
}
39+
}
40+
41+
class Foo {
42+
}

0 commit comments

Comments
 (0)