Repository navigation
Strip the final-by-keyword override from the declaring class before comparing a method's return type with $this - #6941
Conversation
…omparing a method's return type with `$this` - `new Foo()` (and `$x::class === Foo::class` narrowing) produces an ObjectType whose ClassReflection is `asFinal()`. Methods looked up on it get that reflection as their declaring class, while the `$this` return type resolved from the PHPDoc holds the plain one. `ObjectType::isSuperTypeOf()` then answers Maybe, so a `@return $this` method was no longer treated as having side effects when called on a freshly instantiated object: remembered method call values were not invalidated and the call was reported as "on a separate line has no effect". - PhpMethodReflection::hasSideEffects() and AnnotationMethodReflection::hasSideEffects() (`@method $this foo()`) now build the ThisType from `declaringClass->withoutFinalByKeywordOverride()`. - ArgumentsHandler: same fix for the check that keeps object arguments of a fluent `$this`-returning method from being invalidated; it was hidden by the bug above and started over-invalidating once hasSideEffects() became Yes. - Mirrored both changes in turbo-ext (PhpMethodReflectionAccess.cpp, ArgumentsHandler.cpp). - Probed the other places that build a type from a method's declaring class (Called*/Callback* unresolved prototype reflections, PhpDocsResolver, MethodSignatureRule); they only transform `static`, so they don't run into this mismatch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| { | ||
| $this->analyse([__DIR__ . '/data/bug-15320.php'], [ | ||
| [ | ||
| 'Call to method Bug15320DeadCode\\Model::setStatic() on a separate line has no effect.', |
There was a problem hiding this comment.
This is explained by
public function testOutClassStatic(): void
{
$m = new Model();
assert($m->get('name') === 5);
$m->setStatic('name', 10);
assertType('5', $m->get('name'));
}
By default $m->setStatic('name', 10); is considered as pure since it returns something.
There was a problem hiding this comment.
I answered the review question and left the code unchanged. I also added a comment in the test explaining why that error is expected, and pushed it as commit 65d34e9.
Why setStatic() is still reported:
- A
staticreturn type doesn't mean the method has side effects.voidand$thisboth mean the call only matters for what it changes, so PHPStan treats them as having side effects. Astaticmethod, though, can return a modified copy and leave the original object alone (a "wither"). So inPhpMethodReflection::hasSideEffects(),ThisType(Model)->isSuperTypeOf(static)is Maybe, not Yes, and the method stays "possibly pure". - The method body changes nothing.
setStatic()is justreturn $this;, so the rule can confirm the method is pure. - The class is known exactly. The call is made on
new Model(), soPossiblyPureMethodCallCollectorknows no subclass can overridesetStatic().
Put together, discarding the result of $m->setStatic('name', 10); really does nothing, so the error is correct. The issue asks for it too ("error on line 30 is expected"). That matches the nsrt test, where testOutClassStatic keeps the remembered 5 after the call.
make tests and make phpstan both pass.
There was a problem hiding this comment.
I didn't push anything: the change this review calls for is already on the branch.
VincentLanglet's comment wasn't asking for a code change. It answered staabm's question ("why this error?"): a method returning static doesn't imply side effects, because it might return a new instance, like an immutable "wither". So $m->setStatic('name', 10) on a separate line, called on new Model(), really has no effect. The issue (phpstan/phpstan#15320) expects that error too.
I wrote a commit that added a comment explaining this to the test data file, but the push was rejected. Another phpstan-bot commit had already reached the branch, 65d34e96f, "Explain why the static-returning method call is still reported in the bug-15320 test". It puts the same explanation, in more detail, in CallToMethodStatementWithoutImpurePointsRuleTest::testBug15320. Mine would only have duplicated it, so I dropped it and reset my local branch to match the remote.
make tests and make phpstan both passed with my version, which differed only by a comment in the test data. After resetting, I re-ran CallToMethodStatementWithoutImpurePointsRuleTest on the branch as it now stands, and it passes. I didn't re-run the full suite or make phpstan after resetting.
… bug-15320 test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
A method returning
$thiscounts as having side effects (it is clearly fluent/mutating), the same wayvoidmethods do. That worked when the receiver was$this, but not when it was an object made withnew Model(). In that case PHPStan kept remembered method-call values after the call and reported "Call to method Model::setThis() on a separate line has no effect." This PR makes the out-of-class call behave the same as the in-class one.Changes
src/Reflection/Php/PhpMethodReflection.php–hasSideEffects()compares the return type withnew ThisType($this->declaringClass->withoutFinalByKeywordOverride()).src/Reflection/Annotations/AnnotationMethodReflection.php– same fix for@method $this foo()methods. This was also broken; a test covers it.src/Analyser/ArgumentsHandler.php– same fix for the "fluent$thismethod does not invalidate its object arguments" check. Before, the bug above hid this one. OncehasSideEffects()returned Yes, object arguments passed to a$this-returning method called onnew Foo()would have been invalidated incorrectly. A test covers it.turbo-ext/src/PhpMethodReflectionAccess.cpp,turbo-ext/src/ArgumentsHandler.cpp– the same changes in the native mirrors. The extension builds; the smoke test, the signature parity check and the full test suite pass with it loaded.Called*/Callback*UnresolvedMethod/PropertyPrototypeReflectionclasses,PhpDocsResolver::transformStaticTypeandMethodSignatureRule. They only transformstaticand never compare against$this, so they don't hit this mismatch.Root cause
new Model()creates anObjectTypewhoseClassReflectioncomes fromasFinal()(it carries a final-by-keyword override, meaning "exactly this class").PhpClassReflectionExtensiongets the declaring class through$classReflection->getAncestorWithClassName(), which returns that same overridden reflection for methods declared in the class itself. The$thisreturn type resolved from the PHPDoc holds the plain reflection.ObjectType::isSuperTypeOf()deliberately answers Maybe when only one side has the override. SoThisType(declaringClass)->isSuperTypeOf(returnType)was Maybe instead of Yes, and every check of the form "the return type is$thisof the declaring class" failed. Dropping the override from the declaring class before building theThisTypemakes the comparison about the class again, not about exactness.Test
tests/PHPStan/Analyser/nsrt/bug-15320.php– the issue's playground sample, plus:Model $mparameter;@method $thisannotation method called onnew MagicModel();5) after a fluent$thiscall onnew Model().tests/PHPStan/Rules/DeadCode/CallToMethodStatementWithoutImpurePointsRuleTest::testBug15320– no "has no effect" error for the$thismethod. The error for thestaticmethod is still reported, as the issue expects.ArgumentsHandlerchange reverted, the argument-invalidation assertion fails.Fixes phpstan/phpstan#15320
🤖 Generated with Claude Code