Skip to content

Commit 05dfb04

Browse files
committed
Readonly classes cannot be combined with #[AllowDynamicProperties]
1 parent 5dfc583 commit 05dfb04

File tree

10 files changed

+296
-1
lines changed

10 files changed

+296
-1
lines changed

Diff for: Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ lint:
7777
--exclude tests/PHPStan/Rules/Classes/data/extends-readonly-class.php \
7878
--exclude tests/PHPStan/Rules/Classes/data/instantiation-promoted-properties.php \
7979
--exclude tests/PHPStan/Rules/Classes/data/bug-11592.php \
80+
--exclude tests/PHPStan/Rules/Classes/data/bug-12281.php \
81+
--exclude tests/PHPStan/Rules/Traits/data/bug-12281.php \
8082
src tests
8183

8284
cs:

Diff for: conf/config.level0.neon

+1
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ rules:
117117
- PHPStan\Rules\Properties\ReadOnlyPropertyRule
118118
- PHPStan\Rules\Traits\ConflictingTraitConstantsRule
119119
- PHPStan\Rules\Traits\ConstantsInTraitsRule
120+
- PHPStan\Rules\Traits\TraitAttributesRule
120121
- PHPStan\Rules\Types\InvalidTypesInUnionRule
121122
- PHPStan\Rules\Variables\UnsetRule
122123
- PHPStan\Rules\Whitespace\FileWhitespaceRule

Diff for: src/Rules/Classes/ClassAttributesRule.php

+31-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
use PHPStan\Node\InClassNode;
99
use PHPStan\Rules\AttributesCheck;
1010
use PHPStan\Rules\Rule;
11+
use PHPStan\Rules\RuleErrorBuilder;
12+
use function count;
13+
use function sprintf;
1114

1215
/**
1316
* @implements Rule<InClassNode>
@@ -28,12 +31,39 @@ public function processNode(Node $node, Scope $scope): array
2831
{
2932
$classLikeNode = $node->getOriginalNode();
3033

31-
return $this->attributesCheck->check(
34+
$errors = $this->attributesCheck->check(
3235
$scope,
3336
$classLikeNode->attrGroups,
3437
Attribute::TARGET_CLASS,
3538
'class',
3639
);
40+
41+
$classReflection = $node->getClassReflection();
42+
if (
43+
$classReflection->isReadOnly()
44+
|| $classReflection->isEnum()
45+
|| $classReflection->isInterface()
46+
) {
47+
$typeName = 'readonly class';
48+
$identifier = 'class.allowDynamicPropertiesReadonly';
49+
if ($classReflection->isEnum()) {
50+
$typeName = 'enum';
51+
$identifier = 'enum.allowDynamicProperties';
52+
}
53+
if ($classReflection->isInterface()) {
54+
$typeName = 'interface';
55+
$identifier = 'interface.allowDynamicProperties';
56+
}
57+
58+
if (count($classReflection->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) {
59+
$errors[] = RuleErrorBuilder::message(sprintf('Attribute class AllowDynamicProperties cannot be used with %s.', $typeName))
60+
->identifier($identifier)
61+
->nonIgnorable()
62+
->build();
63+
}
64+
}
65+
66+
return $errors;
3767
}
3868

3969
}

Diff for: src/Rules/Traits/TraitAttributesRule.php

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Traits;
4+
5+
use Attribute;
6+
use PhpParser\Node;
7+
use PHPStan\Analyser\MutatingScope;
8+
use PHPStan\Analyser\Scope;
9+
use PHPStan\Reflection\ReflectionProvider;
10+
use PHPStan\Rules\AttributesCheck;
11+
use PHPStan\Rules\Rule;
12+
use PHPStan\Rules\RuleErrorBuilder;
13+
use PHPStan\ShouldNotHappenException;
14+
use function count;
15+
16+
/**
17+
* @implements Rule<Node\Stmt\Trait_>
18+
*/
19+
final class TraitAttributesRule implements Rule
20+
{
21+
22+
public function __construct(
23+
private AttributesCheck $attributesCheck,
24+
private ReflectionProvider $reflectionProvider,
25+
)
26+
{
27+
}
28+
29+
public function getNodeType(): string
30+
{
31+
return Node\Stmt\Trait_::class;
32+
}
33+
34+
public function processNode(Node $node, Scope $scope): array
35+
{
36+
$traitName = $node->namespacedName;
37+
if ($traitName === null) {
38+
return [];
39+
}
40+
41+
if (!$this->reflectionProvider->hasClass($traitName->toString())) {
42+
return [];
43+
}
44+
45+
$errors = $this->attributesCheck->check(
46+
$scope,
47+
$node->attrGroups,
48+
Attribute::TARGET_CLASS,
49+
'class',
50+
);
51+
52+
$classReflection = $this->reflectionProvider->getClass($traitName->toString());
53+
if (count($classReflection->getNativeReflection()->getAttributes('AllowDynamicProperties')) > 0) {
54+
$errors[] = RuleErrorBuilder::message('Attribute class AllowDynamicProperties cannot be used with trait.')
55+
->identifier('class.allowDynamicPropertiesTrait')
56+
->nonIgnorable()
57+
->build();
58+
}
59+
60+
return $errors;
61+
}
62+
63+
}

Diff for: tests/PHPStan/Rules/Classes/ClassAttributesRuleTest.php

+24
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,28 @@ public function testBug12011(): void
168168
]);
169169
}
170170

171+
public function testBug12281(): void
172+
{
173+
if (PHP_VERSION_ID < 80200) {
174+
$this->markTestSkipped('Test requires PHP 8.2.');
175+
}
176+
177+
$this->checkExplicitMixed = true;
178+
$this->checkImplicitMixed = true;
179+
$this->analyse([__DIR__ . '/data/bug-12281.php'], [
180+
[
181+
'Attribute class AllowDynamicProperties cannot be used with readonly class.',
182+
05,
183+
],
184+
[
185+
'Attribute class AllowDynamicProperties cannot be used with enum.',
186+
12,
187+
],
188+
[
189+
'Attribute class AllowDynamicProperties cannot be used with interface.',
190+
15,
191+
],
192+
]);
193+
}
194+
171195
}

Diff for: tests/PHPStan/Rules/Classes/data/bug-12281.php

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php // lint >= 8.2
2+
3+
namespace Bug12281;
4+
5+
#[\AllowDynamicProperties]
6+
readonly class BlogData { /* … */ }
7+
8+
/** @readonly */
9+
#[\AllowDynamicProperties]
10+
class BlogDataPhpdoc { /* … */ }
11+
12+
#[\AllowDynamicProperties]
13+
enum BlogDataEnum { /* … */ }
14+
15+
#[\AllowDynamicProperties]
16+
interface BlogDataInterface { /* … */ }
+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Traits;
4+
5+
use PHPStan\Php\PhpVersion;
6+
use PHPStan\Rules\AttributesCheck;
7+
use PHPStan\Rules\ClassCaseSensitivityCheck;
8+
use PHPStan\Rules\Classes\ClassAttributesRule;
9+
use PHPStan\Rules\ClassForbiddenNameCheck;
10+
use PHPStan\Rules\ClassNameCheck;
11+
use PHPStan\Rules\FunctionCallParametersCheck;
12+
use PHPStan\Rules\NullsafeCheck;
13+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
14+
use PHPStan\Rules\Properties\PropertyReflectionFinder;
15+
use PHPStan\Rules\Rule;
16+
use PHPStan\Rules\RuleLevelHelper;
17+
use PHPStan\Rules\Traits\TraitAttributesRule;
18+
use PHPStan\Testing\RuleTestCase;
19+
use const PHP_VERSION_ID;
20+
21+
/**
22+
* @extends RuleTestCase<TraitAttributesRule>
23+
*/
24+
class TraitAttributesRuleTest extends RuleTestCase
25+
{
26+
27+
private bool $checkExplicitMixed = false;
28+
29+
private bool $checkImplicitMixed = false;
30+
31+
protected function getRule(): Rule
32+
{
33+
$reflectionProvider = $this->createReflectionProvider();
34+
return new TraitAttributesRule(
35+
new AttributesCheck(
36+
$reflectionProvider,
37+
new FunctionCallParametersCheck(
38+
new RuleLevelHelper($reflectionProvider, true, false, true, $this->checkExplicitMixed, $this->checkImplicitMixed, true, false),
39+
new NullsafeCheck(),
40+
new PhpVersion(80000),
41+
new UnresolvableTypeHelper(),
42+
new PropertyReflectionFinder(),
43+
true,
44+
true,
45+
true,
46+
true,
47+
true,
48+
),
49+
new ClassNameCheck(
50+
new ClassCaseSensitivityCheck($reflectionProvider, false),
51+
new ClassForbiddenNameCheck(self::getContainer()),
52+
),
53+
true,
54+
),
55+
$reflectionProvider,
56+
);
57+
}
58+
59+
public function testRule(): void
60+
{
61+
$this->analyse([__DIR__ . '/data/trait-attributes.php'], [
62+
[
63+
'Attribute class TraitAttributes\AbstractAttribute is abstract.',
64+
8,
65+
],
66+
[
67+
'Attribute class TraitAttributes\MyTargettedAttribute does not have the class target.',
68+
20,
69+
],
70+
]);
71+
}
72+
73+
public function testBug12011(): void
74+
{
75+
if (PHP_VERSION_ID < 80300) {
76+
$this->markTestSkipped('Test requires PHP 8.3.');
77+
}
78+
79+
$this->checkExplicitMixed = true;
80+
$this->checkImplicitMixed = true;
81+
82+
$this->analyse([__DIR__ . '/data/bug-12011.php'], [
83+
[
84+
'Parameter #1 $name of attribute class Bug12011Trait\Table constructor expects string|null, int given.',
85+
8,
86+
],
87+
]);
88+
}
89+
90+
public function testBug12281(): void
91+
{
92+
$this->analyse([__DIR__ . '/data/bug-12281.php'], [
93+
[
94+
'Attribute class AllowDynamicProperties cannot be used with trait.',
95+
11,
96+
],
97+
]);
98+
}
99+
100+
}

Diff for: tests/PHPStan/Rules/Traits/data/bug-12011.php

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php // lint >= 8.3
2+
3+
namespace Bug12011Trait;
4+
5+
use Attribute;
6+
7+
8+
#[Table(self::TABLE_NAME)]
9+
trait MyTrait
10+
{
11+
private const int TABLE_NAME = 'table';
12+
}
13+
14+
class X {
15+
use MyTrait;
16+
}
17+
18+
#[Attribute(Attribute::TARGET_CLASS)]
19+
final class Table
20+
{
21+
public function __construct(
22+
public readonly string|null $name = null,
23+
public readonly string|null $schema = null,
24+
) {
25+
}
26+
}

Diff for: tests/PHPStan/Rules/Traits/data/bug-12281.php

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php // lint >= 8.2
2+
3+
namespace Bug12281Traits;
4+
5+
#[\AllowDynamicProperties]
6+
enum BlogDataEnum { /* … */ } // reported by ClassAttributesRule
7+
8+
#[\AllowDynamicProperties]
9+
interface BlogDataInterface { /* … */ } // reported by ClassAttributesRule
10+
11+
#[\AllowDynamicProperties]
12+
trait BlogDataTrait { /* … */ }

Diff for: tests/PHPStan/Rules/Traits/data/trait-attributes.php

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace TraitAttributes;
4+
5+
#[\Attribute]
6+
abstract class AbstractAttribute {}
7+
8+
#[AbstractAttribute]
9+
trait MyTrait {}
10+
11+
#[\Attribute]
12+
class MyAttribute {}
13+
14+
#[MyAttribute]
15+
trait MyTrait2 {}
16+
17+
#[\Attribute(\Attribute::TARGET_PROPERTY)]
18+
class MyTargettedAttribute {}
19+
20+
#[MyTargettedAttribute]
21+
trait MyTrait3 {}

0 commit comments

Comments
 (0)