From 4e577183f4367ebdecdbeca4ab9ce1eb1a0db79f Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Sun, 30 Mar 2025 03:54:54 +0900 Subject: [PATCH] Add stringable access check to ClassConstantRule --- conf/bleedingEdge.neon | 1 + conf/config.neon | 6 ++ conf/parametersSchema.neon | 1 + src/Rules/Classes/ClassConstantRule.php | 19 ++++++ .../Rules/Classes/ClassConstantRuleTest.php | 60 +++++++++++++++++++ .../Classes/data/dynamic-constant-access.php | 3 +- .../dynamic-constant-stringable-access.php | 21 +++++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 76dd0d8904..084b360220 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -1,6 +1,7 @@ parameters: featureToggles: bleedingEdge: true + checkNonStringableDynamicAccess: true checkParameterCastableToNumberFunctions: true skipCheckGenericClasses!: [] stricterFunctionMap: true diff --git a/conf/config.neon b/conf/config.neon index e764d064eb..c1f0203a60 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -22,6 +22,7 @@ parameters: tooWideThrowType: true featureToggles: bleedingEdge: false + checkNonStringableDynamicAccess: false checkParameterCastableToNumberFunctions: false skipCheckGenericClasses: [] stricterFunctionMap: false @@ -882,6 +883,11 @@ services: - class: PHPStan\Rules\ClassForbiddenNameCheck + - + class: PHPStan\Rules\Classes\ClassConstantRule + arguments: + checkNonStringableDynamicAccess: %featureToggles.checkNonStringableDynamicAccess% + - class: PHPStan\Rules\Classes\LocalTypeAliasesCheck arguments: diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 3791c293a1..ad069e33ab 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -28,6 +28,7 @@ parametersSchema: ]) featureToggles: structure([ bleedingEdge: bool(), + checkNonStringableDynamicAccess: bool(), checkParameterCastableToNumberFunctions: bool(), skipCheckGenericClasses: listOf(string()), stricterFunctionMap: bool() diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index 23c3aafdaa..b4fb690582 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -39,6 +39,7 @@ public function __construct( private RuleLevelHelper $ruleLevelHelper, private ClassNameCheck $classCheck, private PhpVersion $phpVersion, + private bool $checkNonStringableDynamicAccess = true, ) { } @@ -60,6 +61,24 @@ public function processNode(Node $node, Scope $scope): array $name = $constantString->getValue(); $constantNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } + + if ($this->checkNonStringableDynamicAccess) { + $accepts = $this->ruleLevelHelper->accepts(new StringType(), $nameType, true); + $typeResult = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $node->name, + '', + static fn (Type $type) => $type->toString()->isString()->yes() + ); + + if (! $typeResult->getType()->isString()->yes() || + $typeResult->getType()->toString()->isNumericString()->yes() + ) { + $errors[] = RuleErrorBuilder::message(sprintf('Cannot fetch class constant with a non-stringable type %s.', $nameType->describe(VerbosityLevel::typeOnly()))) + ->identifier('classConstant.fetchInvalidExpression') + ->build(); + } + } } foreach ($constantNameScopes as $constantName => $constantScope) { diff --git a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php index 838201ffde..97831161c4 100644 --- a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php @@ -19,6 +19,8 @@ class ClassConstantRuleTest extends RuleTestCase private int $phpVersion; + private bool $checkNonStringableDynamicAccess; + protected function getRule(): Rule { $reflectionProvider = $this->createReflectionProvider(); @@ -30,12 +32,14 @@ protected function getRule(): Rule new ClassForbiddenNameCheck(self::getContainer()), ), new PhpVersion($this->phpVersion), + $this->checkNonStringableDynamicAccess, ); } public function testClassConstant(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse( [ __DIR__ . '/data/class-constant.php', @@ -99,6 +103,7 @@ public function testClassConstant(): void public function testClassConstantVisibility(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-constant-visibility.php'], [ [ 'Access to private constant PRIVATE_BAR of class ClassConstantVisibility\Bar.', @@ -168,6 +173,7 @@ public function testClassConstantVisibility(): void public function testClassExists(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-exists.php'], [ [ 'Class UnknownClass\Bar not found.', @@ -242,12 +248,14 @@ public function dataClassConstantOnExpression(): array public function testClassConstantOnExpression(int $phpVersion, array $errors): void { $this->phpVersion = $phpVersion; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-constant-on-expr.php'], $errors); } public function testAttributes(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-constant-attribute.php'], [ [ 'Access to undefined constant ClassConstantAttribute\Foo::BAR.', @@ -287,18 +295,21 @@ public function testRuleWithNullsafeVariant(): void } $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-constant-nullsafe.php'], []); } public function testBug7675(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/bug-7675.php'], []); } public function testBug8034(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/bug-8034.php'], [ [ 'Access to undefined constant static(Bug8034\HelloWorld)::FIELDS.', @@ -310,6 +321,7 @@ public function testBug8034(): void public function testClassConstFetchDefined(): void { $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-const-fetch-defined.php'], [ [ 'Access to undefined constant ClassConstFetchDefined\Foo::TEST.', @@ -411,6 +423,7 @@ public function testPhpstanInternalClass(): void $tip = 'This is most likely unintentional. Did you mean to type \AClass?'; $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/phpstan-internal-class.php'], [ [ 'Referencing prefixed PHPStan class: _PHPStan_156ee64ba\AClass.', @@ -427,6 +440,7 @@ public function testClassConstantAccessedOnTrait(): void } $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/class-constant-accessed-on-trait.php'], [ [ 'Cannot access constant TEST on trait ClassConstantAccessedOnTrait\Foo.', @@ -442,8 +456,17 @@ public function testDynamicAccess(): void } $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; $this->analyse([__DIR__ . '/data/dynamic-constant-access.php'], [ + [ + 'Access to undefined constant ClassConstantDynamicAccess\Foo::FOO.', + 17, + ], + [ + 'Cannot fetch class constant with a non-stringable type object.', + 19, + ], [ 'Access to undefined constant ClassConstantDynamicAccess\Foo::FOO.', 20, @@ -479,4 +502,41 @@ public function testDynamicAccess(): void ]); } + public function testStringableDynamicAccess(): void + { + if (PHP_VERSION_ID < 80300) { + $this->markTestSkipped('Test requires PHP 8.3.'); + } + + $this->phpVersion = PHP_VERSION_ID; + $this->checkNonStringableDynamicAccess = true; + + $this->analyse([__DIR__ . '/data/dynamic-constant-stringable-access.php'], [ + [ + 'Cannot fetch class constant with a non-stringable type mixed.', + 13, + ], + [ + 'Cannot fetch class constant with a non-stringable type string|null.', + 14, + ], + [ + 'Cannot fetch class constant with a non-stringable type Stringable|null.', + 15, + ], + [ + 'Cannot fetch class constant with a non-stringable type int.', + 16, + ], + [ + 'Cannot fetch class constant with a non-stringable type int|null.', + 17, + ], + [ + 'Cannot fetch class constant with a non-stringable type DateTime|string.', + 18, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php b/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php index 10809e566a..09c0b176fe 100644 --- a/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php +++ b/tests/PHPStan/Rules/Classes/data/dynamic-constant-access.php @@ -14,7 +14,7 @@ public function test(string $string, object $obj): void { $bar = 'FOO'; - echo self::{$foo}; + echo self::{$bar}; echo self::{$string}; echo self::{$obj}; echo self::{$this->name}; @@ -44,5 +44,4 @@ public function testScope(): void echo self::{$name}; } - } diff --git a/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php new file mode 100644 index 0000000000..8334d82852 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/dynamic-constant-stringable-access.php @@ -0,0 +1,21 @@ += 8.3 + +namespace ClassConstantDynamicStringableAccess; + +use Stringable; +use DateTime; + +final class Foo +{ + + public function test(mixed $mixed, ?string $nullableStr, ?Stringable $nullableStringable, int $int, ?int $nullableInt, DateTime|string $datetimeOrStr): void + { + echo self::{$mixed}; + echo self::{$nullableStr}; + echo self::{$nullableStringable}; + echo self::{$int}; + echo self::{$nullableInt}; + echo self::{$datetimeOrStr}; + } + +}