Skip to content

Commit b2aad69

Browse files
gnutixclaude
authored andcommitted
Check static method calls through constant class-name strings and class-string unions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 091c5a7 commit b2aad69

6 files changed

Lines changed: 485 additions & 3 deletions

File tree

‎src/Rules/Methods/StaticMethodCallCheck.php‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\DependencyInjection\AutowiredParameter;
1212
use PHPStan\DependencyInjection\AutowiredService;
1313
use PHPStan\Internal\SprintfHelper;
14+
use PHPStan\Node\Expr\TypeExpr;
1415
use PHPStan\Reflection\ExtendedMethodReflection;
1516
use PHPStan\Reflection\MethodReflection;
1617
use PHPStan\Reflection\Native\NativeMethodReflection;
@@ -32,6 +33,7 @@
3233
use PHPStan\Type\TypeCombinator;
3334
use PHPStan\Type\VerbosityLevel;
3435
use function array_merge;
36+
use function count;
3537
use function in_array;
3638
use function sprintf;
3739
use function strtolower;
@@ -183,9 +185,24 @@ public function check(
183185
}
184186
}
185187
} else {
188+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
189+
$exprType = $scope->getType($exprToCheck);
190+
if (!$exprType->hasMethod($methodName)->yes()) {
191+
// A constant class-name string or a class-string type describes the
192+
// object it names, so route the check through that object type. This
193+
// makes constant-string / class-string unions behave exactly like
194+
// object unions with regard to rule levels (member filtering when
195+
// checkUnionTypes is off, whole-union check when it is on).
196+
// Every other type (objects, or unions with members that are not
197+
// class-strings) yields no class name here and keeps the old path.
198+
$objectType = $exprType->getClassStringObjectType();
199+
if ($this->canCheckThroughObjectType($objectType)) {
200+
$exprToCheck = new TypeExpr($objectType);
201+
}
202+
}
186203
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
187204
$scope,
188-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
205+
$exprToCheck,
189206
sprintf('Call to static method %s() on an unknown class %%s.', SprintfHelper::escapeFormatString($methodName)),
190207
static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(),
191208
);
@@ -349,4 +366,25 @@ public function check(
349366
return [$errors, $method];
350367
}
351368

369+
/**
370+
* Class-strings without a class name (class-string, class-string<object>,
371+
* class-string<T of object>) and class-strings naming a trait keep the
372+
* string code path: RuleLevelHelper would filter the object type out or
373+
* report the trait as an unknown class.
374+
*/
375+
private function canCheckThroughObjectType(Type $objectType): bool
376+
{
377+
if (count($objectType->getObjectClassNames()) === 0) {
378+
return false;
379+
}
380+
381+
foreach ($objectType->getObjectClassReflections() as $classReflection) {
382+
if ($classReflection->isTrait()) {
383+
return false;
384+
}
385+
}
386+
387+
return true;
388+
}
389+
352390
}

‎tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php‎

Lines changed: 187 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,22 @@ class CallStaticMethodsRuleTest extends RuleTestCase
3030

3131
private bool $checkThisOnly;
3232

33+
private bool $checkUnionTypes = true;
34+
3335
private bool $checkExplicitMixed = false;
3436

3537
private bool $checkImplicitMixed = false;
3638

39+
private bool $reportMagicMethods = true;
40+
3741
protected function getRule(): Rule
3842
{
3943
$reflectionProvider = self::createReflectionProvider();
4044
$ruleLevelHelper = new RuleLevelHelper(
4145
$reflectionProvider,
4246
checkNullables: $this->checkNullables,
4347
checkThisOnly: $this->checkThisOnly,
44-
checkUnionTypes: true,
48+
checkUnionTypes: $this->checkUnionTypes,
4549
checkExplicitMixed: $this->checkExplicitMixed,
4650
checkImplicitMixed: $this->checkImplicitMixed,
4751
checkBenevolentUnionTypes: false,
@@ -64,7 +68,7 @@ protected function getRule(): Rule
6468
),
6569
checkFunctionNameCase: true,
6670
discoveringSymbolsTip: true,
67-
reportMagicMethods: true,
71+
reportMagicMethods: $this->reportMagicMethods,
6872
),
6973
new FunctionCallParametersCheck(
7074
$ruleLevelHelper,
@@ -1156,4 +1160,185 @@ public function testBug11421(): void
11561160
]);
11571161
}
11581162

1163+
#[RequiresPhp('>= 8.1.0')]
1164+
public function testBug11353(): void
1165+
{
1166+
$this->checkThisOnly = false;
1167+
$this->analyse([__DIR__ . '/data/bug-11353.php'], [
1168+
[
1169+
'Call to an undefined static method Bug11353\A|Bug11353\B|Bug11353\C::create().',
1170+
46,
1171+
],
1172+
]);
1173+
}
1174+
1175+
#[RequiresPhp('>= 8.0.0')]
1176+
public function testStaticCallsOnStringUnions(): void
1177+
{
1178+
$this->checkThisOnly = false;
1179+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1180+
[
1181+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1182+
53,
1183+
],
1184+
[
1185+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1186+
59,
1187+
],
1188+
[
1189+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1190+
71,
1191+
],
1192+
[
1193+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1194+
79,
1195+
],
1196+
[
1197+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1198+
87,
1199+
],
1200+
[
1201+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1202+
101,
1203+
],
1204+
[
1205+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1206+
107,
1207+
],
1208+
[
1209+
'Call to an undefined static method StaticCallStringUnions\Foo|StaticCallStringUnions\WithMagic::create().',
1210+
115,
1211+
],
1212+
[
1213+
'Call to an undefined static method object::create().',
1214+
133,
1215+
],
1216+
[
1217+
'Call to an undefined static method T of object::create().',
1218+
142,
1219+
],
1220+
[
1221+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1222+
150,
1223+
],
1224+
[
1225+
'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.',
1226+
162,
1227+
],
1228+
[
1229+
'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.',
1230+
163,
1231+
],
1232+
[
1233+
'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.',
1234+
171,
1235+
'Learn more at https://phpstan.org/user-guide/discovering-symbols',
1236+
],
1237+
]);
1238+
}
1239+
1240+
#[RequiresPhp('>= 8.0.0')]
1241+
public function testStaticCallsOnStringUnionsWithoutCheckUnionTypes(): void
1242+
{
1243+
$this->checkThisOnly = false;
1244+
$this->checkUnionTypes = false;
1245+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1246+
[
1247+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1248+
53,
1249+
],
1250+
[
1251+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1252+
87,
1253+
],
1254+
[
1255+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1256+
101,
1257+
],
1258+
[
1259+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1260+
107,
1261+
],
1262+
[
1263+
'Call to an undefined static method object::create().',
1264+
133,
1265+
],
1266+
[
1267+
'Call to an undefined static method T of object::create().',
1268+
142,
1269+
],
1270+
[
1271+
'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.',
1272+
162,
1273+
],
1274+
[
1275+
'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.',
1276+
163,
1277+
],
1278+
[
1279+
'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.',
1280+
171,
1281+
'Learn more at https://phpstan.org/user-guide/discovering-symbols',
1282+
],
1283+
]);
1284+
}
1285+
1286+
#[RequiresPhp('>= 8.0.0')]
1287+
public function testStaticCallsOnStringUnionsWithoutReportMagicMethods(): void
1288+
{
1289+
$this->checkThisOnly = false;
1290+
$this->reportMagicMethods = false;
1291+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1292+
[
1293+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1294+
53,
1295+
],
1296+
[
1297+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1298+
59,
1299+
],
1300+
[
1301+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1302+
71,
1303+
],
1304+
[
1305+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1306+
79,
1307+
],
1308+
[
1309+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1310+
87,
1311+
],
1312+
[
1313+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1314+
101,
1315+
],
1316+
[
1317+
'Call to an undefined static method object::create().',
1318+
133,
1319+
],
1320+
[
1321+
'Call to an undefined static method T of object::create().',
1322+
142,
1323+
],
1324+
[
1325+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1326+
150,
1327+
],
1328+
[
1329+
'Call to private static method privateMethod() of class StaticCallStringUnions\WithNonPublic.',
1330+
162,
1331+
],
1332+
[
1333+
'Call to protected static method protectedMethod() of class StaticCallStringUnions\WithNonPublic.',
1334+
163,
1335+
],
1336+
[
1337+
'Call to static method create() on an unknown class StaticCallStringUnions\UnknownClass.',
1338+
171,
1339+
'Learn more at https://phpstan.org/user-guide/discovering-symbols',
1340+
],
1341+
]);
1342+
}
1343+
11591344
}

‎tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ public function testRule(): void
104104
'Cannot call static method doFoo() on int.',
105105
22,
106106
],
107+
[
108+
'Call to an undefined static method StaticMethodCallable\Bar|StaticMethodCallable\Foo::doFoo().',
109+
77,
110+
],
107111
]);
108112
}
109113

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace Bug11353;
4+
5+
enum Identifier: string
6+
{
7+
case A = 'identifier_a';
8+
case B = 'identifier_b';
9+
case C = 'identifier_c';
10+
}
11+
12+
class A
13+
{
14+
public static function create(): self
15+
{
16+
return new self();
17+
}
18+
}
19+
20+
class B
21+
{
22+
public static function create(): self
23+
{
24+
return new self();
25+
}
26+
}
27+
28+
class C
29+
{
30+
// missing static function create(): self
31+
}
32+
33+
class SomeCliCommand
34+
{
35+
public function execute(string $input): void
36+
{
37+
$identifier = Identifier::from($input);
38+
39+
$classFQCN = match ($identifier) {
40+
Identifier::A => A::class,
41+
Identifier::B => B::class,
42+
Identifier::C => C::class,
43+
};
44+
45+
// No warning that C::create() does not exist
46+
$class = $classFQCN::create();
47+
}
48+
}

0 commit comments

Comments
 (0)