From 439ded0d40483cbdd0bcb3a6c1a3b3231586b9da 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 01/12] 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 8d43a7d613..4b2e408bb9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,6 +973,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(); @@ -990,6 +991,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 1844776558bc4cff472a085cd64cba1d2441715d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:15:52 +0000 Subject: [PATCH 02/12] 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 4b2e408bb9..9acd367d63 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,7 +973,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(); @@ -2733,6 +2732,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } + if ($this->expressionHasNewInChain($expr)) { + return $this; + } + $scope = $this; if ( $expr instanceof Expr\ArrayDimFetch From 97a045a8ff8630ba5ddac12c6faefed552f53bb2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:25:49 +0200 Subject: [PATCH 03/12] 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 9acd367d63..f76ed2ed6e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -992,12 +992,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 43de8fdc90bb2e7f7008c13f40369d82ec87955a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:35:14 +0000 Subject: [PATCH 04/12] 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 f76ed2ed6e..192d7f551c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1006,7 +1006,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 2a283bdfdcae2279140850ad24c2756f10bb1b9f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:39:34 +0000 Subject: [PATCH 05/12] 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 192d7f551c..fd2245c3e5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1004,7 +1004,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 f24ac4b772266c89a41a25341d25485c3496442f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:47:29 +0200 Subject: [PATCH 06/12] 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); +} From c5479ba31faaab45a03dada55acb6f5e13cbb6f5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 12:51:20 +0200 Subject: [PATCH 07/12] fix --- src/Analyser/MutatingScope.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index fd2245c3e5..59b9297bf3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,6 +973,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(); @@ -2742,10 +2743,6 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } - if ($this->expressionHasNewInChain($expr)) { - return $this; - } - $scope = $this; if ( $expr instanceof Expr\ArrayDimFetch From d919fc52a1dad7962b17f602137df0bac7feae92 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 12:57:54 +0200 Subject: [PATCH 08/12] add failling test --- .../Rules/Methods/ReturnTypeRuleTest.php | 6 +++ tests/PHPStan/Rules/Methods/data/bug-8985.php | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-8985.php diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index e381f321f0..3d233b8725 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1326,6 +1326,12 @@ public function testBug10924(): void $this->analyse([__DIR__ . '/data/bug-10924.php'], []); } + public function testBug8985(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-8985.php'], []); + } + public function testBug11430(): void { $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-11430.php'], []); diff --git a/tests/PHPStan/Rules/Methods/data/bug-8985.php b/tests/PHPStan/Rules/Methods/data/bug-8985.php new file mode 100644 index 0000000000..491d84f49c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-8985.php @@ -0,0 +1,38 @@ + + */ + protected function getDefaultFunctions(): array + { + /** @var array $x */ + $x = (new Defaults())->getFunctions(); + return $x; + } +} + +class HelloWorld2 +{ + /** + * @return array + */ + protected function getDefaultFunctions(): array + { + /** @var array */ + return (new Defaults())->getFunctions(); + } +} + +class Defaults +{ + public function getFunctions(): mixed + { + return []; + } +} From 5d9d5594053b0272ec99c8ab08c41d3c9d944ecb Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:00:33 +0200 Subject: [PATCH 09/12] tests --- ...nexistentOffsetInArrayDimFetchRuleTest.php | 7 +++++ tests/PHPStan/Rules/Arrays/data/bug-8985.php | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-8985.php diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 7cd71f4123..44588063e8 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1270,6 +1270,13 @@ public function testBug13773(): void ]); } + public function testBug8985(): void + { + $this->reportPossiblyNonexistentConstantArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-8985.php'], []); + } + public function testBug14308(): void { $this->reportPossiblyNonexistentConstantArrayOffset = true; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8985.php b/tests/PHPStan/Rules/Arrays/data/bug-8985.php new file mode 100644 index 0000000000..2b6ea26e3f --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-8985.php @@ -0,0 +1,31 @@ +value; + } +} + +class Repository +{ + /** @return array */ + public function getAll(): array + { + return [new Entity('test')]; + } +} + +assert((new Repository())->getAll() === []); + +$all = (new Repository())->getAll(); +$value = $all[0]->getValue(); From 17f3a00d0fe5ea6e70919e93095b519cddf8a012 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:01:46 +0200 Subject: [PATCH 10/12] separate tests --- tests/PHPStan/Rules/Arrays/data/bug-8985.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-8985.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8985.php b/tests/PHPStan/Rules/Arrays/data/bug-8985.php index 2b6ea26e3f..238b6f73f2 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-8985.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-8985.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bug8985; +namespace Bug8985c; class Entity { diff --git a/tests/PHPStan/Rules/Methods/data/bug-8985.php b/tests/PHPStan/Rules/Methods/data/bug-8985.php index 491d84f49c..2dd4d713e5 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-8985.php +++ b/tests/PHPStan/Rules/Methods/data/bug-8985.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Bug8985; +namespace Bug8985b; class HelloWorld { From e51ec3ef5c64faa7ce2c1a6f6ecb4aedfe725c23 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 19 Apr 2026 11:14:35 +0000 Subject: [PATCH 11/12] Allow stored expression types for new-chain expressions when set on the same AST node When an expression has `new` in its receiver chain, only skip the stored type lookup if the stored type came from a different AST node (e.g. assert narrowing). If the stored type was set for the same AST node (e.g. inline @var annotation), allow it through. This fixes @var overrides on expressions like `return (new Defaults())->getFunctions()`. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 59b9297bf3..b7066cf980 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,8 +973,8 @@ 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() + && (!$this->expressionHasNewInChain($node) || $this->expressionTypes[$exprString]->getExpr() === $node) ) { return $this->expressionTypes[$exprString]->getType(); } From 2bff91d1ae401e5141e0f409f33c4e7f434f50f2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:18:24 +0200 Subject: [PATCH 12/12] Update bug-8985.php --- tests/PHPStan/Rules/Arrays/data/bug-8985.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8985.php b/tests/PHPStan/Rules/Arrays/data/bug-8985.php index 238b6f73f2..f066b4d590 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-8985.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-8985.php @@ -1,4 +1,4 @@ -= 8.0 declare(strict_types=1);