From ba3e81cbe2d88b36ac88f517bdb497c64ad51cf7 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 14 Jun 2026 13:07:58 +0000 Subject: [PATCH 1/5] Report non-stringable names in variable variables, dynamic method calls and dynamic static property/method access - Extract the existing inline `checkNonStringableDynamicAccess` logic from `AccessPropertiesCheck` and `ClassConstantRule` into a shared `NonStringableDynamicAccessCheck` service with two entry points: `checkStringCastableName()` (objects with `__toString` accepted) and `checkStringName()` (real strings only). - `DefinedVariableRule`: report `$$expr` when the name is not stringable (e.g. `$$this`), the case from the issue (identifier `variable.nameNotString`). - `CallMethodsRule` / `CallStaticMethodsRule`: report `$obj->{$expr}()` / `Foo::{$expr}()` when the name is not a string; method names do not accept `Stringable` (identifiers `method.nameNotString`, `staticMethod.nameNotString`). - `AccessStaticPropertiesCheck`: report `Foo::${$expr}` when the name is not stringable, gated on read access to avoid double-reporting on assignment (identifier `staticProperty.nameNotString`). - All checks remain behind the `checkNonStringableDynamicAccess` feature toggle (bleeding edge), matching the pre-existing property and class-constant checks. - Property and class-constant checks now delegate to the shared service; behavior and messages are unchanged. --- phpstan-baseline.neon | 12 +++ src/Rules/Classes/ClassConstantRule.php | 30 +++---- src/Rules/Methods/CallMethodsRule.php | 17 ++++ src/Rules/Methods/CallStaticMethodsRule.php | 21 +++++ src/Rules/NonStringableDynamicAccessCheck.php | 85 +++++++++++++++++++ .../Properties/AccessPropertiesCheck.php | 25 ++---- .../AccessStaticPropertiesCheck.php | 20 ++++- src/Rules/Variables/DefinedVariableRule.php | 13 +++ .../Analyser/Bug9307CallMethodsRuleTest.php | 2 + .../Rules/Classes/ClassConstantRuleTest.php | 24 +++--- .../Rules/Methods/CallMethodsRuleTest.php | 32 +++++++ .../Methods/CallStaticMethodsRuleTest.php | 27 ++++++ ...thPossiblyRenamedNamedArgumentRuleTest.php | 2 + .../Methods/data/dynamic-method-name.php | 24 ++++++ .../data/dynamic-static-method-name.php | 23 +++++ .../AccessPropertiesInAssignRuleTest.php | 24 +++--- .../Properties/AccessPropertiesRuleTest.php | 24 +++--- ...AccessStaticPropertiesInAssignRuleTest.php | 23 ++--- .../AccessStaticPropertiesRuleTest.php | 41 ++++++--- .../data/dynamic-static-property-name.php | 25 ++++++ .../Variables/DefinedVariableRuleTest.php | 44 ++++++++++ .../Variables/data/variable-variable-name.php | 30 +++++++ 22 files changed, 475 insertions(+), 93 deletions(-) create mode 100644 src/Rules/NonStringableDynamicAccessCheck.php create mode 100644 tests/PHPStan/Rules/Methods/data/dynamic-method-name.php create mode 100644 tests/PHPStan/Rules/Methods/data/dynamic-static-method-name.php create mode 100644 tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php create mode 100644 tests/PHPStan/Rules/Variables/data/variable-variable-name.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2c3e0d16c3c..55cb79f1799 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -234,12 +234,24 @@ parameters: count: 1 path: src/DependencyInjection/ContainerFactory.php + - + rawMessage: 'Method name for Nette\Schema\Elements\AnyOf|Nette\Schema\Elements\Structure|Nette\Schema\Elements\Type must be a string, but array|Nette\DI\Definitions\Definition|Nette\DI\Definitions\Reference|string|null was given.' + identifier: method.nameNotString + count: 1 + path: src/DependencyInjection/ContainerFactory.php + - rawMessage: Variable static method call on Nette\Schema\Expect. identifier: staticMethod.dynamicName count: 1 path: src/DependencyInjection/ContainerFactory.php + - + rawMessage: 'Method name for Nette\Schema\Expect must be a string, but array|Nette\DI\Definitions\Definition|Nette\DI\Definitions\Reference|string|null was given.' + identifier: staticMethod.nameNotString + count: 1 + path: src/DependencyInjection/ContainerFactory.php + - rawMessage: Fetching class constant PREVENT_MERGING of deprecated class Nette\DI\Config\Helpers. identifier: classConstant.deprecatedClass diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index a8220d5e11c..4f4070b93e5 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -9,7 +9,6 @@ use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; -use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; use PHPStan\Php\PhpVersion; @@ -18,6 +17,7 @@ use PHPStan\Rules\ClassNameNodePair; use PHPStan\Rules\ClassNameUsageLocation; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; @@ -44,8 +44,7 @@ public function __construct( private RuleLevelHelper $ruleLevelHelper, private ClassNameCheck $classCheck, private PhpVersion $phpVersion, - #[AutowiredParameter(ref: '%featureToggles.checkNonStringableDynamicAccess%')] - private bool $checkNonStringableDynamicAccess, + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, ) { } @@ -68,24 +67,15 @@ public function processNode(Node $node, Scope $scope): array $constantNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - if ($this->checkNonStringableDynamicAccess) { - $nameTypeResult = $this->ruleLevelHelper->findTypeToCheck( - $scope, - $node->name, - '', - static fn (Type $type) => $type->isString()->yes(), - ); + $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); + if ($nonStringableNameType !== null) { + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $nameType = $nameTypeResult->getType(); - if (!$nameType instanceof ErrorType && !$nameType->isString()->yes()) { - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - - $errors[] = RuleErrorBuilder::message(sprintf('Class constant name for %s must be a string, but %s was given.', $className, $nameType->describe(VerbosityLevel::precise()))) - ->identifier('classConstant.nameNotString') - ->build(); - } + $errors[] = RuleErrorBuilder::message(sprintf('Class constant name for %s must be a string, but %s was given.', $className, $nonStringableNameType->describe(VerbosityLevel::precise()))) + ->identifier('classConstant.nameNotString') + ->build(); } } diff --git a/src/Rules/Methods/CallMethodsRule.php b/src/Rules/Methods/CallMethodsRule.php index 51881bfbea2..d3c62320c16 100644 --- a/src/Rules/Methods/CallMethodsRule.php +++ b/src/Rules/Methods/CallMethodsRule.php @@ -14,8 +14,12 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\VerbosityLevel; use function array_merge; +use function sprintf; /** * @implements Rule @@ -27,6 +31,7 @@ final class CallMethodsRule implements Rule public function __construct( private MethodCallCheck $methodCallCheck, private FunctionCallParametersCheck $parametersCheck, + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, ) { } @@ -48,6 +53,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $name = $constantString->getValue(); $methodNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } + + $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); + if ($nonStringableNameType !== null) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method name for %s must be a string, but %s was given.', + $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()), + $nonStringableNameType->describe(VerbosityLevel::precise()), + )) + ->line($node->name->getStartLine()) + ->identifier('method.nameNotString') + ->build(); + } } foreach ($methodNameScopes as $methodName => $methodScope) { diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php index a275d3fb731..86d8e8a6c57 100644 --- a/src/Rules/Methods/CallStaticMethodsRule.php +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; @@ -14,7 +15,10 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; +use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\VerbosityLevel; use function array_merge; use function sprintf; @@ -28,6 +32,7 @@ final class CallStaticMethodsRule implements Rule public function __construct( private StaticMethodCallCheck $methodCallCheck, private FunctionCallParametersCheck $parametersCheck, + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, ) { } @@ -49,6 +54,22 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $name = $constantString->getValue(); $methodNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } + + $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); + if ($nonStringableNameType !== null) { + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Method name for %s must be a string, but %s was given.', + $className, + $nonStringableNameType->describe(VerbosityLevel::precise()), + )) + ->line($node->name->getStartLine()) + ->identifier('staticMethod.nameNotString') + ->build(); + } } foreach ($methodNameScopes as $methodName => $methodScope) { diff --git a/src/Rules/NonStringableDynamicAccessCheck.php b/src/Rules/NonStringableDynamicAccessCheck.php new file mode 100644 index 00000000000..90a58b90cdb --- /dev/null +++ b/src/Rules/NonStringableDynamicAccessCheck.php @@ -0,0 +1,85 @@ +{$name}`, `$obj->{$name}()`, `Foo::{$name}()`, + * `Foo::${$name}`, `Foo::{$name}`) can actually be used as a name at runtime. + * + * Gated behind the `checkNonStringableDynamicAccess` feature toggle. + */ +#[AutowiredService] +final class NonStringableDynamicAccessCheck +{ + + public function __construct( + private RuleLevelHelper $ruleLevelHelper, + #[AutowiredParameter(ref: '%featureToggles.checkNonStringableDynamicAccess%')] + private bool $checkNonStringableDynamicAccess, + ) + { + } + + /** + * For names that PHP casts to string at runtime (variable variables, + * property and static property names) objects implementing __toString are + * accepted. Returns the offending name type to report, or null when the + * name is usable. + */ + public function checkStringCastableName(Scope $scope, Expr $name): ?Type + { + if (!$this->checkNonStringableDynamicAccess) { + return null; + } + + $nameType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $name, + '', + static fn (Type $type) => !$type->toString() instanceof ErrorType && $type->toString()->isString()->yes(), + )->getType(); + + if ( + !$nameType instanceof ErrorType + && ($nameType->toString() instanceof ErrorType || !$nameType->toString()->isString()->yes()) + ) { + return $scope->getType($name); + } + + return null; + } + + /** + * For names that must be actual strings (method, static method and class + * constant names) objects implementing __toString are not accepted. + * Returns the offending name type to report, or null when the name is usable. + */ + public function checkStringName(Scope $scope, Expr $name): ?Type + { + if (!$this->checkNonStringableDynamicAccess) { + return null; + } + + $nameType = $this->ruleLevelHelper->findTypeToCheck( + $scope, + $name, + '', + static fn (Type $type) => $type->isString()->yes(), + )->getType(); + + if (!$nameType instanceof ErrorType && !$nameType->isString()->yes()) { + return $nameType; + } + + return null; + } + +} diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index ce6717addfc..1ccd6454d1a 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -18,6 +18,7 @@ use PHPStan\Reflection\MissingPropertyFromReflectionException; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Type\Constant\ConstantStringType; @@ -39,12 +40,11 @@ public function __construct( private ReflectionProvider $reflectionProvider, private RuleLevelHelper $ruleLevelHelper, private PhpVersion $phpVersion, + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, #[AutowiredParameter] private bool $reportMagicProperties, #[AutowiredParameter] private bool $checkDynamicProperties, - #[AutowiredParameter(ref: '%featureToggles.checkNonStringableDynamicAccess%')] - private bool $checkNonStringableDynamicAccess, ) { } @@ -60,24 +60,11 @@ public function check(PropertyFetch $node, Scope $scope, bool $write): array } else { $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); - if (!$write && $this->checkNonStringableDynamicAccess) { - $nameTypeResult = $this->ruleLevelHelper->findTypeToCheck( - $scope, - $node->name, - '', - static fn (Type $type) => !$type->toString() instanceof ErrorType && $type->toString()->isString()->yes(), - ); - $nameType = $nameTypeResult->getType(); - if ( - !$nameType instanceof ErrorType - && ( - $nameType->toString() instanceof ErrorType - || !$nameType->toString()->isString()->yes() - ) - ) { - $originalNameType = $scope->getType($node->name); + if (!$write) { + $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); + if ($nonStringableNameType !== null) { $className = $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()); - $errors[] = RuleErrorBuilder::message(sprintf('Property name for %s must be a string, but %s was given.', $className, $originalNameType->describe(VerbosityLevel::precise()))) + $errors[] = RuleErrorBuilder::message(sprintf('Property name for %s must be a string, but %s was given.', $className, $nonStringableNameType->describe(VerbosityLevel::precise()))) ->line($node->name->getStartLine()) ->identifier('property.nameNotString') ->build(); diff --git a/src/Rules/Properties/AccessStaticPropertiesCheck.php b/src/Rules/Properties/AccessStaticPropertiesCheck.php index c548a2f342c..8c18cce1637 100644 --- a/src/Rules/Properties/AccessStaticPropertiesCheck.php +++ b/src/Rules/Properties/AccessStaticPropertiesCheck.php @@ -22,6 +22,7 @@ use PHPStan\Rules\ClassNameNodePair; use PHPStan\Rules\ClassNameUsageLocation; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Type\Constant\ConstantStringType; @@ -48,6 +49,7 @@ public function __construct( private RuleLevelHelper $ruleLevelHelper, private ClassNameCheck $classCheck, private PhpVersion $phpVersion, + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, #[AutowiredParameter(ref: '%tips.discoveringSymbols%')] private bool $discoveringSymbolsTip, ) @@ -59,13 +61,29 @@ public function __construct( */ public function check(StaticPropertyFetch $node, Scope $scope, bool $write): array { + $errors = []; if ($node->name instanceof Node\VarLikeIdentifier) { $names = [$node->name->name]; } else { $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); + + $nonStringableNameType = $write ? null : $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); + if ($nonStringableNameType !== null) { + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Static property name for %s must be a string, but %s was given.', + $className, + $nonStringableNameType->describe(VerbosityLevel::precise()), + )) + ->line($node->name->getStartLine()) + ->identifier('staticProperty.nameNotString') + ->build(); + } } - $errors = []; foreach ($names as $name) { $errors = array_merge($errors, $this->processSingleProperty($scope, $node, $name, $write)); } diff --git a/src/Rules/Variables/DefinedVariableRule.php b/src/Rules/Variables/DefinedVariableRule.php index 8fbb1e5d0b0..4bd2f97e9d6 100644 --- a/src/Rules/Variables/DefinedVariableRule.php +++ b/src/Rules/Variables/DefinedVariableRule.php @@ -10,8 +10,10 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Rules\IdentifierRuleError; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use PHPStan\Type\VerbosityLevel; use function array_merge; use function in_array; use function is_string; @@ -25,6 +27,7 @@ final class DefinedVariableRule implements Rule { public function __construct( + private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck, #[AutowiredParameter] private bool $cliArgumentsVariablesRegistered, #[AutowiredParameter] @@ -50,6 +53,16 @@ public function processNode(Node $node, Scope $scope): array $name = $constantString->getValue(); $variableNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } + + $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); + if ($nonStringableNameType !== null) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Variable variable name must be a string, but %s was given.', + $nonStringableNameType->describe(VerbosityLevel::precise()), + )) + ->identifier('variable.nameNotString') + ->build(); + } } foreach ($variableNameScopes as $name => $variableScope) { diff --git a/tests/PHPStan/Analyser/Bug9307CallMethodsRuleTest.php b/tests/PHPStan/Analyser/Bug9307CallMethodsRuleTest.php index 3cf21124155..9136f68c8c5 100644 --- a/tests/PHPStan/Analyser/Bug9307CallMethodsRuleTest.php +++ b/tests/PHPStan/Analyser/Bug9307CallMethodsRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\FunctionCallParametersCheck; use PHPStan\Rules\Methods\CallMethodsRule; use PHPStan\Rules\Methods\MethodCallCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\Properties\PropertyReflectionFinder; @@ -49,6 +50,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, ), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), ); } diff --git a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php index 7e2e6d8b86e..567b0c6b293 100644 --- a/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php +++ b/tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\ClassCaseSensitivityCheck; use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; @@ -25,18 +26,19 @@ protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); $container = self::getContainer(); + $ruleLevelHelper = new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: true, + checkImplicitMixed: true, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); return new ClassConstantRule( $reflectionProvider, - new RuleLevelHelper( - $reflectionProvider, - checkNullables: true, - checkThisOnly: false, - checkUnionTypes: true, - checkExplicitMixed: true, - checkImplicitMixed: true, - checkBenevolentUnionTypes: false, - discoveringSymbolsTip: true, - ), + $ruleLevelHelper, new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true), new ClassForbiddenNameCheck($container), @@ -44,7 +46,7 @@ protected function getRule(): Rule $container, ), new PhpVersion($this->phpVersion), - checkNonStringableDynamicAccess: true, + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), ); } diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index a6e55ecbd30..6bbf57f5727 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Methods; use PHPStan\Rules\FunctionCallParametersCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\Properties\PropertyReflectionFinder; @@ -60,6 +61,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, ), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), ); } @@ -3614,6 +3616,10 @@ public function testDynamicCall(): void 'Call to an undefined method MethodsDynamicCall\Foo::bar().', 23, ], + [ + 'Method name for $this(MethodsDynamicCall\Foo) must be a string, but object was given.', + 25, + ], [ 'Call to an undefined method MethodsDynamicCall\Foo::doBar().', 26, @@ -3637,6 +3643,32 @@ public function testDynamicCall(): void ]); } + public function testDynamicMethodName(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/dynamic-method-name.php'], [ + [ + 'Method name for $this(DynamicMethodName\Foo) must be a string, but $this(DynamicMethodName\Foo) was given.', + 16, + ], + [ + 'Method name for $this(DynamicMethodName\Foo) must be a string, but object was given.', + 17, + ], + [ + 'Method name for $this(DynamicMethodName\Foo) must be a string, but Stringable was given.', + 18, + ], + [ + 'Method name for $this(DynamicMethodName\Foo) must be a string, but int was given.', + 19, + ], + ]); + } + public function testBug12884(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index be9dde7dd3e..cf2dc3a6807 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; use PHPStan\Rules\FunctionCallParametersCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\Properties\PropertyReflectionFinder; @@ -71,6 +72,7 @@ protected function getRule(): Rule checkExtraArguments: true, checkMissingTypehints: true, ), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), ); } @@ -878,6 +880,10 @@ public function testDynamicCall(): void 'Call to an undefined static method MethodsDynamicCall\Foo::bar().', 33, ], + [ + 'Method name for MethodsDynamicCall\Foo must be a string, but object was given.', + 35, + ], [ 'Call to an undefined static method MethodsDynamicCall\Foo::doBar().', 36, @@ -901,6 +907,27 @@ public function testDynamicCall(): void ]); } + public function testDynamicStaticMethodName(): void + { + $this->checkThisOnly = false; + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/dynamic-static-method-name.php'], [ + [ + 'Method name for DynamicStaticMethodName\Foo must be a string, but object was given.', + 16, + ], + [ + 'Method name for DynamicStaticMethodName\Foo must be a string, but Stringable was given.', + 17, + ], + [ + 'Method name for DynamicStaticMethodName\Foo must be a string, but int was given.', + 18, + ], + ]); + } + public function testBug13267(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php index 5cad631977b..12e280c2a7b 100644 --- a/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Php\PhpVersion; use PHPStan\Reflection\Php\PhpClassReflectionExtension; use PHPStan\Rules\FunctionCallParametersCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\Properties\PropertyReflectionFinder; @@ -33,6 +34,7 @@ protected function getRule(): Rule new CallMethodsRule( new MethodCallCheck($reflectionProvider, $ruleLevelHelper, true, true), new FunctionCallParametersCheck($ruleLevelHelper, new NullsafeCheck(), new UnresolvableTypeHelper(), new PropertyReflectionFinder(), $reflectionProvider, true, true, true, true), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), ), new OverridingMethodRule( $phpVersion, diff --git a/tests/PHPStan/Rules/Methods/data/dynamic-method-name.php b/tests/PHPStan/Rules/Methods/data/dynamic-method-name.php new file mode 100644 index 00000000000..e8e2795e66b --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/dynamic-method-name.php @@ -0,0 +1,24 @@ +{$this}(); // error - $this is not a string + $this->$object(); // error - object is not a string + $this->$stringable(); // error - method names cannot be Stringable + $this->$int(); // error - int is not a string + + $this->$name(); // valid + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/dynamic-static-method-name.php b/tests/PHPStan/Rules/Methods/data/dynamic-static-method-name.php new file mode 100644 index 00000000000..8e8019c456c --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/dynamic-static-method-name.php @@ -0,0 +1,23 @@ +checkThisOnly, + checkUnionTypes: $this->checkUnionTypes, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); return new AccessPropertiesRule( new AccessPropertiesCheck( $reflectionProvider, - new RuleLevelHelper( - $reflectionProvider, - checkNullables: true, - checkThisOnly: $this->checkThisOnly, - checkUnionTypes: $this->checkUnionTypes, - checkExplicitMixed: false, - checkImplicitMixed: false, - checkBenevolentUnionTypes: false, - discoveringSymbolsTip: true, - ), + $ruleLevelHelper, new PhpVersion(PHP_VERSION_ID), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), reportMagicProperties: true, checkDynamicProperties: $this->checkDynamicProperties, - checkNonStringableDynamicAccess: true, ), ); } diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php index f8ddba0d610..41e63ddac37 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\ClassCaseSensitivityCheck; use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; @@ -21,19 +22,20 @@ class AccessStaticPropertiesInAssignRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); + $ruleLevelHelper = new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); return new AccessStaticPropertiesInAssignRule( new AccessStaticPropertiesCheck( $reflectionProvider, - new RuleLevelHelper( - $reflectionProvider, - checkNullables: true, - checkThisOnly: false, - checkUnionTypes: true, - checkExplicitMixed: false, - checkImplicitMixed: false, - checkBenevolentUnionTypes: false, - discoveringSymbolsTip: true, - ), + $ruleLevelHelper, new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, true), new ClassForbiddenNameCheck(self::getContainer()), @@ -41,6 +43,7 @@ protected function getRule(): Rule self::getContainer(), ), new PhpVersion(PHP_VERSION_ID), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), discoveringSymbolsTip: true, ), ); diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php index a6e80a4dff5..a1d5cd4b787 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\ClassCaseSensitivityCheck; use PHPStan\Rules\ClassForbiddenNameCheck; use PHPStan\Rules\ClassNameCheck; +use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; @@ -20,19 +21,20 @@ class AccessStaticPropertiesRuleTest extends RuleTestCase protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); + $ruleLevelHelper = new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ); return new AccessStaticPropertiesRule( new AccessStaticPropertiesCheck( $reflectionProvider, - new RuleLevelHelper( - $reflectionProvider, - checkNullables: true, - checkThisOnly: false, - checkUnionTypes: true, - checkExplicitMixed: false, - checkImplicitMixed: false, - checkBenevolentUnionTypes: false, - discoveringSymbolsTip: true, - ), + $ruleLevelHelper, new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, true), new ClassForbiddenNameCheck(self::getContainer()), @@ -40,6 +42,7 @@ protected function getRule(): Rule self::getContainer(), ), new PhpVersion(PHP_VERSION_ID), + new NonStringableDynamicAccessCheck($ruleLevelHelper, true), discoveringSymbolsTip: true, ), ); @@ -297,6 +300,24 @@ public function testClassExists(): void $this->analyse([__DIR__ . '/data/static-properties-class-exists.php'], []); } + public function testDynamicStaticPropertyName(): void + { + $this->analyse([__DIR__ . '/data/dynamic-static-property-name.php'], [ + [ + 'Static property name for DynamicStaticPropertyName\Foo must be a string, but object was given.', + 14, + ], + [ + 'Static property name for DynamicStaticPropertyName\Foo must be a string, but array was given.', + 15, + ], + [ + 'Static property name for DynamicStaticPropertyName\Foo must be a string, but object was given.', + 21, + ], + ]); + } + public function testBug5143(): void { $this->analyse([__DIR__ . '/data/bug-5143.php'], []); diff --git a/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php b/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php new file mode 100644 index 00000000000..7896daa935d --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php @@ -0,0 +1,25 @@ +checkNonStringableDynamicAccess, + ), $this->cliArgumentsVariablesRegistered, $this->checkMaybeUndefinedVariables, ); @@ -1674,4 +1691,31 @@ public function testBug10090(): void ]); } + public function testVariableVariableName(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->checkNonStringableDynamicAccess = true; + $this->analyse([__DIR__ . '/data/variable-variable-name.php'], [ + [ + 'Variable variable name must be a string, but $this(VariableVariableName\Greeter) was given.', + 12, + ], + [ + 'Variable variable name must be a string, but array was given.', + 25, + ], + [ + 'Variable variable name must be a string, but object was given.', + 26, + ], + [ + 'Variable variable name must be a string, but $this(VariableVariableName\Greeter) was given.', + 27, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/variable-variable-name.php b/tests/PHPStan/Rules/Variables/data/variable-variable-name.php new file mode 100644 index 00000000000..adc21ce5104 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/variable-variable-name.php @@ -0,0 +1,30 @@ + Date: Sun, 14 Jun 2026 15:33:32 +0000 Subject: [PATCH 2/5] Build errors inside NonStringableDynamicAccessCheck and return them to callers Co-Authored-By: Claude Opus 4.8 --- src/Rules/Classes/ClassConstantRule.php | 19 ++++---- src/Rules/Methods/CallMethodsRule.php | 21 ++++----- src/Rules/Methods/CallStaticMethodsRule.php | 26 +++++------ src/Rules/NonStringableDynamicAccessCheck.php | 43 ++++++++++++++----- .../Properties/AccessPropertiesCheck.php | 17 ++++---- .../AccessStaticPropertiesCheck.php | 17 ++++---- src/Rules/Variables/DefinedVariableRule.php | 17 +++----- 7 files changed, 85 insertions(+), 75 deletions(-) diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index 4f4070b93e5..981ea6c833b 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -67,16 +67,17 @@ public function processNode(Node $node, Scope $scope): array $constantNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); - if ($nonStringableNameType !== null) { - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors[] = RuleErrorBuilder::message(sprintf('Class constant name for %s must be a string, but %s was given.', $className, $nonStringableNameType->describe(VerbosityLevel::precise()))) - ->identifier('classConstant.nameNotString') - ->build(); - } + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName( + $scope, + $node->name, + 'Class constant name for %s must be a string, but %s was given.', + [$className], + 'classConstant.nameNotString', + )); } foreach ($constantNameScopes as $constantName => $constantScope) { diff --git a/src/Rules/Methods/CallMethodsRule.php b/src/Rules/Methods/CallMethodsRule.php index d3c62320c16..dec54992910 100644 --- a/src/Rules/Methods/CallMethodsRule.php +++ b/src/Rules/Methods/CallMethodsRule.php @@ -16,10 +16,8 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; -use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function array_merge; -use function sprintf; /** * @implements Rule @@ -54,17 +52,14 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $methodNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); - if ($nonStringableNameType !== null) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Method name for %s must be a string, but %s was given.', - $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()), - $nonStringableNameType->describe(VerbosityLevel::precise()), - )) - ->line($node->name->getStartLine()) - ->identifier('method.nameNotString') - ->build(); - } + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName( + $scope, + $node->name, + 'Method name for %s must be a string, but %s was given.', + [$scope->getType($node->var)->describe(VerbosityLevel::typeOnly())], + 'method.nameNotString', + $node->name->getStartLine(), + )); } foreach ($methodNameScopes as $methodName => $methodScope) { diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php index 86d8e8a6c57..298c6f025bc 100644 --- a/src/Rules/Methods/CallStaticMethodsRule.php +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -17,7 +17,6 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; -use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function array_merge; use function sprintf; @@ -55,21 +54,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $methodNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringName($scope, $node->name); - if ($nonStringableNameType !== null) { - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); + $className = $node->class instanceof Name + ? $scope->resolveName($node->class) + : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors[] = RuleErrorBuilder::message(sprintf( - 'Method name for %s must be a string, but %s was given.', - $className, - $nonStringableNameType->describe(VerbosityLevel::precise()), - )) - ->line($node->name->getStartLine()) - ->identifier('staticMethod.nameNotString') - ->build(); - } + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName( + $scope, + $node->name, + 'Method name for %s must be a string, but %s was given.', + [$className], + 'staticMethod.nameNotString', + $node->name->getStartLine(), + )); } foreach ($methodNameScopes as $methodName => $methodScope) { diff --git a/src/Rules/NonStringableDynamicAccessCheck.php b/src/Rules/NonStringableDynamicAccessCheck.php index 90a58b90cdb..6039b9d72db 100644 --- a/src/Rules/NonStringableDynamicAccessCheck.php +++ b/src/Rules/NonStringableDynamicAccessCheck.php @@ -8,6 +8,8 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; use PHPStan\Type\Type; +use PHPStan\Type\VerbosityLevel; +use function sprintf; /** * Checks whether the name of a dynamically accessed variable or member @@ -31,13 +33,15 @@ public function __construct( /** * For names that PHP casts to string at runtime (variable variables, * property and static property names) objects implementing __toString are - * accepted. Returns the offending name type to report, or null when the - * name is usable. + * accepted. + * + * @param list $messageArgs sprintf arguments preceding the offending name type + * @return list */ - public function checkStringCastableName(Scope $scope, Expr $name): ?Type + public function checkStringCastableName(Scope $scope, Expr $name, string $messageFormat, array $messageArgs, string $identifier, ?int $line = null): array { if (!$this->checkNonStringableDynamicAccess) { - return null; + return []; } $nameType = $this->ruleLevelHelper->findTypeToCheck( @@ -51,21 +55,23 @@ public function checkStringCastableName(Scope $scope, Expr $name): ?Type !$nameType instanceof ErrorType && ($nameType->toString() instanceof ErrorType || !$nameType->toString()->isString()->yes()) ) { - return $scope->getType($name); + return [$this->buildError($scope->getType($name), $messageFormat, $messageArgs, $identifier, $line)]; } - return null; + return []; } /** * For names that must be actual strings (method, static method and class * constant names) objects implementing __toString are not accepted. - * Returns the offending name type to report, or null when the name is usable. + * + * @param list $messageArgs sprintf arguments preceding the offending name type + * @return list */ - public function checkStringName(Scope $scope, Expr $name): ?Type + public function checkStringName(Scope $scope, Expr $name, string $messageFormat, array $messageArgs, string $identifier, ?int $line = null): array { if (!$this->checkNonStringableDynamicAccess) { - return null; + return []; } $nameType = $this->ruleLevelHelper->findTypeToCheck( @@ -76,10 +82,25 @@ public function checkStringName(Scope $scope, Expr $name): ?Type )->getType(); if (!$nameType instanceof ErrorType && !$nameType->isString()->yes()) { - return $nameType; + return [$this->buildError($nameType, $messageFormat, $messageArgs, $identifier, $line)]; } - return null; + return []; + } + + /** + * @param list $messageArgs + */ + private function buildError(Type $nameType, string $messageFormat, array $messageArgs, string $identifier, ?int $line): IdentifierRuleError + { + $messageArgs[] = $nameType->describe(VerbosityLevel::precise()); + $builder = RuleErrorBuilder::message(sprintf($messageFormat, ...$messageArgs)) + ->identifier($identifier); + if ($line !== null) { + $builder->line($line); + } + + return $builder->build(); } } diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index 1ccd6454d1a..d3204c4eae5 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -61,14 +61,15 @@ public function check(PropertyFetch $node, Scope $scope, bool $write): array $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); if (!$write) { - $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); - if ($nonStringableNameType !== null) { - $className = $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()); - $errors[] = RuleErrorBuilder::message(sprintf('Property name for %s must be a string, but %s was given.', $className, $nonStringableNameType->describe(VerbosityLevel::precise()))) - ->line($node->name->getStartLine()) - ->identifier('property.nameNotString') - ->build(); - } + $className = $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()); + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringCastableName( + $scope, + $node->name, + 'Property name for %s must be a string, but %s was given.', + [$className], + 'property.nameNotString', + $node->name->getStartLine(), + )); } } diff --git a/src/Rules/Properties/AccessStaticPropertiesCheck.php b/src/Rules/Properties/AccessStaticPropertiesCheck.php index 8c18cce1637..a79e71b3210 100644 --- a/src/Rules/Properties/AccessStaticPropertiesCheck.php +++ b/src/Rules/Properties/AccessStaticPropertiesCheck.php @@ -67,20 +67,19 @@ public function check(StaticPropertyFetch $node, Scope $scope, bool $write): arr } else { $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); - $nonStringableNameType = $write ? null : $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); - if ($nonStringableNameType !== null) { + if (!$write) { $className = $node->class instanceof Name ? $scope->resolveName($node->class) : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors[] = RuleErrorBuilder::message(sprintf( + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringCastableName( + $scope, + $node->name, 'Static property name for %s must be a string, but %s was given.', - $className, - $nonStringableNameType->describe(VerbosityLevel::precise()), - )) - ->line($node->name->getStartLine()) - ->identifier('staticProperty.nameNotString') - ->build(); + [$className], + 'staticProperty.nameNotString', + $node->name->getStartLine(), + )); } } diff --git a/src/Rules/Variables/DefinedVariableRule.php b/src/Rules/Variables/DefinedVariableRule.php index 4bd2f97e9d6..e2153fcd4bf 100644 --- a/src/Rules/Variables/DefinedVariableRule.php +++ b/src/Rules/Variables/DefinedVariableRule.php @@ -13,7 +13,6 @@ use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; -use PHPStan\Type\VerbosityLevel; use function array_merge; use function in_array; use function is_string; @@ -54,15 +53,13 @@ public function processNode(Node $node, Scope $scope): array $variableNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $nonStringableNameType = $this->nonStringableDynamicAccessCheck->checkStringCastableName($scope, $node->name); - if ($nonStringableNameType !== null) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Variable variable name must be a string, but %s was given.', - $nonStringableNameType->describe(VerbosityLevel::precise()), - )) - ->identifier('variable.nameNotString') - ->build(); - } + $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringCastableName( + $scope, + $node->name, + 'Variable variable name must be a string, but %s was given.', + [], + 'variable.nameNotString', + )); } foreach ($variableNameScopes as $name => $variableScope) { From 4301a5585bfdc1dd59f0e759c24cd2f5fc515c27 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 14 Jun 2026 15:55:29 +0000 Subject: [PATCH 3/5] Derive error line and class placeholder inside NonStringableDynamicAccessCheck Both checkStringName() and checkStringCastableName() already receive the name expression, so they now set the error line to $name->getStartLine() themselves instead of each call site passing it explicitly. The leading %s placeholder (the class/object whose member is accessed) is also resolved inside the check: instead of a list of sprintf args, callers pass the class node (or null for variable variables) and the check resolves it via resolveName()/describe() when building the error. Co-Authored-By: Claude Opus 4.8 --- src/Rules/Classes/ClassConstantRule.php | 7 +--- src/Rules/Methods/CallMethodsRule.php | 4 +-- src/Rules/Methods/CallStaticMethodsRule.php | 9 +---- src/Rules/NonStringableDynamicAccessCheck.php | 33 +++++++++++-------- .../Properties/AccessPropertiesCheck.php | 4 +-- .../AccessStaticPropertiesCheck.php | 7 +--- src/Rules/Variables/DefinedVariableRule.php | 2 +- 7 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/Rules/Classes/ClassConstantRule.php b/src/Rules/Classes/ClassConstantRule.php index 981ea6c833b..2dc357887ab 100644 --- a/src/Rules/Classes/ClassConstantRule.php +++ b/src/Rules/Classes/ClassConstantRule.php @@ -5,7 +5,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\ClassConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; @@ -67,15 +66,11 @@ public function processNode(Node $node, Scope $scope): array $constantNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName( $scope, $node->name, 'Class constant name for %s must be a string, but %s was given.', - [$className], + $node->class, 'classConstant.nameNotString', )); } diff --git a/src/Rules/Methods/CallMethodsRule.php b/src/Rules/Methods/CallMethodsRule.php index dec54992910..61c7cd620c8 100644 --- a/src/Rules/Methods/CallMethodsRule.php +++ b/src/Rules/Methods/CallMethodsRule.php @@ -16,7 +16,6 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; -use PHPStan\Type\VerbosityLevel; use function array_merge; /** @@ -56,9 +55,8 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $scope, $node->name, 'Method name for %s must be a string, but %s was given.', - [$scope->getType($node->var)->describe(VerbosityLevel::typeOnly())], + $node->var, 'method.nameNotString', - $node->name->getStartLine(), )); } diff --git a/src/Rules/Methods/CallStaticMethodsRule.php b/src/Rules/Methods/CallStaticMethodsRule.php index 298c6f025bc..53d797361e5 100644 --- a/src/Rules/Methods/CallStaticMethodsRule.php +++ b/src/Rules/Methods/CallStaticMethodsRule.php @@ -5,7 +5,6 @@ use PhpParser\Node; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\StaticCall; -use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; @@ -17,7 +16,6 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\NonStringableDynamicAccessCheck; use PHPStan\Rules\Rule; -use PHPStan\Type\VerbosityLevel; use function array_merge; use function sprintf; @@ -54,17 +52,12 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $methodNameScopes[$name] = $scope->filterByTruthyValue(new Identical($node->name, new String_($name))); } - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName( $scope, $node->name, 'Method name for %s must be a string, but %s was given.', - [$className], + $node->class, 'staticMethod.nameNotString', - $node->name->getStartLine(), )); } diff --git a/src/Rules/NonStringableDynamicAccessCheck.php b/src/Rules/NonStringableDynamicAccessCheck.php index 6039b9d72db..a7105bf25bf 100644 --- a/src/Rules/NonStringableDynamicAccessCheck.php +++ b/src/Rules/NonStringableDynamicAccessCheck.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules; use PhpParser\Node\Expr; +use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -35,10 +36,10 @@ public function __construct( * property and static property names) objects implementing __toString are * accepted. * - * @param list $messageArgs sprintf arguments preceding the offending name type + * @param Name|Expr|null $classNode the node whose name/type fills the leading `%s` placeholder, or null when the message has none * @return list */ - public function checkStringCastableName(Scope $scope, Expr $name, string $messageFormat, array $messageArgs, string $identifier, ?int $line = null): array + public function checkStringCastableName(Scope $scope, Expr $name, string $messageFormat, $classNode, string $identifier): array { if (!$this->checkNonStringableDynamicAccess) { return []; @@ -55,7 +56,7 @@ public function checkStringCastableName(Scope $scope, Expr $name, string $messag !$nameType instanceof ErrorType && ($nameType->toString() instanceof ErrorType || !$nameType->toString()->isString()->yes()) ) { - return [$this->buildError($scope->getType($name), $messageFormat, $messageArgs, $identifier, $line)]; + return [$this->buildError($scope, $name, $scope->getType($name), $messageFormat, $classNode, $identifier)]; } return []; @@ -65,10 +66,10 @@ public function checkStringCastableName(Scope $scope, Expr $name, string $messag * For names that must be actual strings (method, static method and class * constant names) objects implementing __toString are not accepted. * - * @param list $messageArgs sprintf arguments preceding the offending name type + * @param Name|Expr|null $classNode the node whose name/type fills the leading `%s` placeholder, or null when the message has none * @return list */ - public function checkStringName(Scope $scope, Expr $name, string $messageFormat, array $messageArgs, string $identifier, ?int $line = null): array + public function checkStringName(Scope $scope, Expr $name, string $messageFormat, $classNode, string $identifier): array { if (!$this->checkNonStringableDynamicAccess) { return []; @@ -82,25 +83,29 @@ public function checkStringName(Scope $scope, Expr $name, string $messageFormat, )->getType(); if (!$nameType instanceof ErrorType && !$nameType->isString()->yes()) { - return [$this->buildError($nameType, $messageFormat, $messageArgs, $identifier, $line)]; + return [$this->buildError($scope, $name, $nameType, $messageFormat, $classNode, $identifier)]; } return []; } /** - * @param list $messageArgs + * @param Name|Expr|null $classNode */ - private function buildError(Type $nameType, string $messageFormat, array $messageArgs, string $identifier, ?int $line): IdentifierRuleError + private function buildError(Scope $scope, Expr $name, Type $nameType, string $messageFormat, $classNode, string $identifier): IdentifierRuleError { - $messageArgs[] = $nameType->describe(VerbosityLevel::precise()); - $builder = RuleErrorBuilder::message(sprintf($messageFormat, ...$messageArgs)) - ->identifier($identifier); - if ($line !== null) { - $builder->line($line); + $messageArgs = []; + if ($classNode !== null) { + $messageArgs[] = $classNode instanceof Name + ? $scope->resolveName($classNode) + : $scope->getType($classNode)->describe(VerbosityLevel::typeOnly()); } + $messageArgs[] = $nameType->describe(VerbosityLevel::precise()); - return $builder->build(); + return RuleErrorBuilder::message(sprintf($messageFormat, ...$messageArgs)) + ->line($name->getStartLine()) + ->identifier($identifier) + ->build(); } } diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index d3204c4eae5..c6bd1e3e5fa 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -61,14 +61,12 @@ public function check(PropertyFetch $node, Scope $scope, bool $write): array $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); if (!$write) { - $className = $scope->getType($node->var)->describe(VerbosityLevel::typeOnly()); $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringCastableName( $scope, $node->name, 'Property name for %s must be a string, but %s was given.', - [$className], + $node->var, 'property.nameNotString', - $node->name->getStartLine(), )); } } diff --git a/src/Rules/Properties/AccessStaticPropertiesCheck.php b/src/Rules/Properties/AccessStaticPropertiesCheck.php index a79e71b3210..92742621326 100644 --- a/src/Rules/Properties/AccessStaticPropertiesCheck.php +++ b/src/Rules/Properties/AccessStaticPropertiesCheck.php @@ -68,17 +68,12 @@ public function check(StaticPropertyFetch $node, Scope $scope, bool $write): arr $names = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $scope->getType($node->name)->getConstantStrings()); if (!$write) { - $className = $node->class instanceof Name - ? $scope->resolveName($node->class) - : $scope->getType($node->class)->describe(VerbosityLevel::typeOnly()); - $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringCastableName( $scope, $node->name, 'Static property name for %s must be a string, but %s was given.', - [$className], + $node->class, 'staticProperty.nameNotString', - $node->name->getStartLine(), )); } } diff --git a/src/Rules/Variables/DefinedVariableRule.php b/src/Rules/Variables/DefinedVariableRule.php index e2153fcd4bf..674e0801eab 100644 --- a/src/Rules/Variables/DefinedVariableRule.php +++ b/src/Rules/Variables/DefinedVariableRule.php @@ -57,7 +57,7 @@ public function processNode(Node $node, Scope $scope): array $scope, $node->name, 'Variable variable name must be a string, but %s was given.', - [], + null, 'variable.nameNotString', )); } From bdf0205ff81bd5f40722c9cd94998fffeeac619e Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 14 Jun 2026 18:34:05 +0200 Subject: [PATCH 4/5] Fix --- phpstan-baseline.neon | 12 ------------ src/DependencyInjection/ContainerFactory.php | 8 ++++++-- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 55cb79f1799..2c3e0d16c3c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -234,24 +234,12 @@ parameters: count: 1 path: src/DependencyInjection/ContainerFactory.php - - - rawMessage: 'Method name for Nette\Schema\Elements\AnyOf|Nette\Schema\Elements\Structure|Nette\Schema\Elements\Type must be a string, but array|Nette\DI\Definitions\Definition|Nette\DI\Definitions\Reference|string|null was given.' - identifier: method.nameNotString - count: 1 - path: src/DependencyInjection/ContainerFactory.php - - rawMessage: Variable static method call on Nette\Schema\Expect. identifier: staticMethod.dynamicName count: 1 path: src/DependencyInjection/ContainerFactory.php - - - rawMessage: 'Method name for Nette\Schema\Expect must be a string, but array|Nette\DI\Definitions\Definition|Nette\DI\Definitions\Reference|string|null was given.' - identifier: staticMethod.nameNotString - count: 1 - path: src/DependencyInjection/ContainerFactory.php - - rawMessage: Fetching class constant PREVENT_MERGING of deprecated class Nette\DI\Config\Helpers. identifier: classConstant.deprecatedClass diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index 67f8d4ea1aa..f1adf9a1962 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -379,11 +379,15 @@ private function processSchema(array $statements, bool $required = true): Schema $parameterSchema = null; foreach ($statements as $statement) { $processedArguments = array_map(fn ($argument) => $this->processArgument($argument), $statement->arguments); + $entity = $statement->getEntity(); + if (!is_string($entity)) { + throw new ShouldNotHappenException(); + } if ($parameterSchema === null) { /** @var Type|AnyOf|Structure $parameterSchema */ - $parameterSchema = Expect::{$statement->getEntity()}(...$processedArguments); + $parameterSchema = Expect::{$entity}(...$processedArguments); } else { - $parameterSchema->{$statement->getEntity()}(...$processedArguments); + $parameterSchema->{$entity}(...$processedArguments); } } From 0403980a13c75ce564ffb0fb7fe8f053aa874955 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 15 Jun 2026 06:07:19 +0000 Subject: [PATCH 5/5] Add dynamic static property name tests for nullable and union name types Co-Authored-By: Claude Opus 4.8 --- .../Properties/AccessStaticPropertiesRuleTest.php | 14 +++++++++++--- .../data/dynamic-static-property-name.php | 12 +++++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php index a1d5cd4b787..4f6f0a6070e 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php @@ -305,15 +305,23 @@ public function testDynamicStaticPropertyName(): void $this->analyse([__DIR__ . '/data/dynamic-static-property-name.php'], [ [ 'Static property name for DynamicStaticPropertyName\Foo must be a string, but object was given.', - 14, + 19, ], [ 'Static property name for DynamicStaticPropertyName\Foo must be a string, but array was given.', - 15, + 20, ], [ 'Static property name for DynamicStaticPropertyName\Foo must be a string, but object was given.', - 21, + 26, + ], + [ + 'Static property name for DynamicStaticPropertyName\Foo must be a string, but object|string was given.', + 31, + ], + [ + 'Static property name for DynamicStaticPropertyName\Foo must be a string, but int|object was given.', + 32, ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php b/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php index 7896daa935d..28c98b03c7a 100644 --- a/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php +++ b/tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php @@ -9,7 +9,12 @@ class Foo public static string $bar = ''; - public function test(string $name, Stringable $stringable, int $int, array $array, object $object): void + /** + * @param string|int $stringOrInt + * @param string|object $stringOrObject + * @param int|object $intOrObject + */ + public function test(string $name, Stringable $stringable, int $int, array $array, object $object, ?string $nullableString, $stringOrInt, $stringOrObject, $intOrObject): void { echo self::${$object}; // error - object is not stringable echo self::${$array}; // error - array is not a string @@ -20,6 +25,11 @@ public function test(string $name, Stringable $stringable, int $int, array $arra self::${$object} = 'x'; // error - object is not stringable (reported once) self::${$name} = 'x'; // valid + + echo self::${$nullableString}; // valid - null is castable to string + echo self::${$stringOrInt}; // valid - both castable to string + echo self::${$stringOrObject}; // error - object part is not stringable + echo self::${$intOrObject}; // error - object part is not stringable } }