From ef0678d749ab4c9c8a84457c752f99ffbf64be55 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 29 Apr 2026 19:02:29 +0000 Subject: [PATCH 1/5] Resolve method reflection for dynamic static calls (`$var::method()`) to enable purity and side-effect checking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `StaticCallHandler::processExpr()`, when the class part of a static call is an expression (e.g. `$enum::from()`), resolve the method reflection via `getObjectTypeOrClassStringObjectType()` — the same approach already used in `resolveType()` for return type computation. - Previously, `$methodReflection` was always null for `$expr->class instanceof Expr`, causing a false "call to unknown method" impure point for every dynamic static call. - Guard `$this`-invalidation and promoted-property initialization logic with `$expr->class instanceof Name` to prevent incorrect scope effects when the call target is an expression (e.g. `$other::__construct()`). - Update `CallToStaticMethodStatementWithoutSideEffectsRuleTest::testDynamicStaticCall` expectations: pure dynamic static calls are now correctly detected as having no effect. --- .../ExprHandler/StaticCallHandler.php | 21 ++++- ...hodStatementWithoutSideEffectsRuleTest.php | 15 +++- .../Rules/Pure/PureFunctionRuleTest.php | 6 ++ .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 12 +++ .../Rules/Pure/data/bug-14557-function.php | 47 ++++++++++ tests/PHPStan/Rules/Pure/data/bug-14557.php | 88 +++++++++++++++++++ 6 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/Pure/data/bug-14557-function.php create mode 100644 tests/PHPStan/Rules/Pure/data/bug-14557.php diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index ee0a758d491..6ef0017e4fa 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -144,6 +144,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } else { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } + } elseif ($expr->class instanceof Expr) { + $classType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); + $methodName = $expr->name->name; + $methodReflection = $scope->getMethodReflection($classType, $methodName); + if ($methodReflection !== null) { + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $expr->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + } } } else { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -202,7 +214,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ( - $methodReflection !== null + $expr->class instanceof Name + && $methodReflection !== null && ( ( !$methodReflection->isStatic() @@ -215,7 +228,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ) { $scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass()); } elseif ( - $methodReflection !== null + $expr->class instanceof Name + && $methodReflection !== null && $this->rememberPossiblyImpureFunctionValues && $scope->isInClass() && $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName()) @@ -230,7 +244,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ( - $methodReflection !== null + $expr->class instanceof Name + && $methodReflection !== null && !$methodReflection->isStatic() && $methodReflection->getName() === '__construct' && $scopeFunction instanceof MethodReflection diff --git a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php index 9fc94aa711f..56804e8a84b 100644 --- a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php @@ -132,7 +132,20 @@ public function testBug10819(): void public function testDynamicStaticCall(): void { - $this->analyse([__DIR__ . '/data/dynamic-static-call.php'], []); + $this->analyse([__DIR__ . '/data/dynamic-static-call.php'], [ + [ + 'Call to static method DynamicStaticCall\Foo::doFoo() on a separate line has no effect.', + 32, + ], + [ + 'Call to static method DynamicStaticCall\FinalFoo::doFoo() on a separate line has no effect.', + 33, + ], + [ + 'Call to static method DynamicStaticCall\Bar::finalFoo() on a separate line has no effect.', + 34, + ], + ]); } #[RequiresPhp('>= 8.5.0')] diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index 8e172d035a0..d4fd0d07518 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -219,4 +219,10 @@ public function testBug14511(): void $this->analyse([__DIR__ . '/data/bug-14511.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug14557(): void + { + $this->analyse([__DIR__ . '/data/bug-14557-function.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 88bb38c5d40..aa5eff87fd6 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -389,4 +389,16 @@ public function testBug14511(): void $this->analyse([__DIR__ . '/data/bug-14511-method.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testBug14557(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14557.php'], [ + [ + 'Impure call to method Bug14557\SomeClass::impureStaticMethod() in pure method Bug14557\Foo::impureViaClassString().', + 85, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/bug-14557-function.php b/tests/PHPStan/Rules/Pure/data/bug-14557-function.php new file mode 100644 index 00000000000..c1b7ebbd225 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-14557-function.php @@ -0,0 +1,47 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14557Function; + +enum MyEnum: string +{ + case Foo = 'foo'; + case Bar = 'bar'; +} + +/** + * @param enum-string $enum + * @phpstan-pure + */ +function fromEnumString(string $enum): MyEnum +{ + return $enum::from('foo'); +} + +/** + * @param enum-string $enum + * @phpstan-pure + */ +function tryFromEnumString(string $enum): ?MyEnum +{ + return $enum::tryFrom('foo'); +} + +/** + * @param class-string $enum + * @phpstan-pure + */ +function fromClassString(string $enum): MyEnum +{ + return $enum::from('foo'); +} + +/** + * @param class-string $enum + * @phpstan-pure + */ +function tryFromClassString(string $enum): ?MyEnum +{ + return $enum::tryFrom('foo'); +} diff --git a/tests/PHPStan/Rules/Pure/data/bug-14557.php b/tests/PHPStan/Rules/Pure/data/bug-14557.php new file mode 100644 index 00000000000..792bc21ba35 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-14557.php @@ -0,0 +1,88 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14557; + +enum MyEnum: string +{ + case Foo = 'foo'; + case Bar = 'bar'; +} + +class SomeClass +{ + + /** @phpstan-pure */ + public static function pureStaticMethod(): int + { + return 1; + } + + /** @phpstan-impure */ + public static function impureStaticMethod(): int + { + echo 'hello'; + return 1; + } + +} + +class Foo +{ + + /** + * @param enum-string $enum + * @phpstan-pure + */ + public function doFoo(string $enum): MyEnum + { + return $enum::from('foo'); + } + + /** + * @param enum-string $enum + * @phpstan-pure + */ + public function doBar(string $enum): ?MyEnum + { + return $enum::tryFrom('foo'); + } + + /** + * @param class-string $enum + * @phpstan-pure + */ + public function doBaz(string $enum): MyEnum + { + return $enum::from('foo'); + } + + /** + * @param class-string $enum + * @phpstan-pure + */ + public function doLorem(string $enum): ?MyEnum + { + return $enum::tryFrom('foo'); + } + + /** + * @param class-string $class + * @phpstan-pure + */ + public function pureViaClassString(string $class): int + { + return $class::pureStaticMethod(); + } + + /** + * @param class-string $class + * @phpstan-pure + */ + public function impureViaClassString(string $class): int + { + return $class::impureStaticMethod(); // error + } + +} From e0e28b97a4bed7975246314cd52b58e147f22fe6 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 2 May 2026 20:02:46 +0200 Subject: [PATCH 2/5] Simplify --- src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- tests/PHPStan/Rules/Methods/data/dynamic-static-call.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 6ef0017e4fa..2869dcee52f 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -145,7 +145,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } } elseif ($expr->class instanceof Expr) { - $classType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); + $classType = $scope->getType($expr->class)->getObjectTypeOrClassStringObjectType(); $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { diff --git a/tests/PHPStan/Rules/Methods/data/dynamic-static-call.php b/tests/PHPStan/Rules/Methods/data/dynamic-static-call.php index b52e5df44d7..57db55a66ee 100644 --- a/tests/PHPStan/Rules/Methods/data/dynamic-static-call.php +++ b/tests/PHPStan/Rules/Methods/data/dynamic-static-call.php @@ -29,8 +29,8 @@ final static public function finalFoo():int class Baz { function doBaz(Foo $foo, FinalFoo $finalFoo, Bar $bar):void { - $foo::doFoo(); // no error, subclass could override static method with impure impl - $finalFoo::doFoo(); // could be "Call to static method .. on a separate line has no effect", because final class - $bar::finalFoo(); // could be "Call to static method .. on a separate line has no effect", because final method + $foo::doFoo(); + $finalFoo::doFoo(); + $bar::finalFoo(); } } From dea87661c2631bddcb8ee2b6bc9c25c96e450035 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 2 May 2026 20:05:13 +0200 Subject: [PATCH 3/5] Fix test --- .../CallToStaticMethodStatementWithoutSideEffectsRuleTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php index 56804e8a84b..4f3696de3ec 100644 --- a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php @@ -51,6 +51,10 @@ public function testRulePhp7(): void 'Call to static method DateTimeImmutable::createFromFormat() on a separate line has no effect.', 12, ], + [ + 'Call to static method DateTimeImmutable::createFromFormat() on a separate line has no effect.', + 13, + ], [ 'Call to method DateTime::format() on a separate line has no effect.', 23, From 24e0653e3fb6ddee837597c7e8e290d8dab91041 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 2 May 2026 18:48:08 +0000 Subject: [PATCH 4/5] Add non-regression test for dynamic static call with by-reference parameter Co-Authored-By: Claude Opus 4.6 --- .../IfConstantConditionRuleTest.php | 6 ++++ .../Rules/Comparison/data/bug-5020.php | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-5020.php diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index ac9c2d3329d..22f9d9577c0 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -237,4 +237,10 @@ public function testBug6822(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6822.php'], []); } + public function testBug5020(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-5020.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-5020.php b/tests/PHPStan/Rules/Comparison/data/bug-5020.php new file mode 100644 index 00000000000..816269eb90a --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-5020.php @@ -0,0 +1,33 @@ + $transformer + */ +function foo(string $transformer): void +{ + $input = ' asdasda asdasd '; + $error = false; + $output = $transformer::Transform($input, $error); + if ($error) { + + } +} From 998d1018efc07f56a2e56aeb24a7e1eabb81698c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 2 May 2026 19:16:59 +0000 Subject: [PATCH 5/5] Add test for dynamic static call on object instance (MyEnum $enum) Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Pure/PureMethodRuleTest.php | 2 +- tests/PHPStan/Rules/Pure/data/bug-14557-function.php | 8 ++++++++ tests/PHPStan/Rules/Pure/data/bug-14557.php | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index aa5eff87fd6..8287809407a 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -396,7 +396,7 @@ public function testBug14557(): void $this->analyse([__DIR__ . '/data/bug-14557.php'], [ [ 'Impure call to method Bug14557\SomeClass::impureStaticMethod() in pure method Bug14557\Foo::impureViaClassString().', - 85, + 93, ], ]); } diff --git a/tests/PHPStan/Rules/Pure/data/bug-14557-function.php b/tests/PHPStan/Rules/Pure/data/bug-14557-function.php index c1b7ebbd225..f79309e1641 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-14557-function.php +++ b/tests/PHPStan/Rules/Pure/data/bug-14557-function.php @@ -45,3 +45,11 @@ function tryFromClassString(string $enum): ?MyEnum { return $enum::tryFrom('foo'); } + +/** + * @phpstan-pure + */ +function fromEnum(MyEnum $enum): MyEnum +{ + return $enum::from('foo'); +} diff --git a/tests/PHPStan/Rules/Pure/data/bug-14557.php b/tests/PHPStan/Rules/Pure/data/bug-14557.php index 792bc21ba35..bcc9d020234 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-14557.php +++ b/tests/PHPStan/Rules/Pure/data/bug-14557.php @@ -67,6 +67,14 @@ public function doLorem(string $enum): ?MyEnum return $enum::tryFrom('foo'); } + /** + * @phpstan-pure + */ + public function fromEnum(MyEnum $enum): MyEnum + { + return $enum::from('foo'); + } + /** * @param class-string $class * @phpstan-pure