Skip to content

Commit 58bba30

Browse files
gnutixclaude
andcommitted
Check static property access through constant class-name strings and class-string unions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 184d34e commit 58bba30

6 files changed

Lines changed: 407 additions & 2 deletions

‎src/Rules/Properties/AccessStaticPropertiesCheck.php‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use PHPStan\DependencyInjection\AutowiredParameter;
1717
use PHPStan\DependencyInjection\AutowiredService;
1818
use PHPStan\Internal\SprintfHelper;
19+
use PHPStan\Node\Expr\TypeExpr;
1920
use PHPStan\Php\PhpVersion;
2021
use PHPStan\Reflection\ReflectionProvider;
2122
use PHPStan\Rules\ClassNameCheck;
@@ -175,9 +176,21 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node,
175176
$classType = $scope->resolveTypeByName($node->class);
176177
}
177178
} else {
179+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->class);
180+
// A constant class-name string or a class-string type describes the
181+
// object it names, so route the check through that object type. This
182+
// makes constant-string / class-string unions behave exactly like
183+
// object unions with regard to rule levels (member filtering when
184+
// checkUnionTypes is off, whole-union check when it is on).
185+
// Every other type (objects, or unions with members that are not
186+
// class-strings) yields no class name here and keeps the old path.
187+
$objectType = $scope->getType($exprToCheck)->getClassStringObjectType();
188+
if ($this->canCheckThroughObjectType($objectType)) {
189+
$exprToCheck = new TypeExpr($objectType);
190+
}
178191
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
179192
$scope,
180-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $node->class),
193+
$exprToCheck,
181194
sprintf('Access to static property $%s on an unknown class %%s.', SprintfHelper::escapeFormatString($name)),
182195
static fn (Type $type): bool => $type->canAccessProperties()->yes() && $type->hasStaticProperty($name)->yes(),
183196
);
@@ -339,4 +352,25 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node,
339352
]);
340353
}
341354

355+
/**
356+
* Class-strings without a class name (class-string, class-string<object>,
357+
* class-string<T of object>) and class-strings naming a trait keep the
358+
* string code path: RuleLevelHelper would filter the object type out or
359+
* report the trait as an unknown class.
360+
*/
361+
private function canCheckThroughObjectType(Type $objectType): bool
362+
{
363+
if (count($objectType->getObjectClassNames()) === 0) {
364+
return false;
365+
}
366+
367+
foreach ($objectType->getObjectClassReflections() as $classReflection) {
368+
if ($classReflection->isTrait()) {
369+
return false;
370+
}
371+
}
372+
373+
return true;
374+
}
375+
342376
}

‎tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,4 +109,46 @@ public function testAsymmetricVisibility(): void
109109
]);
110110
}
111111

112+
#[RequiresPhp('>= 8.0.0')]
113+
public function testWritesOnStringUnions(): void
114+
{
115+
$this->analyse([__DIR__ . '/data/static-property-string-unions-assign.php'], [
116+
[
117+
'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.',
118+
32,
119+
],
120+
[
121+
'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.',
122+
38,
123+
],
124+
[
125+
'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz::$missing.',
126+
44,
127+
],
128+
[
129+
'Access to private property $secret of class StaticPropertyStringUnionsAssign\Foo.',
130+
50,
131+
],
132+
[
133+
'Access to an undefined static property StaticPropertyStringUnionsAssign\Baz|StaticPropertyStringUnionsAssign\Foo::$count.',
134+
56,
135+
],
136+
]);
137+
}
138+
139+
#[RequiresPhp('>= 8.5.0')]
140+
public function testAsymmetricVisibilityOnConstantString(): void
141+
{
142+
$this->analyse([__DIR__ . '/data/static-property-string-unions-asymmetric.php'], [
143+
[
144+
'Access to private(set) property $privateSet of class StaticPropertyStringUnionsAsymmetric\Foo.',
145+
16,
146+
],
147+
[
148+
'Access to protected(set) property $protectedSet of class StaticPropertyStringUnionsAsymmetric\Foo.',
149+
17,
150+
],
151+
]);
152+
}
153+
112154
}

‎tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,16 @@
2121
class AccessStaticPropertiesRuleTest extends RuleTestCase
2222
{
2323

24+
private bool $checkUnionTypes = true;
25+
2426
protected function getRule(): Rule
2527
{
2628
$reflectionProvider = self::createReflectionProvider();
2729
$ruleLevelHelper = new RuleLevelHelper(
2830
$reflectionProvider,
2931
checkNullables: true,
3032
checkThisOnly: false,
31-
checkUnionTypes: true,
33+
checkUnionTypes: $this->checkUnionTypes,
3234
checkExplicitMixed: false,
3335
checkImplicitMixed: false,
3436
checkBenevolentUnionTypes: false,
@@ -396,4 +398,95 @@ public function testBug15002(): void
396398
$this->analyse([__DIR__ . '/../Methods/data/bug-15002.php'], []);
397399
}
398400

401+
#[RequiresPhp('>= 8.0.0')]
402+
public function testStaticPropertyAccessOnStringUnions(): void
403+
{
404+
$this->analyse([__DIR__ . '/data/static-property-string-unions.php'], [
405+
[
406+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.',
407+
32,
408+
],
409+
[
410+
'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.',
411+
38,
412+
],
413+
[
414+
'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.',
415+
50,
416+
],
417+
[
418+
'Access to an undefined static property StaticPropertyStringUnions\Baz|StaticPropertyStringUnions\Foo::$create.',
419+
58,
420+
],
421+
[
422+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.',
423+
66,
424+
],
425+
[
426+
'Static access to instance property StaticPropertyStringUnions\Foo::$doBar.',
427+
80,
428+
],
429+
[
430+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
431+
118,
432+
],
433+
[
434+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
435+
119,
436+
],
437+
[
438+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
439+
120,
440+
],
441+
[
442+
'Access to private property $privateProperty of class StaticPropertyStringUnions\WithNonPublic.',
443+
126,
444+
],
445+
[
446+
'Access to protected property $protectedProperty of class StaticPropertyStringUnions\WithNonPublic.',
447+
127,
448+
],
449+
]);
450+
}
451+
452+
#[RequiresPhp('>= 8.0.0')]
453+
public function testStaticPropertyAccessOnStringUnionsWithoutCheckUnionTypes(): void
454+
{
455+
$this->checkUnionTypes = false;
456+
$this->analyse([__DIR__ . '/data/static-property-string-unions.php'], [
457+
[
458+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.',
459+
32,
460+
],
461+
[
462+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$create.',
463+
66,
464+
],
465+
[
466+
'Static access to instance property StaticPropertyStringUnions\Foo::$doBar.',
467+
80,
468+
],
469+
[
470+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
471+
118,
472+
],
473+
[
474+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
475+
119,
476+
],
477+
[
478+
'Access to an undefined static property StaticPropertyStringUnions\Baz::$missing.',
479+
120,
480+
],
481+
[
482+
'Access to private property $privateProperty of class StaticPropertyStringUnions\WithNonPublic.',
483+
126,
484+
],
485+
[
486+
'Access to protected property $protectedProperty of class StaticPropertyStringUnions\WithNonPublic.',
487+
127,
488+
],
489+
]);
490+
}
491+
399492
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php declare(strict_types = 1); // lint >= 8.0
2+
3+
namespace StaticPropertyStringUnionsAssign;
4+
5+
class Foo
6+
{
7+
8+
public static int $count = 0;
9+
10+
private static int $secret = 0;
11+
12+
}
13+
14+
class Baz
15+
{
16+
17+
}
18+
19+
trait SomeTrait
20+
{
21+
22+
public static int $traitProperty = 0;
23+
24+
}
25+
26+
class Runner
27+
{
28+
29+
public function writeToMissingProperty(): void
30+
{
31+
$class = Baz::class;
32+
$class::$missing = 1;
33+
}
34+
35+
public function incrementMissingProperty(): void
36+
{
37+
$class = Baz::class;
38+
$class::$missing++;
39+
}
40+
41+
public function coalesceAssignMissingProperty(): void
42+
{
43+
$class = Baz::class;
44+
$class::$missing ??= 1;
45+
}
46+
47+
public function writeToPrivateProperty(): void
48+
{
49+
$class = Foo::class;
50+
$class::$secret = 1;
51+
}
52+
53+
public function writeToUnion(bool $flag): void
54+
{
55+
$class = $flag ? Foo::class : Baz::class;
56+
$class::$count = 1;
57+
}
58+
59+
public function writeToExistingProperty(): void
60+
{
61+
$class = Foo::class;
62+
$class::$count = 1;
63+
}
64+
65+
public function writeThroughTrait(): void
66+
{
67+
$trait = SomeTrait::class;
68+
$trait::$traitProperty = 1;
69+
}
70+
71+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types = 1); // lint >= 8.5
2+
3+
namespace StaticPropertyStringUnionsAsymmetric;
4+
5+
class Foo
6+
{
7+
8+
public private(set) static int $privateSet = 0;
9+
10+
public protected(set) static int $protectedSet = 0;
11+
12+
}
13+
14+
function (): void {
15+
$class = Foo::class;
16+
$class::$privateSet = 1;
17+
$class::$protectedSet = 1;
18+
echo $class::$privateSet;
19+
};

0 commit comments

Comments
 (0)