From af7874451f084b5fef0be923efb7bdf9bf0810d5 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 23 May 2026 11:13:25 +0000 Subject: [PATCH 1/2] Do not add `HasMethodType` to class-string types when the method is native - In `MethodExistsTypeSpecifyingExtension`, when narrowing class-string types after `method_exists()`, use `createFuncCallSpec` instead of adding `HasMethodType` when all class reflections have the method natively. - Previously, adding `HasMethodType` to the class-string type changed its representation from `GenericClassStringType` to an `IntersectionType`, which caused `StaticMethodCallCheck` to skip visibility checking via two paths: the early return at line 72-74 for Name nodes, and the `isString()->yes()` bail-out at line 203-205 for expression nodes. - With this fix, the class-string type is preserved, and the normal visibility checking flow in `StaticMethodCallCheck` correctly reports calls to private/protected static methods even when guarded by `method_exists()`. Closes https://github.com/phpstan/phpstan/issues/14684 --- .../MethodExistsTypeSpecifyingExtension.php | 13 +++- .../Methods/CallStaticMethodsRuleTest.php | 36 +++++++++++ .../PHPStan/Rules/Methods/data/bug-14684.php | 62 +++++++++++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14684.php diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 29621c1342..f66db635ea 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -59,10 +59,21 @@ public function specifyTypes( $objectOrStringType = $scope->getType($args[0]->value); if ($objectOrStringType->isString()->yes()) { if ($objectOrStringType->isClassString()->yes()) { - foreach ($objectOrStringType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { + $allNative = true; + $classReflections = $objectOrStringType->getClassStringObjectType()->getObjectClassReflections(); + foreach ($classReflections as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { return $this->createFuncCallSpec($node, $context, $scope); } + if ($classReflection->hasNativeMethod($methodNameType->getValue())) { + continue; + } + + $allNative = false; + } + + if ($allNative && $classReflections !== []) { + return $this->createFuncCallSpec($node, $context, $scope); } return $this->typeSpecifier->create( diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 47a70c9d02..220df23cb2 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1033,4 +1033,40 @@ public function testBug14596(): void ]); } + public function testBug14684(): void + { + $this->checkThisOnly = false; + $this->checkExplicitMixed = false; + $this->analyse([__DIR__ . '/data/bug-14684.php'], [ + [ + 'Call to private static method privateFoo() of class Bug14684\X.', + 25, + ], + [ + 'Call to protected static method protectedFoo() of class Bug14684\X.', + 29, + ], + [ + 'Call to private static method privateFoo() of class Bug14684\SubX.', + 41, + ], + [ + 'Call to protected static method protectedFoo() of class Bug14684\X.', + 45, + ], + [ + 'Call to private static method privateFoo() of class Bug14684\X.', + 52, + ], + [ + 'Call to protected static method protectedFoo() of class Bug14684\X.', + 56, + ], + [ + 'Call to private static method privateFoo() of class Bug14684\SubX.', + 60, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14684.php b/tests/PHPStan/Rules/Methods/data/bug-14684.php new file mode 100644 index 0000000000..dca6387f9e --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14684.php @@ -0,0 +1,62 @@ + $row */ +function testClassStringFinalMethod(string $row): void +{ + if (method_exists($row, 'publicFoo')) { + $row::publicFoo(); + } + + if (method_exists($row, 'privateFoo')) { + $row::privateFoo(); + } + + if (method_exists($row, 'protectedFoo')) { + $row::protectedFoo(); + } +} + +/** @param class-string $row */ +function testClassStringFinalClass(string $row): void +{ + if (method_exists($row, 'publicFoo')) { + $row::publicFoo(); + } + + if (method_exists($row, 'privateFoo')) { + $row::privateFoo(); + } + + if (method_exists($row, 'protectedFoo')) { + $row::protectedFoo(); + } +} + +function testLiteralClassCall(): void +{ + if (method_exists(X::class, 'privateFoo')) { + X::privateFoo(); + } + + if (method_exists(X::class, 'protectedFoo')) { + X::protectedFoo(); + } + + if (method_exists(SubX::class, 'privateFoo')) { + SubX::privateFoo(); + } +} From dfa6f995fb5f83c3096afe80511724f5eb4ec62d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 24 May 2026 12:16:04 +0000 Subject: [PATCH 2/2] Check visibility for class-string static calls with HasMethodType in intersection When method_exists() narrowing adds HasMethodType to a class-string type (e.g. class-string&hasMethod(foo)), StaticMethodCallCheck previously returned early at the isString()->yes() check, skipping visibility checking entirely. This also affected the union case class-string where only some classes have the method natively. Fix: when the type is a class-string (isClassString()->yes()), extract the concrete class types that have the method natively and proceed with visibility checking on those, rather than returning early. Co-Authored-By: Claude Opus 4.6 --- src/Rules/Methods/StaticMethodCallCheck.php | 13 +++++++++++++ .../Methods/CallStaticMethodsRuleTest.php | 8 ++++++++ .../PHPStan/Rules/Methods/data/bug-14684.php | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/src/Rules/Methods/StaticMethodCallCheck.php b/src/Rules/Methods/StaticMethodCallCheck.php index aaabe3d1e7..b3da13562a 100644 --- a/src/Rules/Methods/StaticMethodCallCheck.php +++ b/src/Rules/Methods/StaticMethodCallCheck.php @@ -26,6 +26,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\ObjectType; use PHPStan\Type\StaticType; use PHPStan\Type\StringType; use PHPStan\Type\Type; @@ -200,6 +201,18 @@ public function check( if (!$classType->isObject()->yes()) { return [[], null]; } + } elseif ($classType->isClassString()->yes()) { + $nativeMethodTypes = []; + foreach ($classType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { + if ($classReflection->hasNativeMethod($methodName)) { + $nativeMethodTypes[] = new ObjectType($classReflection->getName()); + } + } + if ($nativeMethodTypes !== []) { + $classType = TypeCombinator::union(...$nativeMethodTypes); + } else { + return [[], null]; + } } elseif ($classType->isString()->yes()) { return [[], null]; } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 220df23cb2..5b4255282a 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -1066,6 +1066,14 @@ public function testBug14684(): void 'Call to private static method privateFoo() of class Bug14684\SubX.', 60, ], + [ + 'Call to private static method privateFoo() of class Bug14684\X.', + 71, + ], + [ + 'Call to protected static method protectedFoo() of class Bug14684\X.', + 75, + ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14684.php b/tests/PHPStan/Rules/Methods/data/bug-14684.php index dca6387f9e..720bd5ce6e 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14684.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14684.php @@ -60,3 +60,22 @@ function testLiteralClassCall(): void SubX::privateFoo(); } } + +class Y { +} + +/** @param class-string $row */ +function testClassStringUnion(string $row): void +{ + if (method_exists($row, 'privateFoo')) { + $row::privateFoo(); + } + + if (method_exists($row, 'protectedFoo')) { + $row::protectedFoo(); + } + + if (method_exists($row, 'publicFoo')) { + $row::publicFoo(); + } +}