Report non-stringable names in variable variables, dynamic method calls and dynamic static property/method access#5871
Conversation
…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.
VincentLanglet
left a comment
There was a problem hiding this comment.
Try to do
$errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName(...));
instead by passing as args
'Method name for %s must be a string, but %s was given.'
and other needed param.
…o callers Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
I've addressed VincentLanglet's review. Here's a summary of what changed: What the reviewer asked forInstead of each rule receiving the offending name $errors = array_merge($errors, $this->nonStringableDynamicAccessCheck->checkStringName(...));with the message format and other needed parameters passed as args. Changes made
All 6 call sites ( The error messages, identifiers, and lines are all unchanged — only where they're built moved. Verification
Committed as |
VincentLanglet
left a comment
There was a problem hiding this comment.
-
Do we have to set manually the line (isn't it automatically ?)
-
If we have to, can't we always set it to
$node->name->getStartLine()to avoid passing it to the method ? -
The param for the sprintf are always an empty array or an array with a single element. We could have a nullable param with something like
$placeholder = $foo instanceof Name
? $scope->resolveName($foo)
: $scope->getType($foo)->describe(VerbosityLevel::typeOnly());
…cessCheck 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<string> 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 <noreply@anthropic.com>
|
I've addressed all three points in VincentLanglet's review. Summary1) "Do we have to set manually the line (isn't it automatically)?" I confirmed the line is set automatically — 2) "Can't we always set it to Yes. Both 3) "The sprintf params are always empty or a single element — use a nullable param." Replaced the $messageArgs[] = $classNode instanceof Name
? $scope->resolveName($classNode)
: $scope->getType($classNode)->describe(VerbosityLevel::typeOnly());Callers just pass the relevant node — Verification
Committed as |
VincentLanglet
left a comment
There was a problem hiding this comment.
The baseline is not generated in the same order, cf diff
232,233c232,233
< 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
---
> rawMessage: Variable method call on Nette\Schema\Elements\AnyOf|Nette\Schema\Elements\Structure|Nette\Schema\Elements\Type.
> identifier: method.dynamicName
238,239c238,239
< 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
---
> 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
244,245c244,245
< rawMessage: Variable method call on Nette\Schema\Elements\AnyOf|Nette\Schema\Elements\Structure|Nette\Schema\Elements\Type.
< identifier: method.dynamicName
---
> rawMessage: Variable static method call on Nette\Schema\Expect.
> identifier: staticMethod.dynamicName
250,251c250,251
< rawMessage: Variable static method call on Nette\Schema\Expect.
< identifier: staticMethod.dynamicName
---
> 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
|
I processed this review but have nothing to report. |
8bb5dc5 to
1322990
Compare
1322990 to
bdf0205
Compare
Summary
$$this(and other dynamic accesses whose name expression cannot become a string) was not reported by PHPStan, even though it fatals at runtime (Object of class X could not be converted to string). PHPStan already detected this for dynamic property access ($obj->{$expr}) and class constant access (Foo::{$expr}) behind thecheckNonStringableDynamicAccessfeature toggle, but the same family of constructs was unchecked for variable variables, dynamic method calls, dynamic static method calls and dynamic static property access.This PR extends the existing check to the rest of that family.
Changes
src/Rules/NonStringableDynamicAccessCheck.php— a shared service (gated on thecheckNonStringableDynamicAccessfeature toggle) with:checkStringCastableName()for names PHP casts to string at runtime (variable variables, property and static-property names) — objects implementing__toStringare accepted.checkStringName()for names that must be real strings (method, static-method and class-constant names) —Stringableis not accepted, matching PHP's "Method name must be a string".src/Rules/Variables/DefinedVariableRule.php— report$$exprwith a non-stringable name (variable.nameNotString). This is the case from the issue.src/Rules/Methods/CallMethodsRule.php— report$obj->{$expr}()with a non-string name (method.nameNotString).src/Rules/Methods/CallStaticMethodsRule.php— reportFoo::{$expr}()with a non-string name (staticMethod.nameNotString).src/Rules/Properties/AccessStaticPropertiesCheck.php— reportFoo::${$expr}with a non-stringable name (staticProperty.nameNotString), gated on read access so assignments are not reported twice.src/Rules/Properties/AccessPropertiesCheck.phpandsrc/Rules/Classes/ClassConstantRule.php— refactored to delegate to the new shared service; messages and behavior unchanged.Root cause
These constructs all share one conceptual axis: a name expression that PHP must turn into a string before it can resolve a variable/member. The runtime-string semantics differ slightly per construct:
Stringable(PHP casts via__toString);Stringablefatals).PHPStan only implemented the check for two members of this family (instance properties and class constants). The fix centralizes the logic and applies it to every member, with the correct stringable-vs-string semantics for each (verified against real PHP behavior).
Test
tests/PHPStan/Rules/Variables/data/variable-variable-name.php+DefinedVariableRuleTest::testVariableVariableName— covers the reported$$this, plus$$array/$$object(errors) and$$name/$$stringable/$$int(valid).tests/PHPStan/Rules/Methods/data/dynamic-method-name.php+CallMethodsRuleTest::testDynamicMethodName— covers$this, object,Stringable(rejected for methods) andint.tests/PHPStan/Rules/Methods/data/dynamic-static-method-name.php+CallStaticMethodsRuleTest::testDynamicStaticMethodName— same, for static calls.tests/PHPStan/Rules/Properties/data/dynamic-static-property-name.php+AccessStaticPropertiesRuleTest::testDynamicStaticPropertyName— covers object/array (errors),Stringable/int/string (valid), and an assignment to confirm it is reported exactly once.data/dynamic-call.phpexpectations updated for the new method/static-method name errors it now surfaces.Fixes phpstan/phpstan#4710