Skip to content

Commit 6faefb4

Browse files
gnutixclaude
andcommitted
Check class constant access through constant class-name strings and class-string unions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 411daa4 commit 6faefb4

3 files changed

Lines changed: 198 additions & 2 deletions

File tree

src/Rules/Classes/ClassConstantRule.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\DependencyInjection\AutowiredParameter;
1313
use PHPStan\DependencyInjection\RegisteredRule;
1414
use PHPStan\Internal\SprintfHelper;
15+
use PHPStan\Node\Expr\TypeExpr;
1516
use PHPStan\Php\PhpVersion;
1617
use PHPStan\Reflection\ReflectionProvider;
1718
use PHPStan\Rules\ClassNameCheck;
@@ -28,6 +29,7 @@
2829
use PHPStan\Type\TypeCombinator;
2930
use PHPStan\Type\VerbosityLevel;
3031
use function array_merge;
32+
use function count;
3133
use function in_array;
3234
use function sprintf;
3335
use function strtolower;
@@ -198,9 +200,28 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
198200
return $messages;
199201
}
200202
} else {
203+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
204+
if (strtolower($constantName) !== 'class') {
205+
$exprType = $scope->getType($exprToCheck);
206+
if ($exprType->isClassString()->yes() && !$exprType->hasConstant($constantName)->yes()) {
207+
// A constant class-name string or a class-string type describes the
208+
// object it names, so route the check through that object type. This
209+
// makes constant-string / class-string unions behave exactly like
210+
// object unions with regard to rule levels (member filtering when
211+
// checkUnionTypes is off, whole-union check when it is on).
212+
// The ::class pseudo-constant is excluded above because
213+
// $string::class is a runtime TypeError, handled below as a string.
214+
$objectType = $exprType->getClassStringObjectType();
215+
if (count($objectType->getObjectClassNames()) === 0) {
216+
// plain class-string resolves to ObjectWithoutClassType, keep silent
217+
return $messages;
218+
}
219+
$exprToCheck = new TypeExpr($objectType);
220+
}
221+
}
201222
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
202223
$scope,
203-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
224+
$exprToCheck,
204225
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
205226
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
206227
);

tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class ClassConstantRuleTest extends RuleTestCase
2121

2222
private int $phpVersion;
2323

24+
private bool $checkUnionTypes = true;
25+
2426
protected function getRule(): Rule
2527
{
2628
$reflectionProvider = self::createReflectionProvider();
@@ -31,7 +33,7 @@ protected function getRule(): Rule
3133
$reflectionProvider,
3234
checkNullables: true,
3335
checkThisOnly: false,
34-
checkUnionTypes: true,
36+
checkUnionTypes: $this->checkUnionTypes,
3537
checkExplicitMixed: true,
3638
checkImplicitMixed: true,
3739
checkBenevolentUnionTypes: false,
@@ -567,4 +569,65 @@ public function testStringableDynamicAccess(): void
567569
]);
568570
}
569571

572+
#[RequiresPhp('>= 8.1.0')]
573+
public function testClassConstantAccessOnStringUnions(): void
574+
{
575+
$this->phpVersion = PHP_VERSION_ID;
576+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
577+
[
578+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
579+
39,
580+
],
581+
[
582+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
583+
45,
584+
],
585+
[
586+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
587+
57,
588+
],
589+
[
590+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
591+
65,
592+
],
593+
[
594+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
595+
73,
596+
],
597+
[
598+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
599+
87,
600+
],
601+
[
602+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
603+
95,
604+
],
605+
]);
606+
}
607+
608+
#[RequiresPhp('>= 8.1.0')]
609+
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
610+
{
611+
$this->phpVersion = PHP_VERSION_ID;
612+
$this->checkUnionTypes = false;
613+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
614+
[
615+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
616+
39,
617+
],
618+
[
619+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
620+
73,
621+
],
622+
[
623+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
624+
87,
625+
],
626+
[
627+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
628+
95,
629+
],
630+
]);
631+
}
632+
570633
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace ClassConstantStringUnions;
4+
5+
class Foo
6+
{
7+
8+
const BAR = 1;
9+
10+
}
11+
12+
class Bar
13+
{
14+
15+
const BAR = 2;
16+
17+
}
18+
19+
class Baz
20+
{
21+
22+
}
23+
24+
enum SomeEnum: string
25+
{
26+
27+
case A = 'a';
28+
29+
const K = 1;
30+
31+
}
32+
33+
class Runner
34+
{
35+
36+
public function singleConstantString(): void
37+
{
38+
$class = Baz::class;
39+
echo $class::BAR;
40+
}
41+
42+
public function unionOfConstantStrings(bool $flag): void
43+
{
44+
$class = $flag ? Foo::class : Baz::class;
45+
echo $class::BAR;
46+
}
47+
48+
public function unionOfConstantStringsAllValid(bool $flag): void
49+
{
50+
$class = $flag ? Foo::class : Bar::class;
51+
echo $class::BAR;
52+
}
53+
54+
public function getClassUnion(Foo|Baz $object): void
55+
{
56+
$class = get_class($object);
57+
echo $class::BAR;
58+
}
59+
60+
/**
61+
* @param class-string<Foo>|class-string<Baz> $class
62+
*/
63+
public function genericClassStringUnion(string $class): void
64+
{
65+
echo $class::BAR;
66+
}
67+
68+
/**
69+
* @param class-string<Baz> $class
70+
*/
71+
public function singleGenericClassString(string $class): void
72+
{
73+
echo $class::BAR;
74+
}
75+
76+
/**
77+
* @param class-string $class
78+
*/
79+
public function plainClassString(string $class): void
80+
{
81+
echo $class::BAR;
82+
}
83+
84+
public function classPseudoConstantOnConstantStringUnion(bool $flag): void
85+
{
86+
$class = $flag ? Foo::class : Baz::class;
87+
echo $class::class;
88+
}
89+
90+
/**
91+
* @param class-string<Foo> $class
92+
*/
93+
public function classPseudoConstantOnGenericClassString(string $class): void
94+
{
95+
echo $class::class;
96+
}
97+
98+
public function enumCaseAccessThroughConstantString(): void
99+
{
100+
$class = SomeEnum::class;
101+
echo $class::A->value;
102+
}
103+
104+
/**
105+
* @param class-string<SomeEnum> $class
106+
*/
107+
public function enumCaseAccessThroughGenericClassString(string $class): void
108+
{
109+
echo $class::A->value;
110+
}
111+
112+
}

0 commit comments

Comments
 (0)