Skip to content

Commit 888bf9c

Browse files
gnutixclaude
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 411daa4 commit 888bf9c

6 files changed

Lines changed: 329 additions & 3 deletions

File tree

src/Rules/Methods/StaticMethodCallCheck.php

Lines changed: 18 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->isClassString()->yes() && !$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+
$objectType = $exprType->getClassStringObjectType();
197+
if (count($objectType->getObjectClassNames()) === 0) {
198+
// plain class-string resolves to ObjectWithoutClassType, keep silent
199+
return [[], null];
200+
}
201+
$exprToCheck = new TypeExpr($objectType);
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
);

tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,22 @@ class CallStaticMethodsRuleTest extends RuleTestCase
2525

2626
private bool $checkThisOnly;
2727

28+
private bool $checkUnionTypes = true;
29+
2830
private bool $checkExplicitMixed = false;
2931

3032
private bool $checkImplicitMixed = false;
3133

34+
private bool $reportMagicMethods = true;
35+
3236
protected function getRule(): Rule
3337
{
3438
$reflectionProvider = self::createReflectionProvider();
3539
$ruleLevelHelper = new RuleLevelHelper(
3640
$reflectionProvider,
3741
checkNullables: true,
3842
checkThisOnly: $this->checkThisOnly,
39-
checkUnionTypes: true,
43+
checkUnionTypes: $this->checkUnionTypes,
4044
checkExplicitMixed: $this->checkExplicitMixed,
4145
checkImplicitMixed: $this->checkImplicitMixed,
4246
checkBenevolentUnionTypes: false,
@@ -58,7 +62,7 @@ protected function getRule(): Rule
5862
),
5963
checkFunctionNameCase: true,
6064
discoveringSymbolsTip: true,
61-
reportMagicMethods: true,
65+
reportMagicMethods: $this->reportMagicMethods,
6266
),
6367
new FunctionCallParametersCheck(
6468
$ruleLevelHelper,
@@ -1057,4 +1061,114 @@ public function testClassExistOnCall(): void
10571061
$this->analyse([__DIR__ . '/data/class-exists-on-static-call.php'], []);
10581062
}
10591063

1064+
#[RequiresPhp('>= 8.1.0')]
1065+
public function testBug11353(): void
1066+
{
1067+
$this->checkThisOnly = false;
1068+
$this->analyse([__DIR__ . '/data/bug-11353.php'], [
1069+
[
1070+
'Call to an undefined static method Bug11353\A|Bug11353\B|Bug11353\C::create().',
1071+
46,
1072+
],
1073+
]);
1074+
}
1075+
1076+
#[RequiresPhp('>= 8.0.0')]
1077+
public function testStaticCallsOnStringUnions(): void
1078+
{
1079+
$this->checkThisOnly = false;
1080+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1081+
[
1082+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1083+
53,
1084+
],
1085+
[
1086+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1087+
59,
1088+
],
1089+
[
1090+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1091+
71,
1092+
],
1093+
[
1094+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1095+
79,
1096+
],
1097+
[
1098+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1099+
87,
1100+
],
1101+
[
1102+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1103+
101,
1104+
],
1105+
[
1106+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1107+
107,
1108+
],
1109+
[
1110+
'Call to an undefined static method StaticCallStringUnions\Foo|StaticCallStringUnions\WithMagic::create().',
1111+
115,
1112+
],
1113+
]);
1114+
}
1115+
1116+
#[RequiresPhp('>= 8.0.0')]
1117+
public function testStaticCallsOnStringUnionsWithoutCheckUnionTypes(): void
1118+
{
1119+
$this->checkThisOnly = false;
1120+
$this->checkUnionTypes = false;
1121+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1122+
[
1123+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1124+
53,
1125+
],
1126+
[
1127+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1128+
87,
1129+
],
1130+
[
1131+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1132+
101,
1133+
],
1134+
[
1135+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1136+
107,
1137+
],
1138+
]);
1139+
}
1140+
1141+
#[RequiresPhp('>= 8.0.0')]
1142+
public function testStaticCallsOnStringUnionsWithoutReportMagicMethods(): void
1143+
{
1144+
$this->checkThisOnly = false;
1145+
$this->reportMagicMethods = false;
1146+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1147+
[
1148+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1149+
53,
1150+
],
1151+
[
1152+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1153+
59,
1154+
],
1155+
[
1156+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1157+
71,
1158+
],
1159+
[
1160+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1161+
79,
1162+
],
1163+
[
1164+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1165+
87,
1166+
],
1167+
[
1168+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1169+
101,
1170+
],
1171+
]);
1172+
}
1173+
10601174
}

tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug11353;
6+
7+
enum Identifier: string
8+
{
9+
case A = 'identifier_a';
10+
case B = 'identifier_b';
11+
case C = 'identifier_c';
12+
}
13+
14+
class A
15+
{
16+
public static function create(): self
17+
{
18+
return new self();
19+
}
20+
}
21+
22+
class B
23+
{
24+
public static function create(): self
25+
{
26+
return new self();
27+
}
28+
}
29+
30+
class C
31+
{
32+
// missing static function create(): self
33+
}
34+
35+
class SomeCliCommand
36+
{
37+
public function execute(string $input): void
38+
{
39+
$identifier = Identifier::from($input);
40+
41+
$classFQCN = match ($identifier) {
42+
Identifier::A => A::class,
43+
Identifier::B => B::class,
44+
Identifier::C => C::class,
45+
};
46+
47+
// No warning that C::create() does not exist
48+
$class = $classFQCN::create();
49+
}
50+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace StaticCallStringUnions;
6+
7+
class Foo
8+
{
9+
10+
public static function create(): self
11+
{
12+
return new self();
13+
}
14+
15+
public function doBar(): void
16+
{
17+
}
18+
19+
}
20+
21+
class Bar
22+
{
23+
24+
public static function create(): self
25+
{
26+
return new self();
27+
}
28+
29+
}
30+
31+
class Baz
32+
{
33+
34+
}
35+
36+
class WithMagic
37+
{
38+
39+
/**
40+
* @param array<mixed> $args
41+
*/
42+
public static function __callStatic(string $name, array $args): mixed
43+
{
44+
return null;
45+
}
46+
47+
}
48+
49+
class Runner
50+
{
51+
52+
public function singleConstantString(): void
53+
{
54+
$class = Baz::class;
55+
$class::create();
56+
}
57+
58+
public function unionOfConstantStrings(bool $flag): void
59+
{
60+
$class = $flag ? Foo::class : Baz::class;
61+
$class::create();
62+
}
63+
64+
public function unionOfConstantStringsAllValid(bool $flag): void
65+
{
66+
$class = $flag ? Foo::class : Bar::class;
67+
$class::create();
68+
}
69+
70+
public function getClassUnion(Foo|Baz $object): void
71+
{
72+
$class = get_class($object);
73+
$class::create();
74+
}
75+
76+
/**
77+
* @param class-string<Foo>|class-string<Baz> $class
78+
*/
79+
public function genericClassStringUnion(string $class): void
80+
{
81+
$class::create();
82+
}
83+
84+
/**
85+
* @param class-string<Baz> $class
86+
*/
87+
public function singleGenericClassString(string $class): void
88+
{
89+
$class::create();
90+
}
91+
92+
/**
93+
* @param class-string $class
94+
*/
95+
public function plainClassString(string $class): void
96+
{
97+
$class::create();
98+
}
99+
100+
public function instanceMethodThroughConstantString(): void
101+
{
102+
$class = Foo::class;
103+
$class::doBar();
104+
}
105+
106+
public function magicCallStaticThroughConstantString(): void
107+
{
108+
$class = WithMagic::class;
109+
$class::whatever();
110+
}
111+
112+
/**
113+
* @param class-string<Foo>|class-string<WithMagic> $class
114+
*/
115+
public function unionWithMagicMember(string $class): void
116+
{
117+
$class::create();
118+
}
119+
120+
/**
121+
* @param class-string<Foo> $class
122+
*/
123+
public function methodExistsNarrowing(string $class): void
124+
{
125+
if (method_exists($class, 'doSomethingElse')) {
126+
$class::doSomethingElse();
127+
}
128+
}
129+
130+
}

tests/PHPStan/Rules/Methods/data/static-method-callable.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,14 @@ public function doFoo()
6767
}
6868

6969
}
70+
71+
class Dolor
72+
{
73+
74+
public function doFoo(bool $flag): void
75+
{
76+
$class = $flag ? Foo::class : Bar::class;
77+
$class::doFoo(...);
78+
}
79+
80+
}

0 commit comments

Comments
 (0)