Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Report non-stringable names in variable variables, dynamic method cal…
…ls 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.
  • Loading branch information
VincentLanglet committed Sep 19, 2026
commit dd8aa7f8bafdb737e9a6a6bb3d37f4107c2af69a
12 changes: 12 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -228,12 +228,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
Expand Down
30 changes: 10 additions & 20 deletions src/Rules/Classes/ClassConstantRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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,
)
{
}
Expand All @@ -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();
}
}

Expand Down
17 changes: 17 additions & 0 deletions src/Rules/Methods/CallMethodsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Node\Expr\MethodCall>
Expand All @@ -27,6 +31,7 @@ final class CallMethodsRule implements Rule
public function __construct(
private MethodCallCheck $methodCallCheck,
private FunctionCallParametersCheck $parametersCheck,
private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck,
)
{
}
Expand All @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/Rules/Methods/CallStaticMethodsRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -28,6 +32,7 @@ final class CallStaticMethodsRule implements Rule
public function __construct(
private StaticMethodCallCheck $methodCallCheck,
private FunctionCallParametersCheck $parametersCheck,
private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck,
)
{
}
Expand All @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions src/Rules/NonStringableDynamicAccessCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules;

use PhpParser\Node\Expr;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\AutowiredParameter;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Type\ErrorType;
use PHPStan\Type\Type;

/**
* Checks whether the name of a dynamically accessed variable or member
* (`$$name`, `$obj->{$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;
}

}
25 changes: 6 additions & 19 deletions src/Rules/Properties/AccessPropertiesCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
)
{
}
Expand All @@ -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();
Expand Down
20 changes: 19 additions & 1 deletion src/Rules/Properties/AccessStaticPropertiesCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
)
Expand All @@ -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));
}
Expand Down
13 changes: 13 additions & 0 deletions src/Rules/Variables/DefinedVariableRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +27,7 @@ final class DefinedVariableRule implements Rule
{

public function __construct(
private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck,
#[AutowiredParameter]
private bool $cliArgumentsVariablesRegistered,
#[AutowiredParameter]
Expand All @@ -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) {
Expand Down
Loading