From c3f494bba9423740ad7f53a47834a7868ee8ee65 Mon Sep 17 00:00:00 2001 From: Dorian Villet Date: Thu, 2 Jul 2026 19:33:17 +0200 Subject: [PATCH] Check static method calls through constant class-name strings and class-string unions Co-Authored-By: Claude Fable 5 --- src/Rules/Methods/StaticMethodCallCheck.php | 40 +++- .../Methods/CallStaticMethodsRuleTest.php | 189 ++++++++++++++++- .../Methods/StaticMethodCallableRuleTest.php | 4 + .../PHPStan/Rules/Methods/data/bug-11353.php | 48 +++++ .../data/static-call-string-unions.php | 196 ++++++++++++++++++ .../Methods/data/static-method-callable.php | 11 + 6 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-11353.php create mode 100644 tests/PHPStan/Rules/Methods/data/static-call-string-unions.php diff --git a/src/Rules/Methods/StaticMethodCallCheck.php b/src/Rules/Methods/StaticMethodCallCheck.php index aaabe3d1e79..d792621648c 100644 --- a/src/Rules/Methods/StaticMethodCallCheck.php +++ b/src/Rules/Methods/StaticMethodCallCheck.php @@ -11,6 +11,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Internal\SprintfHelper; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\Native\NativeMethodReflection; @@ -32,6 +33,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; use function array_merge; +use function count; use function in_array; use function sprintf; use function strtolower; @@ -183,9 +185,24 @@ public function check( } } } else { + $exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class); + $exprType = $scope->getType($exprToCheck); + if (!$exprType->hasMethod($methodName)->yes()) { + // A constant class-name string or a class-string type describes the + // object it names, so route the check through that object type. This + // makes constant-string / class-string unions behave exactly like + // object unions with regard to rule levels (member filtering when + // checkUnionTypes is off, whole-union check when it is on). + // Every other type (objects, or unions with members that are not + // class-strings) yields no class name here and keeps the old path. + $objectType = $exprType->getClassStringObjectType(); + if ($this->canCheckThroughObjectType($objectType)) { + $exprToCheck = new TypeExpr($objectType); + } + } $classTypeResult = $this->ruleLevelHelper->findTypeToCheck( $scope, - NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class), + $exprToCheck, sprintf('Call to static method %s() on an unknown class %%s.', SprintfHelper::escapeFormatString($methodName)), static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(), ); @@ -349,4 +366,25 @@ public function check( return [$errors, $method]; } + /** + * Class-strings without a class name (class-string, class-string, + * class-string) and class-strings naming a trait keep the + * string code path: RuleLevelHelper would filter the object type out or + * report the trait as an unknown class. + */ + private function canCheckThroughObjectType(Type $objectType): bool + { + if (count($objectType->getObjectClassNames()) === 0) { + return false; + } + + foreach ($objectType->getObjectClassReflections() as $classReflection) { + if ($classReflection->isTrait()) { + return false; + } + } + + return true; + } + } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index fbed93ba6c9..dfe3f564689 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -30,10 +30,14 @@ class CallStaticMethodsRuleTest extends RuleTestCase private bool $checkThisOnly; + private bool $checkUnionTypes = true; + private bool $checkExplicitMixed = false; private bool $checkImplicitMixed = false; + private bool $reportMagicMethods = true; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -41,7 +45,7 @@ protected function getRule(): Rule $reflectionProvider, checkNullables: $this->checkNullables, checkThisOnly: $this->checkThisOnly, - checkUnionTypes: true, + checkUnionTypes: $this->checkUnionTypes, checkExplicitMixed: $this->checkExplicitMixed, checkImplicitMixed: $this->checkImplicitMixed, checkBenevolentUnionTypes: false, @@ -64,7 +68,7 @@ protected function getRule(): Rule ), checkFunctionNameCase: true, discoveringSymbolsTip: true, - reportMagicMethods: true, + reportMagicMethods: $this->reportMagicMethods, ), new FunctionCallParametersCheck( $ruleLevelHelper, @@ -1132,4 +1136,185 @@ public function testBug15251(): void ]); } + #[RequiresPhp('>= 8.1.0')] + public function testBug11353(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/bug-11353.php'], [ + [ + 'Call to an undefined static method Bug11353\A|Bug11353\B|Bug11353\C::create().', + 46, + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testStaticCallsOnStringUnions(): void + { + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [ + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 53, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 59, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 71, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 79, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 87, + ], + [ + 'Static call to instance method StaticCallStringUnions\Foo::doBar().', + 101, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().', + 107, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Foo|StaticCallStringUnions\WithMagic::create().', + 115, + ], + [ + 'Call to an undefined static method object::create().', + 133, + ], + [ + 'Call to an undefined static method T of object::create().', + 142, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 150, + ], + [ + 'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.', + 162, + ], + [ + 'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.', + 163, + ], + [ + 'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.', + 171, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testStaticCallsOnStringUnionsWithoutCheckUnionTypes(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = false; + $this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [ + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 53, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 87, + ], + [ + 'Static call to instance method StaticCallStringUnions\Foo::doBar().', + 101, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().', + 107, + ], + [ + 'Call to an undefined static method object::create().', + 133, + ], + [ + 'Call to an undefined static method T of object::create().', + 142, + ], + [ + 'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.', + 162, + ], + [ + 'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.', + 163, + ], + [ + 'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.', + 171, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + ]); + } + + #[RequiresPhp('>= 8.0.0')] + public function testStaticCallsOnStringUnionsWithoutReportMagicMethods(): void + { + $this->checkThisOnly = false; + $this->reportMagicMethods = false; + $this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [ + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 53, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 59, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 71, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 79, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz::create().', + 87, + ], + [ + 'Static call to instance method StaticCallStringUnions\Foo::doBar().', + 101, + ], + [ + 'Call to an undefined static method object::create().', + 133, + ], + [ + 'Call to an undefined static method T of object::create().', + 142, + ], + [ + 'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().', + 150, + ], + [ + 'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.', + 162, + ], + [ + 'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.', + 163, + ], + [ + 'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.', + 171, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php b/tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php index 6576eecfdd2..3057b8ab511 100644 --- a/tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php +++ b/tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php @@ -104,6 +104,10 @@ public function testRule(): void 'Cannot call static method doFoo() on int.', 22, ], + [ + 'Call to an undefined static method StaticMethodCallable\Bar|StaticMethodCallable\Foo::doFoo().', + 77, + ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-11353.php b/tests/PHPStan/Rules/Methods/data/bug-11353.php new file mode 100644 index 00000000000..3fccbdce9d9 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-11353.php @@ -0,0 +1,48 @@ += 8.1 + +namespace Bug11353; + +enum Identifier: string +{ + case A = 'identifier_a'; + case B = 'identifier_b'; + case C = 'identifier_c'; +} + +class A +{ + public static function create(): self + { + return new self(); + } +} + +class B +{ + public static function create(): self + { + return new self(); + } +} + +class C +{ + // missing static function create(): self +} + +class SomeCliCommand +{ + public function execute(string $input): void + { + $identifier = Identifier::from($input); + + $classFQCN = match ($identifier) { + Identifier::A => A::class, + Identifier::B => B::class, + Identifier::C => C::class, + }; + + // No warning that C::create() does not exist + $class = $classFQCN::create(); + } +} diff --git a/tests/PHPStan/Rules/Methods/data/static-call-string-unions.php b/tests/PHPStan/Rules/Methods/data/static-call-string-unions.php new file mode 100644 index 00000000000..8b73647c144 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/static-call-string-unions.php @@ -0,0 +1,196 @@ += 8.0 + +namespace StaticCallStringUnions; + +class Foo +{ + + public static function create(): self + { + return new self(); + } + + public function doBar(): void + { + } + +} + +class Bar +{ + + public static function create(): self + { + return new self(); + } + +} + +class Baz +{ + +} + +class WithMagic +{ + + /** + * @param array $args + */ + public static function __callStatic(string $name, array $args): mixed + { + return null; + } + +} + +class Runner +{ + + public function singleConstantString(): void + { + $class = Baz::class; + $class::create(); + } + + public function unionOfConstantStrings(bool $flag): void + { + $class = $flag ? Foo::class : Baz::class; + $class::create(); + } + + public function unionOfConstantStringsAllValid(bool $flag): void + { + $class = $flag ? Foo::class : Bar::class; + $class::create(); + } + + public function getClassUnion(Foo|Baz $object): void + { + $class = get_class($object); + $class::create(); + } + + /** + * @param class-string|class-string $class + */ + public function genericClassStringUnion(string $class): void + { + $class::create(); + } + + /** + * @param class-string $class + */ + public function singleGenericClassString(string $class): void + { + $class::create(); + } + + /** + * @param class-string $class + */ + public function plainClassString(string $class): void + { + $class::create(); + } + + public function instanceMethodThroughConstantString(): void + { + $class = Foo::class; + $class::doBar(); + } + + public function magicCallStaticThroughConstantString(): void + { + $class = WithMagic::class; + $class::whatever(); + } + + /** + * @param class-string|class-string $class + */ + public function unionWithMagicMember(string $class): void + { + $class::create(); + } + + /** + * @param class-string $class + */ + public function methodExistsNarrowing(string $class): void + { + if (method_exists($class, 'doSomethingElse')) { + $class::doSomethingElse(); + } + } + + /** + * @param class-string $class + */ + public function objectClassString(string $class): void + { + $class::create(); + } + + /** + * @template T of object + * @param class-string $class + */ + public function templateObjectClassString(string $class): void + { + $class::create(); + } + + /** + * @param class-string $class + */ + public function classStringOfUnion(string $class): void + { + $class::create(); + } + + public function traitThroughConstantString(): void + { + $trait = SomeTrait::class; + $trait::traitMethod(); + } + + public function visibilityThroughConstantString(): void + { + $class = WithNonPublic::class; + $class::privateMethod(); + $class::protectedMethod(); + } + + /** + * @param class-string $class + */ + public function unknownClassString(string $class): void + { + $class::create(); + } + +} + +trait SomeTrait +{ + + public static function traitMethod(): void + { + } + +} + +class WithNonPublic +{ + + private static function privateMethod(): void + { + } + + protected static function protectedMethod(): void + { + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/static-method-callable.php b/tests/PHPStan/Rules/Methods/data/static-method-callable.php index d8256c1c4b7..0e7740b66b7 100644 --- a/tests/PHPStan/Rules/Methods/data/static-method-callable.php +++ b/tests/PHPStan/Rules/Methods/data/static-method-callable.php @@ -67,3 +67,14 @@ public function doFoo() } } + +class Dolor +{ + + public function doFoo(bool $flag): void + { + $class = $flag ? Foo::class : Bar::class; + $class::doFoo(...); + } + +}