diff --git a/CLAUDE.md b/CLAUDE.md index 10279d2f0d..6aa1ad96a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,6 +307,10 @@ This affects match expression exhaustiveness: `class-string` matc `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 `static::CONST` resolution and ClassConstantAccessType + +PHPDoc types like `@return static::SOME_CONST` require late static binding semantics — the constant should resolve based on the caller's class, not the declaring class. `TypeNodeResolver::resolveConstTypeNode()` and `resolveArrayShapeOffsetType()` handle this by creating a `ClassConstantAccessType(StaticType, constantName)` when the keyword is `static` (not `self`). This type implements `LateResolvableType` and wraps a `StaticType` that gets replaced with the actual caller's `ObjectType` via `CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType()` during method call resolution. The `getResult()` method then resolves the constant on the concrete type. For final classes, the `$isStatic` flag is cleared and the constant is resolved eagerly (same as `self`). The wildcard case (`static::CONST_*`) falls back to `getValueType()` from the class reflection. + ### PHPDoc inheritance PHPDoc types (`@return`, `@param`, `@throws`, `@property`) are inherited through class hierarchies. Bugs arise when: diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf926..9b6ab8eed3 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -58,6 +58,7 @@ use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; +use PHPStan\Type\ClassConstantAccessType; use PHPStan\Type\ClassStringType; use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalType; @@ -1098,9 +1099,14 @@ private function resolveArrayShapeOffsetType(ArrayShapeItemNode $itemNode, NameS throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode } + $isStatic = false; if ($nameScope->getClassName() !== null) { switch (strtolower($constExpr->className)) { case 'static': + $className = $nameScope->getClassName(); + $isStatic = true; + break; + case 'self': $className = $nameScope->getClassName(); break; @@ -1128,11 +1134,19 @@ private function resolveArrayShapeOffsetType(ArrayShapeItemNode $itemNode, NameS } $classReflection = $this->getReflectionProvider()->getClass($className); + if ($isStatic && $classReflection->isFinal()) { + $isStatic = false; + } + $constantName = $constExpr->name; if (!$classReflection->hasConstant($constantName)) { return new ErrorType(); } + if ($isStatic) { + return new ClassConstantAccessType(new StaticType($classReflection), $constantName); + } + $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($constantName); if ($reflectionConstant === false) { return new ErrorType(); @@ -1188,9 +1202,14 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode } + $isStatic = false; if ($nameScope->getClassName() !== null) { switch (strtolower($constExpr->className)) { case 'static': + $className = $nameScope->getClassName(); + $isStatic = true; + break; + case 'self': $className = $nameScope->getClassName(); break; @@ -1219,6 +1238,10 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc $classReflection = $this->getReflectionProvider()->getClass($className); + if ($isStatic && $classReflection->isFinal()) { + $isStatic = false; + } + $constantName = $constExpr->name; if (Strings::contains($constantName, '*')) { // convert * into .*? and escape everything else so the constants can be matched against the pattern @@ -1235,6 +1258,16 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc continue; } + if ($isStatic) { + $constantReflection = $classReflection->getConstant($classConstantName); + if (!$constantReflection->isFinal() && !$constantReflection->hasPhpDocType() && !$constantReflection->hasNativeType()) { + $constantTypes[] = new MixedType(); + continue; + } + $constantTypes[] = $constantReflection->getValueType(); + continue; + } + $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); if (!$this->getReflectionProvider()->hasClass($declaringClassName)) { continue; @@ -1263,6 +1296,10 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc return new EnumCaseObjectType($classReflection->getName(), $constantName); } + if ($isStatic) { + return new ClassConstantAccessType(new StaticType($classReflection), $constantName); + } + $reflectionConstant = $classReflection->getNativeReflection()->getReflectionConstant($constantName); if ($reflectionConstant === false) { return new ErrorType(); diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php new file mode 100644 index 0000000000..583e3a9e21 --- /dev/null +++ b/src/Type/ClassConstantAccessType.php @@ -0,0 +1,95 @@ +type->getReferencedClasses(); + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->type->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->type->equals($type->type) + && $this->constantName === $type->constantName; + } + + public function describe(VerbosityLevel $level): string + { + return $this->resolve()->describe($level); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->type); + } + + protected function getResult(): Type + { + if ($this->type->hasConstant($this->constantName)->yes()) { + return $this->type->getConstant($this->constantName)->getValueType(); + } + + return new ErrorType(); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $type = $cb($this->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type, $this->constantName); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $type = $cb($this->type, $right->type); + + if ($this->type === $type) { + return $this; + } + + return new self($type, $this->constantName); + } + + public function toPhpDocNode(): TypeNode + { + return new ConstTypeNode(new ConstFetchNode('static', $this->constantName)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13828.php b/tests/PHPStan/Analyser/nsrt/bug-13828.php new file mode 100644 index 0000000000..2b7e7375ab --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -0,0 +1,43 @@ +test()); + assertType("'bar'", $bar->test()); +} + +final class FinalFoo +{ + const FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +function testFinal(FinalFoo $foo): void +{ + assertType("'foo'", $foo->test()); +}