From ae09b5dd59f0140596bee722daff667b9451b3e8 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/19] 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 86a4a4a55a5393a8467542a31b87b66fafe87eb9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:15:52 +0000 Subject: [PATCH 02/19] 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 dfef10684edbb54a33aeb9a26bd0ed52066a1f45 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:25:49 +0200 Subject: [PATCH 03/19] 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 1bc359f8a558c63450b3fdb93d887b1e162da8c6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:35:14 +0000 Subject: [PATCH 04/19] 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 2888f430ea5d6eabb9f362d91fde907780905e27 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:39:34 +0000 Subject: [PATCH 05/19] 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 2c3529e9ecf9028ec28500234a07f2dce79f2922 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 14 Apr 2026 07:47:29 +0200 Subject: [PATCH 06/19] 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 492528aa01f2ae7d6e8ef55c733a0557b4ffe182 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 12:51:20 +0200 Subject: [PATCH 07/19] 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 e03b220d9761e98f19ecb6a0f79da0d5005780e7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 12:57:54 +0200 Subject: [PATCH 08/19] 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 b15ddbb4853f8a5e9d8873d48974244b3ba7be4b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:00:33 +0200 Subject: [PATCH 09/19] 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 61a7fff6e6b16284f2c6163e5ac7466aaa51063d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:01:46 +0200 Subject: [PATCH 10/19] 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 75ed20c15bf53ab5bd4723f3628b6dda5524d400 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 19 Apr 2026 11:14:35 +0000 Subject: [PATCH 11/19] 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 f6dcfa81c574aa0a13cc5237b450cab2a1b54248 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 13:18:24 +0200 Subject: [PATCH 12/19] 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); From e69f874d51092b995aec4d414afa146298d5130a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 20 Apr 2026 06:56:08 +0200 Subject: [PATCH 13/19] Update ReturnTypeRuleTest.php --- tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 3d233b8725..56ef189005 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1326,6 +1326,7 @@ public function testBug10924(): void $this->analyse([__DIR__ . '/data/bug-10924.php'], []); } + #[RequiresPhp('>= 8.0.0')] public function testBug8985(): void { $this->checkExplicitMixed = true; From 51b2db1a574cbd9a991293d470e78bd60ade00ac Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 20:21:17 +0000 Subject: [PATCH 14/19] Check for impure sub-expressions in TypeSpecifier::createForExpr Instead of only checking for `new` in the expression chain at type resolution time, prevent TypeSpecifier from storing narrowed types when the receiver chain or arguments contain impure calls. This generalizes the fix to handle named constructor patterns like `Repository::create()->getAll()` where `create()` is impure, and impure arguments like `$repo->getAll(Repository::create())`. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 151 +++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3e50294c94..1360327354 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2409,6 +2409,14 @@ private function createForExpr( } } + if ($this->subExpressionsHaveSideEffects($expr, $scope)) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); + } + + return new SpecifiedTypes([], []); + } + $sureTypes = []; $sureNotTypes = []; if ($context->false()) { @@ -2437,6 +2445,149 @@ private function createForExpr( return $types; } + private function subExpressionsHaveSideEffects(Expr $expr, Scope $scope): bool + { + if ( + $expr instanceof MethodCall + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof PropertyFetch + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof ArrayDimFetch + ) { + if ($this->expressionHasSideEffects($expr->var, $scope)) { + return true; + } + } elseif ( + $expr instanceof StaticCall + || $expr instanceof StaticPropertyFetch + ) { + if ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) { + return true; + } + } + + if ($expr instanceof Expr\CallLike && !$expr->isFirstClassCallable()) { + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + } + + return false; + } + + private function expressionHasSideEffects(Expr $expr, Scope $scope): bool + { + if ($expr instanceof Expr\New_) { + return true; + } + + if ($expr instanceof FuncCall) { + if ($expr->isFirstClassCallable()) { + return false; + } + if ($expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return true; + } + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return true; + } + if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + return false; + } + + if ($expr instanceof MethodCall || $expr instanceof Expr\NullsafeMethodCall) { + if ($expr->isFirstClassCallable()) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + if ($expr->name instanceof Node\Identifier) { + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof StaticCall) { + if ($expr->isFirstClassCallable()) { + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + if ($expr->name instanceof Node\Identifier) { + if ($expr->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($expr->class); + } else { + $calledOnType = $scope->getType($expr->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + + if ($expr instanceof PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof ArrayDimFetch) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof StaticPropertyFetch) { + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + + return false; + } + private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes { if ($expr instanceof Expr\NullsafePropertyFetch) { From ac59fc8b6c5d9435ce96e37bb42856210d37597a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 20:21:28 +0000 Subject: [PATCH 15/19] Add tests for impure receiver chain and impure arguments Tests cover: - Named constructor: Repository::create()->getAll() with impure create() - Chained named constructor: Repository::create()->getEntity()->getValue() - Named constructor property: Repository::create()->name - Impure argument: $repository->getAllFor(Repository::create()) Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-8985.php | 37 ++++++++++++++++++++ tests/PHPStan/Rules/Arrays/data/bug-8985.php | 24 +++++++++++++ 2 files changed, 61 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-8985.php b/tests/PHPStan/Analyser/nsrt/bug-8985.php index 47e5522a3b..54ad92afdb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8985.php @@ -29,6 +29,12 @@ public function getAll(): array return [new Entity('test')]; } + /** @return array */ + public function getAllFor(mixed $filter): array + { + return [new Entity('test')]; + } + public string $name = 'default'; /** @return array */ @@ -42,6 +48,12 @@ public function getEntity(): Entity return new Entity('test'); } + /** @phpstan-impure */ + public static function create(): self + { + return new self(); + } + public const MY_CONST = 'const_value'; } @@ -109,3 +121,28 @@ function testClassConstFetchOnUnknownClass(string $class, string $anotherClass): $class = $anotherClass; assertType("*ERROR*", (new $class())::MY_CONST); } + +function testNamedConstructor(): void { + assert(Repository::create()->getAll() === []); + + $all = Repository::create()->getAll(); + assertType('array', $all); +} + +function testNamedConstructorChained(): void { + assert(Repository::create()->getEntity()->getValue() === 'specific'); + + assertType('string', Repository::create()->getEntity()->getValue()); +} + +function testNamedConstructorProperty(): void { + assert(Repository::create()->name === 'foo'); + + assertType('string', Repository::create()->name); +} + +function testImpureArgument(Repository $repository): void { + assert($repository->getAllFor(Repository::create()) === []); + + assertType('array', $repository->getAllFor(Repository::create())); +} diff --git a/tests/PHPStan/Rules/Arrays/data/bug-8985.php b/tests/PHPStan/Rules/Arrays/data/bug-8985.php index f066b4d590..26f4cefdf5 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-8985.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-8985.php @@ -23,9 +23,33 @@ public function getAll(): array { return [new Entity('test')]; } + + /** @return array */ + public function getAllFor(mixed $filter): array + { + return [new Entity('test')]; + } + + /** @phpstan-impure */ + public static function create(): self + { + return new self(); + } } assert((new Repository())->getAll() === []); $all = (new Repository())->getAll(); $value = $all[0]->getValue(); + +assert(Repository::create()->getAll() === []); + +$all2 = Repository::create()->getAll(); +$value2 = $all2[0]->getValue(); + +function testImpureArgument(Repository $repository): void { + assert($repository->getAllFor(Repository::create()) === []); + + $all = $repository->getAllFor(Repository::create()); + $value = $all[0]->getValue(); +} From 667d56bb8ddbeb318b7098a3cdc065febf2d5461 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 21:09:07 +0000 Subject: [PATCH 16/19] Merge sub-expression side effect checks into existing purity conditions Instead of having a standalone subExpressionsHaveSideEffects check separate from the existing MethodCall/StaticCall/FuncCall purity conditions, merge the receiver chain and argument side effect checks directly into those conditions. This addresses the review feedback that the hasSideEffects() checking pattern was duplicated. - MethodCall block: now also checks receiver ($expr->var) and args - StaticCall block: now also checks class expr and args - FuncCall blocks: now also check args for side effects - Extract callLikeArgsHaveSideEffects() helper, reused by both the existing blocks and subExpressionsHaveSideEffects() - subExpressionsHaveSideEffects() remains as catch-all for types not covered by the blocks above (PropertyFetch, ArrayDimFetch, NullsafeMethodCall, NullsafePropertyFetch, StaticPropertyFetch) Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 1360327354..15633ec637 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2338,6 +2338,10 @@ private function createForExpr( if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { return new SpecifiedTypes([], []); } + + if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { + return new SpecifiedTypes([], []); + } } if ( @@ -2362,6 +2366,10 @@ private function createForExpr( } } } + + if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { + return new SpecifiedTypes([], []); + } } if ( @@ -2375,6 +2383,8 @@ private function createForExpr( $methodReflection === null || $methodReflection->hasSideEffects()->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + || $this->expressionHasSideEffects($expr->var, $scope) + || $this->callLikeArgsHaveSideEffects($expr, $scope) ) { if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); @@ -2400,6 +2410,8 @@ private function createForExpr( $methodReflection === null || $methodReflection->hasSideEffects()->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + || ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) + || $this->callLikeArgsHaveSideEffects($expr, $scope) ) { if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); @@ -2466,11 +2478,22 @@ private function subExpressionsHaveSideEffects(Expr $expr, Scope $scope): bool } } - if ($expr instanceof Expr\CallLike && !$expr->isFirstClassCallable()) { - foreach ($expr->getArgs() as $arg) { - if ($this->expressionHasSideEffects($arg->value, $scope)) { - return true; - } + if ($expr instanceof Expr\CallLike && $this->callLikeArgsHaveSideEffects($expr, $scope)) { + return true; + } + + return false; + } + + private function callLikeArgsHaveSideEffects(Expr\CallLike $expr, Scope $scope): bool + { + if ($expr->isFirstClassCallable()) { + return false; + } + + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; } } From 9fce389c9e31b9a13b8754057543b41695db8a5a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 22 Apr 2026 21:21:31 +0000 Subject: [PATCH 17/19] Remove expressionHasNewInChain from MutatingScope The TypeSpecifier side-effect checks now handle the new-in-chain case: expressionHasSideEffects() treats New_ as impure, preventing narrowed types from being stored for any expression with new in its chain. The MutatingScope check is no longer needed. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b7066cf980..8d43a7d613 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -974,7 +974,6 @@ private function resolveType(string $exprString, Expr $node): Type && !$node instanceof Expr\Closure && !$node instanceof Expr\ArrowFunction && $this->hasExpressionType($node)->yes() - && (!$this->expressionHasNewInChain($node) || $this->expressionTypes[$exprString]->getExpr() === $node) ) { return $this->expressionTypes[$exprString]->getType(); } @@ -991,27 +990,6 @@ private function resolveType(string $exprString, Expr $node): Type return new MixedType(); } - private function expressionHasNewInChain(Expr $expr): bool - { - 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 - ) { - return $expr->class instanceof Expr\New_ || ($expr->class instanceof Expr && $this->expressionHasNewInChain($expr->class)); - } - - return false; - } - /** * @param callable(Type): ?bool $typeCallback */ From 4e20ef7748acbced16f92ce9edf4461a8e51c986 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 23 Apr 2026 05:24:32 +0000 Subject: [PATCH 18/19] Extract side-effect checks into ExprSideEffectsHelper Move expression side-effect detection logic from TypeSpecifier into a dedicated ExprSideEffectsHelper class with public rememberFuncCall, rememberMethodCall, rememberStaticCall, and subExpressionsHaveSideEffects methods that encapsulate all purity and side-effect conditions. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprSideEffectsHelper.php | 275 +++++++++++++++++++++++++ src/Analyser/TypeSpecifier.php | 254 ++--------------------- src/Analyser/TypeSpecifierFactory.php | 2 +- 3 files changed, 289 insertions(+), 242 deletions(-) create mode 100644 src/Analyser/ExprSideEffectsHelper.php diff --git a/src/Analyser/ExprSideEffectsHelper.php b/src/Analyser/ExprSideEffectsHelper.php new file mode 100644 index 0000000000..bf43019430 --- /dev/null +++ b/src/Analyser/ExprSideEffectsHelper.php @@ -0,0 +1,275 @@ +name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return false; + } + + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { + return false; + } + } else { + $nameType = $scope->getType($expr->name); + if ($nameType->isCallable()->yes()) { + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + + if ($isPure !== null) { + if ($isPure->no()) { + return false; + } + + if (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()) { + return false; + } + } + } + } + + return !$this->callLikeArgsHaveSideEffects($expr, $scope); + } + + public function rememberMethodCall(MethodCall $expr, Scope $scope): bool + { + if (!$expr->name instanceof Node\Identifier) { + return false; + } + + $methodName = $expr->name->toString(); + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + || $this->expressionHasSideEffects($expr->var, $scope) + || $this->callLikeArgsHaveSideEffects($expr, $scope) + ) { + return false; + } + + return true; + } + + public function rememberStaticCall(StaticCall $expr, Scope $scope): bool + { + if (!$expr->name instanceof Node\Identifier) { + return false; + } + + $methodName = $expr->name->toString(); + if ($expr->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($expr->class); + } else { + $calledOnType = $scope->getType($expr->class); + } + + $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); + + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + || ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) + || $this->callLikeArgsHaveSideEffects($expr, $scope) + ) { + return false; + } + + return true; + } + + public function subExpressionsHaveSideEffects(Expr $expr, Scope $scope): bool + { + if ( + $expr instanceof MethodCall + || $expr instanceof Expr\NullsafeMethodCall + || $expr instanceof PropertyFetch + || $expr instanceof Expr\NullsafePropertyFetch + || $expr instanceof ArrayDimFetch + ) { + if ($this->expressionHasSideEffects($expr->var, $scope)) { + return true; + } + } elseif ( + $expr instanceof StaticCall + || $expr instanceof StaticPropertyFetch + ) { + if ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) { + return true; + } + } + + if ($expr instanceof Expr\CallLike && $this->callLikeArgsHaveSideEffects($expr, $scope)) { + return true; + } + + return false; + } + + private function callLikeArgsHaveSideEffects(Expr\CallLike $expr, Scope $scope): bool + { + if ($expr->isFirstClassCallable()) { + return false; + } + + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + + return false; + } + + private function expressionHasSideEffects(Expr $expr, Scope $scope): bool + { + if ($expr instanceof Expr\New_) { + return true; + } + + if ($expr instanceof FuncCall) { + if ($expr->isFirstClassCallable()) { + return false; + } + if ($expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return true; + } + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return true; + } + if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + return false; + } + + if ($expr instanceof MethodCall || $expr instanceof Expr\NullsafeMethodCall) { + if ($expr->isFirstClassCallable()) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + if ($expr->name instanceof Node\Identifier) { + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof StaticCall) { + if ($expr->isFirstClassCallable()) { + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + if ($expr->name instanceof Node\Identifier) { + if ($expr->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($expr->class); + } else { + $calledOnType = $scope->getType($expr->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { + return true; + } + } else { + return true; + } + foreach ($expr->getArgs() as $arg) { + if ($this->expressionHasSideEffects($arg->value, $scope)) { + return true; + } + } + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + + if ($expr instanceof PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof ArrayDimFetch) { + return $this->expressionHasSideEffects($expr->var, $scope); + } + + if ($expr instanceof StaticPropertyFetch) { + if ($expr->class instanceof Expr) { + return $this->expressionHasSideEffects($expr->class, $scope); + } + return false; + } + + return false; + } + +} diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 15633ec637..a92bca567c 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -120,7 +120,7 @@ public function __construct( private array $functionTypeSpecifyingExtensions, private array $methodTypeSpecifyingExtensions, private array $staticMethodTypeSpecifyingExtensions, - private bool $rememberPossiblyImpureFunctionValues, + private ExprSideEffectsHelper $exprSideEffectsHelper, ) { } @@ -2319,109 +2319,35 @@ private function createForExpr( } } - if ( - $expr instanceof FuncCall - && $expr->name instanceof Name - ) { - $has = $this->reflectionProvider->hasFunction($expr->name, $scope); - if (!$has) { - // backwards compatibility with previous behaviour - return new SpecifiedTypes([], []); - } - - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $hasSideEffects = $functionReflection->hasSideEffects(); - if ($hasSideEffects->yes()) { - return new SpecifiedTypes([], []); - } - - if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { - return new SpecifiedTypes([], []); - } - - if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { - return new SpecifiedTypes([], []); - } - } - - if ( - $expr instanceof FuncCall - && !$expr->name instanceof Name - ) { - $nameType = $scope->getType($expr->name); - if ($nameType->isCallable()->yes()) { - $isPure = null; - foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { - $variantIsPure = $variant->isPure(); - $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); - } - - if ($isPure !== null) { - if ($isPure->no()) { - return new SpecifiedTypes([], []); - } - - if (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()) { - return new SpecifiedTypes([], []); - } - } - } - - if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { - return new SpecifiedTypes([], []); - } + if ($expr instanceof FuncCall && !$this->exprSideEffectsHelper->rememberFuncCall($expr, $scope)) { + return new SpecifiedTypes([], []); } if ( $expr instanceof MethodCall && $expr->name instanceof Node\Identifier + && !$this->exprSideEffectsHelper->rememberMethodCall($expr, $scope) ) { - $methodName = $expr->name->toString(); - $calledOnType = $scope->getType($expr->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - || $this->expressionHasSideEffects($expr->var, $scope) - || $this->callLikeArgsHaveSideEffects($expr, $scope) - ) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } - - return new SpecifiedTypes([], []); + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); } + + return new SpecifiedTypes([], []); } if ( $expr instanceof StaticCall && $expr->name instanceof Node\Identifier + && !$this->exprSideEffectsHelper->rememberStaticCall($expr, $scope) ) { - $methodName = $expr->name->toString(); - if ($expr->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($expr->class); - } else { - $calledOnType = $scope->getType($expr->class); + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); } - $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - || ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) - || $this->callLikeArgsHaveSideEffects($expr, $scope) - ) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } - - return new SpecifiedTypes([], []); - } + return new SpecifiedTypes([], []); } - if ($this->subExpressionsHaveSideEffects($expr, $scope)) { + if ($this->exprSideEffectsHelper->subExpressionsHaveSideEffects($expr, $scope)) { if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); } @@ -2457,160 +2383,6 @@ private function createForExpr( return $types; } - private function subExpressionsHaveSideEffects(Expr $expr, Scope $scope): bool - { - if ( - $expr instanceof MethodCall - || $expr instanceof Expr\NullsafeMethodCall - || $expr instanceof PropertyFetch - || $expr instanceof Expr\NullsafePropertyFetch - || $expr instanceof ArrayDimFetch - ) { - if ($this->expressionHasSideEffects($expr->var, $scope)) { - return true; - } - } elseif ( - $expr instanceof StaticCall - || $expr instanceof StaticPropertyFetch - ) { - if ($expr->class instanceof Expr && $this->expressionHasSideEffects($expr->class, $scope)) { - return true; - } - } - - if ($expr instanceof Expr\CallLike && $this->callLikeArgsHaveSideEffects($expr, $scope)) { - return true; - } - - return false; - } - - private function callLikeArgsHaveSideEffects(Expr\CallLike $expr, Scope $scope): bool - { - if ($expr->isFirstClassCallable()) { - return false; - } - - foreach ($expr->getArgs() as $arg) { - if ($this->expressionHasSideEffects($arg->value, $scope)) { - return true; - } - } - - return false; - } - - private function expressionHasSideEffects(Expr $expr, Scope $scope): bool - { - if ($expr instanceof Expr\New_) { - return true; - } - - if ($expr instanceof FuncCall) { - if ($expr->isFirstClassCallable()) { - return false; - } - if ($expr->name instanceof Name) { - if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { - return true; - } - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $hasSideEffects = $functionReflection->hasSideEffects(); - if ($hasSideEffects->yes()) { - return true; - } - if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { - return true; - } - } else { - return true; - } - foreach ($expr->getArgs() as $arg) { - if ($this->expressionHasSideEffects($arg->value, $scope)) { - return true; - } - } - return false; - } - - if ($expr instanceof MethodCall || $expr instanceof Expr\NullsafeMethodCall) { - if ($expr->isFirstClassCallable()) { - return $this->expressionHasSideEffects($expr->var, $scope); - } - if ($expr->name instanceof Node\Identifier) { - $calledOnType = $scope->getType($expr->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - return true; - } - } else { - return true; - } - foreach ($expr->getArgs() as $arg) { - if ($this->expressionHasSideEffects($arg->value, $scope)) { - return true; - } - } - return $this->expressionHasSideEffects($expr->var, $scope); - } - - if ($expr instanceof StaticCall) { - if ($expr->isFirstClassCallable()) { - if ($expr->class instanceof Expr) { - return $this->expressionHasSideEffects($expr->class, $scope); - } - return false; - } - if ($expr->name instanceof Node\Identifier) { - if ($expr->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($expr->class); - } else { - $calledOnType = $scope->getType($expr->class); - } - $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - return true; - } - } else { - return true; - } - foreach ($expr->getArgs() as $arg) { - if ($this->expressionHasSideEffects($arg->value, $scope)) { - return true; - } - } - if ($expr->class instanceof Expr) { - return $this->expressionHasSideEffects($expr->class, $scope); - } - return false; - } - - if ($expr instanceof PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { - return $this->expressionHasSideEffects($expr->var, $scope); - } - - if ($expr instanceof ArrayDimFetch) { - return $this->expressionHasSideEffects($expr->var, $scope); - } - - if ($expr instanceof StaticPropertyFetch) { - if ($expr->class instanceof Expr) { - return $this->expressionHasSideEffects($expr->class, $scope); - } - return false; - } - - return false; - } - private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes { if ($expr instanceof Expr\NullsafePropertyFetch) { diff --git a/src/Analyser/TypeSpecifierFactory.php b/src/Analyser/TypeSpecifierFactory.php index c42a66cb1b..76bffaf1a7 100644 --- a/src/Analyser/TypeSpecifierFactory.php +++ b/src/Analyser/TypeSpecifierFactory.php @@ -35,7 +35,7 @@ public function create(): TypeSpecifier $functionTypeSpecifying, $methodTypeSpecifying, $staticMethodTypeSpecifying, - $this->container->getParameter('rememberPossiblyImpureFunctionValues'), + $this->container->getByType(ExprSideEffectsHelper::class), ); foreach (array_merge( From b04337413bc8b7d6482366e4c69957081e685132 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 23 Apr 2026 07:28:01 +0200 Subject: [PATCH 19/19] fix cs --- src/Analyser/ExprSideEffectsHelper.php | 76 +++++++++++++------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/src/Analyser/ExprSideEffectsHelper.php b/src/Analyser/ExprSideEffectsHelper.php index bf43019430..0bbdabb3a7 100644 --- a/src/Analyser/ExprSideEffectsHelper.php +++ b/src/Analyser/ExprSideEffectsHelper.php @@ -171,19 +171,19 @@ private function expressionHasSideEffects(Expr $expr, Scope $scope): bool if ($expr->isFirstClassCallable()) { return false; } - if ($expr->name instanceof Name) { - if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { - return true; - } - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $hasSideEffects = $functionReflection->hasSideEffects(); - if ($hasSideEffects->yes()) { - return true; - } - if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { - return true; - } - } else { + if (!($expr->name instanceof Name)) { + return true; + } + + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return true; + } + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); + $hasSideEffects = $functionReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return true; + } + if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { return true; } foreach ($expr->getArgs() as $arg) { @@ -198,17 +198,17 @@ private function expressionHasSideEffects(Expr $expr, Scope $scope): bool if ($expr->isFirstClassCallable()) { return $this->expressionHasSideEffects($expr->var, $scope); } - if ($expr->name instanceof Node\Identifier) { - $calledOnType = $scope->getType($expr->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - return true; - } - } else { + if (!($expr->name instanceof Node\Identifier)) { + return true; + } + + $calledOnType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { return true; } foreach ($expr->getArgs() as $arg) { @@ -226,21 +226,21 @@ private function expressionHasSideEffects(Expr $expr, Scope $scope): bool } return false; } - if ($expr->name instanceof Node\Identifier) { - if ($expr->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($expr->class); - } else { - $calledOnType = $scope->getType($expr->class); - } - $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - return true; - } + if (!($expr->name instanceof Node\Identifier)) { + return true; + } + + if ($expr->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($expr->class); } else { + $calledOnType = $scope->getType($expr->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ( + $methodReflection === null + || $methodReflection->hasSideEffects()->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) + ) { return true; } foreach ($expr->getArgs() as $arg) {