From ac43e44641bb21b461276b8d219f1334f25891f0 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 64e46074738..e81f24ab55a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -974,6 +974,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(); @@ -991,6 +992,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 00000000000..c268577dd77 --- /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 4c9dbc646aed657b34f94f11ee11584e71a51d65 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 e81f24ab55a..c9d32bdc88a 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 Variable && !$node instanceof Expr\Closure && !$node instanceof Expr\ArrowFunction - && !$this->expressionHasNewInChain($node) && $this->hasExpressionType($node)->yes() ) { return $this->expressionTypes[$exprString]->getType(); @@ -2739,6 +2738,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } + if ($this->expressionHasNewInChain($expr)) { + return $this; + } + $scope = $this; if ( $expr instanceof Expr\ArrayDimFetch From 73d7975a4af8b81257cdc5c120c544abfa150bd5 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 c9d32bdc88a..bdf51e245ac 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -993,12 +993,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 dbea66eb3a7b42a28429ccf6b3132bdffe6a5397 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 bdf51e245ac..09eca6678db 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1007,7 +1007,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 c268577dd77..9c562faba68 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 c9165c6076da82fadaa57d238c8b4c2df2a17346 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 09eca6678db..2f97eb2f34c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1005,7 +1005,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 9c562faba68..87478f53b37 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 ebec3ec5507fd4eaf64e0de0e3737dde27df4e55 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 87478f53b37..47e5522a3be 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 16e9101f387abc0266c7b4fc04a1b0d77b8cc37b 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 2f97eb2f34c..3e2ebce2e34 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -974,6 +974,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(); @@ -2748,10 +2749,6 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } - if ($this->expressionHasNewInChain($expr)) { - return $this; - } - $scope = $this; if ( $expr instanceof Expr\ArrayDimFetch From 3d300728cd178da96d51b1ad23d576b250259d49 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 5930c600443..87cfebff4fd 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1331,6 +1331,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 00000000000..491d84f49c8 --- /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 e665bb2093ee4fd644c7cfa64cc9f3e5565086b4 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 51c32f370af..8f27b62a161 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1296,6 +1296,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 00000000000..2b6ea26e3fe --- /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 40627241e76c3dc3f52a896488b2430dab6bbb76 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 2b6ea26e3fe..238b6f73f29 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 491d84f49c8..2dd4d713e51 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 b18ffc61d41d24578f3ea055da7156c31ae25655 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 3e2ebce2e34..bd2af8e45b7 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -974,8 +974,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 8e73f58d1eb0f70573dee3b914f78113fd86e628 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 238b6f73f29..f066b4d5901 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 cb1f15fbf2d69ea3546d70838322859a612d7bdf 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 87cfebff4fd..cf4154a92de 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1331,6 +1331,7 @@ public function testBug10924(): void $this->analyse([__DIR__ . '/data/bug-10924.php'], []); } + #[RequiresPhp('>= 8.0.0')] public function testBug8985(): void { $this->checkExplicitMixed = true; From 94773089f2d3068e762a7582a80d6e9771fc588a 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 24903779b3f..6f9f1ce5776 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2512,6 +2512,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()) { @@ -2540,6 +2548,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 dd26f0905c28f6337b368e3b30f68dbbc46f04c2 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 47e5522a3be..54ad92afdbe 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 f066b4d5901..26f4cefdf56 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 35a8013f34aaccd8fc2d75890370123bd95997b5 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 6f9f1ce5776..9f432a9f6f4 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2441,6 +2441,10 @@ private function createForExpr( if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { return new SpecifiedTypes([], []); } + + if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { + return new SpecifiedTypes([], []); + } } if ( @@ -2465,6 +2469,10 @@ private function createForExpr( } } } + + if ($this->callLikeArgsHaveSideEffects($expr, $scope)) { + return new SpecifiedTypes([], []); + } } if ( @@ -2478,6 +2486,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); @@ -2503,6 +2513,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); @@ -2569,11 +2581,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 3a99f741266af5dd791bf73f29b6be6d983300bb 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 bd2af8e45b7..64e46074738 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -975,7 +975,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(); } @@ -992,27 +991,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 0fd6ae6e2e13bafe3bb865552c1a8909b26ccf66 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 00000000000..bf430194305 --- /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 9f432a9f6f4..109bb65508d 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -121,7 +121,7 @@ public function __construct( private array $functionTypeSpecifyingExtensions, private array $methodTypeSpecifyingExtensions, private array $staticMethodTypeSpecifyingExtensions, - private bool $rememberPossiblyImpureFunctionValues, + private ExprSideEffectsHelper $exprSideEffectsHelper, ) { } @@ -2422,109 +2422,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); } @@ -2560,160 +2486,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 c42a66cb1b7..76bffaf1a7e 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 8eaba9aadae3919b56678343624b081b168671d4 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 bf430194305..0bbdabb3a78 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) {