From 5e2127c9dcf4fb1e2f52013c5d96677622eabe18 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:34:25 +0000 Subject: [PATCH 1/6] Fix phpstan/phpstan#8985: Expression result remembered on new() - Added expressionHasNewInChain() check in MutatingScope::resolveType() to skip stored expression type lookup when the expression's receiver chain contains a New_ node - New regression test in tests/PHPStan/Analyser/nsrt/bug-8985.php - The root cause was that (new Foo())->method() produced the same expression key regardless of source location, so type narrowing from assert() on one new instance incorrectly applied to subsequent ones --- src/Analyser/MutatingScope.php | 12 ++++++++ tests/PHPStan/Analyser/nsrt/bug-8985.php | 36 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8985.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 122340cc27..967c9e4510 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -971,6 +971,7 @@ private function resolveType(string $exprString, Expr $node): Type !$node instanceof Variable && !$node instanceof Expr\Closure && !$node instanceof Expr\ArrowFunction + && !$this->expressionHasNewInChain($node) && $this->hasExpressionType($node)->yes() ) { return $this->expressionTypes[$exprString]->getType(); @@ -988,6 +989,17 @@ private function resolveType(string $exprString, Expr $node): Type return new MixedType(); } + private function expressionHasNewInChain(Expr $expr): bool + { + if ($expr instanceof MethodCall || $expr instanceof PropertyFetch || $expr instanceof Expr\NullsafeMethodCall || $expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\ArrayDimFetch) { + return $expr->var instanceof Expr\New_ || $this->expressionHasNewInChain($expr->var); + } + if (($expr instanceof Expr\StaticCall || $expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\ClassConstFetch) && $expr->class instanceof Expr) { + return $expr->class instanceof Expr\New_ || $this->expressionHasNewInChain($expr->class); + } + return false; + } + /** * @param callable(Type): ?bool $typeCallback */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-8985.php b/tests/PHPStan/Analyser/nsrt/bug-8985.php new file mode 100644 index 0000000000..c268577dd7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8985.php @@ -0,0 +1,36 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug8985; + +use function PHPStan\Testing\assertType; + +class Entity +{ + public function __construct(private string $value) + { + } + + public function getValue(): string + { + return $this->value; + } +} + +class Repository +{ + /** @return array */ + public function getAll(): array + { + return [new Entity('test')]; + } +} + +function () : void { + assert((new Repository())->getAll() === []); + + $all = (new Repository())->getAll(); + assertType('array', $all); + $value = $all[0]->getValue(); +}; From d0d1966bfb146676b879a13442a735fce8570dbe Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:15:52 +0000 Subject: [PATCH 2/6] Skip storing expression types with new in chain instead of skipping at resolve time Move the expressionHasNewInChain check from resolveType() to specifyExpressionType() so that expressions based on new are never remembered in the first place, rather than being stored and then ignored at lookup time. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 967c9e4510..9f32d1465c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -971,7 +971,6 @@ private function resolveType(string $exprString, Expr $node): Type !$node instanceof Variable && !$node instanceof Expr\Closure && !$node instanceof Expr\ArrowFunction - && !$this->expressionHasNewInChain($node) && $this->hasExpressionType($node)->yes() ) { return $this->expressionTypes[$exprString]->getType(); @@ -2731,6 +2730,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } + if ($this->expressionHasNewInChain($expr)) { + return $this; + } + $scope = $this; if ( $expr instanceof Expr\ArrayDimFetch From 36e3e69b54323069915316934113217f44eb9bb8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:25:49 +0200 Subject: [PATCH 3/6] more readable --- src/Analyser/MutatingScope.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 9f32d1465c..302e5f8849 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -990,12 +990,23 @@ private function resolveType(string $exprString, Expr $node): Type private function expressionHasNewInChain(Expr $expr): bool { - if ($expr instanceof MethodCall || $expr instanceof PropertyFetch || $expr instanceof Expr\NullsafeMethodCall || $expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\ArrayDimFetch) { + if ( + $expr instanceof MethodCall || + $expr instanceof Expr\NullsafeMethodCall || + $expr instanceof Expr\ArrayDimFetch || + $expr instanceof PropertyFetch || + $expr instanceof Expr\NullsafePropertyFetch + ) { return $expr->var instanceof Expr\New_ || $this->expressionHasNewInChain($expr->var); } - if (($expr instanceof Expr\StaticCall || $expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\ClassConstFetch) && $expr->class instanceof Expr) { + if ( + $expr instanceof Expr\StaticCall + || $expr instanceof Expr\StaticPropertyFetch + || $expr instanceof Expr\ClassConstFetch + ) { return $expr->class instanceof Expr\New_ || $this->expressionHasNewInChain($expr->class); } + return false; } From dbdb449c6d69c2b1061f24cffeb99ebc5bd6bb56 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:35:14 +0000 Subject: [PATCH 4/6] Fix expressionHasNewInChain crash on Name nodes, add tests for all expression types The recursive call in expressionHasNewInChain() could receive a Name node (e.g. from Foo::bar()) instead of an Expr, causing a type error. Added an instanceof Expr guard before recursing on $expr->class. Also added test coverage for all expression types handled by expressionHasNewInChain(): nullsafe method calls, property fetches, nullsafe property fetches, array dim fetches, static calls, and chained expressions. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-8985.php | 64 ++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 302e5f8849..1752bcff20 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1004,7 +1004,7 @@ private function expressionHasNewInChain(Expr $expr): bool || $expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\ClassConstFetch ) { - return $expr->class instanceof Expr\New_ || $this->expressionHasNewInChain($expr->class); + return $expr->class instanceof Expr\New_ || ($expr->class instanceof Expr && $this->expressionHasNewInChain($expr->class)); } return false; diff --git a/tests/PHPStan/Analyser/nsrt/bug-8985.php b/tests/PHPStan/Analyser/nsrt/bug-8985.php index c268577dd7..9c562faba6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8985.php @@ -8,8 +8,11 @@ class Entity { - public function __construct(private string $value) + public string $value; + + public function __construct(string $value) { + $this->value = $value; } public function getValue(): string @@ -25,12 +28,67 @@ public function getAll(): array { return [new Entity('test')]; } + + public string $name = 'default'; + + /** @return array */ + public static function staticGetAll(): array + { + return [new Entity('test')]; + } + + public function getEntity(): Entity + { + return new Entity('test'); + } } -function () : void { +function testMethodCall(): void { assert((new Repository())->getAll() === []); $all = (new Repository())->getAll(); assertType('array', $all); $value = $all[0]->getValue(); -}; +} + +function testNullsafeMethodCall(): void { + assert((new Repository())?->getEntity()?->getValue() === 'specific'); + + assertType('string', (new Repository())?->getEntity()?->getValue()); +} + +function testPropertyFetch(): void { + assert((new Repository())->name === 'foo'); + + assertType('string', (new Repository())->name); +} + +function testNullsafePropertyFetch(): void { + assert((new Repository())?->name === 'foo'); + + assertType('string', (new Repository())?->name); +} + +function testArrayDimFetch(): void { + assert((new Repository())->getAll()[0]->getValue() === 'specific'); + + assertType('string', (new Repository())->getAll()[0]->getValue()); +} + +function testStaticCall(): void { + assert((new Repository())::staticGetAll() === []); + + assertType('array', (new Repository())::staticGetAll()); +} + +function testChainedMethodCalls(): void { + assert((new Repository())->getEntity()->getValue() === 'specific'); + + assertType('string', (new Repository())->getEntity()->getValue()); +} + +function testChainedPropertyOnMethodCall(): void { + assert((new Repository())->getEntity()->value === 'specific'); + + assertType('string', (new Repository())->getEntity()->value); +} From 654734a716cb67a61f12632d2883c89da80973b0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:39:34 +0000 Subject: [PATCH 5/6] Allow ClassConstFetch on new to be remembered, add tests for all expression types Class constants are class-level, not instance-level, so narrowed types from (new Foo())::MY_CONST should still be remembered. Also fix a type error where $expr->class could be a Name (not Expr) for static calls. Added tests covering: method calls, nullsafe method calls, property fetches, nullsafe property fetches, array dim fetches, static calls, static property fetches, class const fetches, and chained expressions. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 1 - tests/PHPStan/Analyser/nsrt/bug-8985.php | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1752bcff20..cb8491e219 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1002,7 +1002,6 @@ private function expressionHasNewInChain(Expr $expr): bool if ( $expr instanceof Expr\StaticCall || $expr instanceof Expr\StaticPropertyFetch - || $expr instanceof Expr\ClassConstFetch ) { return $expr->class instanceof Expr\New_ || ($expr->class instanceof Expr && $this->expressionHasNewInChain($expr->class)); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-8985.php b/tests/PHPStan/Analyser/nsrt/bug-8985.php index 9c562faba6..87478f53b3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8985.php @@ -41,6 +41,8 @@ public function getEntity(): Entity { return new Entity('test'); } + + public const MY_CONST = 'const_value'; } function testMethodCall(): void { @@ -92,3 +94,9 @@ function testChainedPropertyOnMethodCall(): void { assertType('string', (new Repository())->getEntity()->value); } + +function testClassConstFetch(): void { + assert((new Repository())::MY_CONST === 'const_value'); + + assertType("'const_value'", (new Repository())::MY_CONST); +} From e7850f1bab8898c9063720acffa59822bd3c278c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:47:29 +0200 Subject: [PATCH 6/6] testClassConstFetchOnUnknownClass --- tests/PHPStan/Analyser/nsrt/bug-8985.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8985.php b/tests/PHPStan/Analyser/nsrt/bug-8985.php index 87478f53b3..47e5522a3b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8985.php @@ -100,3 +100,12 @@ function testClassConstFetch(): void { assertType("'const_value'", (new Repository())::MY_CONST); } + +function testClassConstFetchOnUnknownClass(string $class, string $anotherClass): void { + assert((new $class())::MY_CONST === 'const_value'); + + assertType("'const_value'", (new $class())::MY_CONST); + + $class = $anotherClass; + assertType("*ERROR*", (new $class())::MY_CONST); +}