Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ This affects match expression exhaustiveness: `class-string<FinalA|FinalB>` 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:
Expand Down
37 changes: 37 additions & 0 deletions src/PhpDoc/TypeNodeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
95 changes: 95 additions & 0 deletions src/Type/ClassConstantAccessType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type;

use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode;
use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use PHPStan\Type\Generic\TemplateTypeVariance;
use PHPStan\Type\Traits\LateResolvableTypeTrait;
use PHPStan\Type\Traits\NonGeneralizableTypeTrait;

final class ClassConstantAccessType implements CompoundType, LateResolvableType
{

use LateResolvableTypeTrait;
use NonGeneralizableTypeTrait;

public function __construct(
private Type $type,
private string $constantName,
)
{
}

public function getReferencedClasses(): array
{
return $this->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()) {

Check warning on line 54 in src/Type/ClassConstantAccessType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ protected function getResult(): Type { - if ($this->type->hasConstant($this->constantName)->yes()) { + if (!$this->type->hasConstant($this->constantName)->no()) { return $this->type->getConstant($this->constantName)->getValueType(); }

Check warning on line 54 in src/Type/ClassConstantAccessType.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ protected function getResult(): Type { - if ($this->type->hasConstant($this->constantName)->yes()) { + if (!$this->type->hasConstant($this->constantName)->no()) { return $this->type->getConstant($this->constantName)->getValueType(); }
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));
}

}
43 changes: 43 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13828.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace Bug13828;

use function PHPStan\Testing\assertType;

class FooBar
{
const FOO_BAR = 'foo';

/** @return static::FOO_BAR */
public function test(): string
{
return static::FOO_BAR;
}
}

class BarBaz extends FooBar
{
const FOO_BAR = 'bar';
}

function test(FooBar $foo, BarBaz $bar): void
{
assertType("'foo'", $foo->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());
}
Loading