diff --git a/CLAUDE.md b/CLAUDE.md index f48abb644d..cb6d78d638 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,10 @@ The `tryRemove()` method handles removing a `ConstantStringType` (e.g., `'Car'`) This affects match expression exhaustiveness: `class-string` matched against `FinalA::class` and `FinalB::class` is exhaustive only because both classes are final. +### StaticType::transformStaticType and ThisType downgrading + +`StaticType::transformStaticType()` is used when resolving method return types on a `StaticType` caller. It traverses the return type and transforms `StaticType`/`ThisType` instances via `changeBaseClass()`. Since `ThisType extends StaticType`, both are caught by the `$type instanceof StaticType` check. The critical invariant: when the **caller** is a `StaticType` (not `ThisType`) and the method's return type contains `ThisType`, the `ThisType` must be downgraded to a plain `StaticType`. This is because `$this` (the exact instance) cannot be guaranteed when calling on a `static` type (which could be any subclass instance). `ThisType::changeBaseClass()` returns a new `ThisType`, which preserves the `$this` semantics — so the downgrade must happen explicitly after `changeBaseClass()`. The `CallbackUnresolvedMethodPrototypeReflection` at line 91 also has special handling for `ThisType` return types intersected with `selfOutType`. + ### PHPDoc inheritance PHPDoc types (`@return`, `@param`, `@throws`, `@property`) are inherited through class hierarchies. Bugs arise when: diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 2876bfb15e..fdec15cc17 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -356,6 +356,14 @@ private function transformStaticType(Type $type, ClassMemberAccessAnswerer $scop $isFinal = $classReflection->isFinal(); } $type = $type->changeBaseClass($classReflection); + + // When calling a method on a `static` type (not `$this`), + // `$this` return type should be downgraded to `static` + // because we can't guarantee the exact instance. + if ($type instanceof ThisType && !$this instanceof ThisType) { + $type = new self($type->getClassReflection(), $type->getSubtractedType()); + } + if (!$isFinal || $type instanceof ThisType) { return $traverse($type); } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index b5b8d1e4e1..b0604072a4 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1291,4 +1291,18 @@ public function testBug10771(): void $this->analyse([__DIR__ . '/data/bug-10771.php'], []); } + public function testBug5946(): void + { + $this->analyse([__DIR__ . '/data/bug-5946.php'], [ + [ + 'Method Bug5946\Model::getModel() should return $this(Bug5946\Model) but returns static(Bug5946\Model).', + 21, + ], + [ + 'Method Bug5946\Model::getModel() should return $this(Bug5946\Model) but returns static(Bug5946\Model).', + 23, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-5946.php b/tests/PHPStan/Rules/Methods/data/bug-5946.php new file mode 100644 index 0000000000..2ae792e64a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-5946.php @@ -0,0 +1,28 @@ +getParent()->getModel(false); // error - returns static not $this + } elseif (mt_rand() === 0) { + return $this->getParent(); // error - returns static not $this + } + + return $this; + } +}