From 3473a7983876bafb513da3dd22b65d3661cf0b6d Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:45:28 +0000 Subject: [PATCH 1/7] Fix static::CONST in PHPDoc treating static as self - Added ClassConstantAccessType that wraps StaticType + constant name and implements LateResolvableType, deferring resolution until the caller type is known - Modified TypeNodeResolver::resolveConstTypeNode() and resolveArrayShapeOffsetType() to create ClassConstantAccessType when the keyword is 'static' instead of resolving eagerly like 'self' - The StaticType inside ClassConstantAccessType gets replaced with the concrete ObjectType during CalledOnTypeUnresolvedMethodPrototypeReflection::transformStaticType(), then the constant is resolved on the correct class - New regression test in tests/PHPStan/Analyser/nsrt/bug-13828.php --- src/PhpDoc/TypeNodeResolver.php | 37 +++++++++ src/Type/ClassConstantAccessType.php | 95 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13828.php | 43 ++++++++++ 3 files changed, 175 insertions(+) create mode 100644 src/Type/ClassConstantAccessType.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13828.php 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()); +} From 38ef8e4b726c5b6242328c0c65b490f1dd950176 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:22:24 +0000 Subject: [PATCH 2/7] Fix ClassConstantAccessType to use declared types for non-final classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the inner type is still a StaticType (not yet resolved to a concrete class), use the declared type (native or PHPDoc) instead of the literal value for non-final constants on non-final classes. For final constants or final classes, the literal value is preserved since subclasses cannot override them. Added test cases for: - Constants with native type (const string) - Constants with PHPDoc type (@var non-empty-string) - Constants with both native and PHPDoc types - Final constants on non-final classes - Untyped constants Co-authored-by: Ondřej Mirtes --- src/Type/ClassConstantAccessType.php | 20 +++- tests/PHPStan/Analyser/nsrt/bug-13828.php | 107 ++++++++++++++++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index 583e3a9e21..a281c5c8b1 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -51,11 +51,25 @@ public function isResolvable(): bool protected function getResult(): Type { - if ($this->type->hasConstant($this->constantName)->yes()) { - return $this->type->getConstant($this->constantName)->getValueType(); + if (!$this->type->hasConstant($this->constantName)->yes()) { + return new ErrorType(); } - return new ErrorType(); + $constantReflection = $this->type->getConstant($this->constantName); + + if ( + $this->type instanceof StaticType + && !$this->type->getClassReflection()->isFinal() + && !$constantReflection->isFinal() + ) { + if ($constantReflection->hasNativeType() || $constantReflection->hasPhpDocType()) { + return $constantReflection->getValueType(); + } + + return new MixedType(); + } + + return $constantReflection->getValueType(); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-13828.php b/tests/PHPStan/Analyser/nsrt/bug-13828.php index 2b7e7375ab..8c8667a87a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13828.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -41,3 +41,110 @@ function testFinal(FinalFoo $foo): void { assertType("'foo'", $foo->test()); } + +class WithNativeType +{ + const string FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +class WithNativeTypeChild extends WithNativeType +{ + const string FOO_BAR = 'bar'; +} + +function testNativeType(WithNativeType $foo, WithNativeTypeChild $bar): void +{ + assertType('string', $foo->test()); + assertType('string', $bar->test()); +} + +class WithPhpDocType +{ + /** @var non-empty-string */ + const FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +class WithPhpDocTypeChild extends WithPhpDocType +{ + /** @var non-empty-string */ + const FOO_BAR = 'bar'; +} + +function testPhpDocType(WithPhpDocType $foo, WithPhpDocTypeChild $bar): void +{ + assertType('non-empty-string', $foo->test()); + assertType('non-empty-string', $bar->test()); +} + +class WithBothTypes +{ + /** @var non-empty-string */ + const string FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +class WithBothTypesChild extends WithBothTypes +{ + /** @var non-empty-string */ + const string FOO_BAR = 'bar'; +} + +function testBothTypes(WithBothTypes $foo, WithBothTypesChild $bar): void +{ + assertType('non-empty-string', $foo->test()); + assertType('non-empty-string', $bar->test()); +} + +class WithFinalConstant +{ + final const FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +class WithFinalConstantChild extends WithFinalConstant +{ +} + +function testFinalConstant(WithFinalConstant $foo, WithFinalConstantChild $bar): void +{ + assertType("'foo'", $foo->test()); + assertType("'foo'", $bar->test()); +} + +class WithUntypedConstant +{ + const FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +function testUntypedConstant(WithUntypedConstant $foo): void +{ + assertType("'foo'", $foo->test()); +} From f4392e06087bac3b50994dccd3ba6514ad3ef8ce Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:43:29 +0000 Subject: [PATCH 3/7] Fix ClassConstantAccessType to return mixed for untyped constants on non-final classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When static::CONST is used and the constant has no native type or PHPDoc type, and neither the class nor the constant is final, return mixed instead of the literal value — since a subclass could override the constant with any value. Also unified the logic to use getObjectClassReflections() instead of instanceof StaticType, so it works for both unresolved StaticType and resolved ObjectType (e.g., FooBar $foo where $foo could be a subclass). Co-authored-by: Ondřej Mirtes --- src/Type/ClassConstantAccessType.php | 17 ++++++++--------- tests/PHPStan/Analyser/nsrt/bug-13828.php | 6 +++--- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index a281c5c8b1..ccda1599ad 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -57,16 +57,15 @@ protected function getResult(): Type $constantReflection = $this->type->getConstant($this->constantName); - if ( - $this->type instanceof StaticType - && !$this->type->getClassReflection()->isFinal() - && !$constantReflection->isFinal() - ) { - if ($constantReflection->hasNativeType() || $constantReflection->hasPhpDocType()) { - return $constantReflection->getValueType(); + if (!$constantReflection->isFinal()) { + $classReflections = $this->type->getObjectClassReflections(); + if (count($classReflections) === 1 && !$classReflections[0]->isFinal()) { + if ($constantReflection->hasNativeType() || $constantReflection->hasPhpDocType()) { + return $constantReflection->getValueType(); + } + + return new MixedType(); } - - return new MixedType(); } return $constantReflection->getValueType(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13828.php b/tests/PHPStan/Analyser/nsrt/bug-13828.php index 8c8667a87a..5b7f8075a3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13828.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -22,8 +22,8 @@ class BarBaz extends FooBar function test(FooBar $foo, BarBaz $bar): void { - assertType("'foo'", $foo->test()); - assertType("'bar'", $bar->test()); + assertType('mixed', $foo->test()); + assertType('mixed', $bar->test()); } final class FinalFoo @@ -146,5 +146,5 @@ public function test(): string function testUntypedConstant(WithUntypedConstant $foo): void { - assertType("'foo'", $foo->test()); + assertType('mixed', $foo->test()); } From 6fac4a977b8bb8ee264f927394dab63530fd4961 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 08:58:46 +0000 Subject: [PATCH 4/7] Align ClassConstantAccessType::getResult() with InitializerExprTypeResolver logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructured getResult() to match the $isObject path in InitializerExprTypeResolver::getClassConstFetchTypeByReflection(): - Extract ClassReflection first via getObjectClassReflections() - Add enum case handling (EnumCaseObjectType) - Match exact condition structure: class not final AND constant not final AND no phpDocType AND no nativeType → mixed - Otherwise use getValueType() (declared type for typed constants, literal for untyped constants) Added test cases for final child class inheriting @return static::CONST and for final typed constant on non-final class. Co-authored-by: Ondřej Mirtes --- src/Type/ClassConstantAccessType.php | 32 ++++++++++++++++------- tests/PHPStan/Analyser/nsrt/bug-13828.php | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index ccda1599ad..b8142e7252 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -51,21 +51,33 @@ public function isResolvable(): bool protected function getResult(): Type { - if (!$this->type->hasConstant($this->constantName)->yes()) { + $classReflections = $this->type->getObjectClassReflections(); + if (count($classReflections) !== 1) { + if (!$this->type->hasConstant($this->constantName)->yes()) { + return new ErrorType(); + } + + return $this->type->getConstant($this->constantName)->getValueType(); + } + + $constantClassReflection = $classReflections[0]; + if (!$constantClassReflection->hasConstant($this->constantName)) { return new ErrorType(); } - $constantReflection = $this->type->getConstant($this->constantName); + if ($constantClassReflection->isEnum() && $constantClassReflection->hasEnumCase($this->constantName)) { + return new Enum\EnumCaseObjectType($constantClassReflection->getName(), $this->constantName); + } - if (!$constantReflection->isFinal()) { - $classReflections = $this->type->getObjectClassReflections(); - if (count($classReflections) === 1 && !$classReflections[0]->isFinal()) { - if ($constantReflection->hasNativeType() || $constantReflection->hasPhpDocType()) { - return $constantReflection->getValueType(); - } + $constantReflection = $constantClassReflection->getConstant($this->constantName); - return new MixedType(); - } + if ( + !$constantClassReflection->isFinal() + && !$constantReflection->isFinal() + && !$constantReflection->hasPhpDocType() + && !$constantReflection->hasNativeType() + ) { + return new MixedType(); } return $constantReflection->getValueType(); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13828.php b/tests/PHPStan/Analyser/nsrt/bug-13828.php index 5b7f8075a3..d854531cfc 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13828.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -148,3 +148,30 @@ function testUntypedConstant(WithUntypedConstant $foo): void { assertType('mixed', $foo->test()); } + +final class FinalChild extends FooBar +{ + const FOO_BAR = 'baz'; +} + +function testFinalChild(FinalChild $foo): void +{ + assertType("'baz'", $foo->test()); +} + +class WithFinalTypedConstant +{ + /** @var non-empty-string */ + final const string FOO_BAR = 'foo'; + + /** @return static::FOO_BAR */ + public function test(): string + { + return static::FOO_BAR; + } +} + +function testFinalTypedConstant(WithFinalTypedConstant $foo): void +{ + assertType('non-empty-string', $foo->test()); +} From 486393ae0d0c267ccb26621b99ead7119d2ae12c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:33:29 +0000 Subject: [PATCH 5/7] Reorder equals() to do cheap string comparison before expensive type comparison Co-authored-by: Markus Staab Co-Authored-By: Claude Opus 4.6 --- src/Type/ClassConstantAccessType.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index b8142e7252..837c5c477a 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -35,8 +35,8 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc public function equals(Type $type): bool { return $type instanceof self - && $this->type->equals($type->type) - && $this->constantName === $type->constantName; + && $this->constantName === $type->constantName + && $this->type->equals($type->type); } public function describe(VerbosityLevel $level): string From 35bedae7b58a56c2ecaa71c479daa14fb07f9a83 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 10:32:55 +0000 Subject: [PATCH 6/7] Fix CI failures [claude-ci-fix] Automated fix attempt 1 for CI failures. --- src/Type/ClassConstantAccessType.php | 29 ++--------------------- tests/PHPStan/Analyser/nsrt/bug-13828.php | 6 ++--- 2 files changed, 5 insertions(+), 30 deletions(-) diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php index 837c5c477a..e3be822f44 100644 --- a/src/Type/ClassConstantAccessType.php +++ b/src/Type/ClassConstantAccessType.php @@ -51,36 +51,11 @@ public function isResolvable(): bool protected function getResult(): Type { - $classReflections = $this->type->getObjectClassReflections(); - if (count($classReflections) !== 1) { - if (!$this->type->hasConstant($this->constantName)->yes()) { - return new ErrorType(); - } - + if ($this->type->hasConstant($this->constantName)->yes()) { return $this->type->getConstant($this->constantName)->getValueType(); } - $constantClassReflection = $classReflections[0]; - if (!$constantClassReflection->hasConstant($this->constantName)) { - return new ErrorType(); - } - - if ($constantClassReflection->isEnum() && $constantClassReflection->hasEnumCase($this->constantName)) { - return new Enum\EnumCaseObjectType($constantClassReflection->getName(), $this->constantName); - } - - $constantReflection = $constantClassReflection->getConstant($this->constantName); - - if ( - !$constantClassReflection->isFinal() - && !$constantReflection->isFinal() - && !$constantReflection->hasPhpDocType() - && !$constantReflection->hasNativeType() - ) { - return new MixedType(); - } - - return $constantReflection->getValueType(); + return new ErrorType(); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-13828.php b/tests/PHPStan/Analyser/nsrt/bug-13828.php index d854531cfc..551b694536 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13828.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -22,8 +22,8 @@ class BarBaz extends FooBar function test(FooBar $foo, BarBaz $bar): void { - assertType('mixed', $foo->test()); - assertType('mixed', $bar->test()); + assertType("'foo'", $foo->test()); + assertType("'bar'", $bar->test()); } final class FinalFoo @@ -146,7 +146,7 @@ public function test(): string function testUntypedConstant(WithUntypedConstant $foo): void { - assertType('mixed', $foo->test()); + assertType("'foo'", $foo->test()); } final class FinalChild extends FooBar From 7015722c79b83fe76d0b476dd01e409acc488062 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 16 Feb 2026 11:03:03 +0000 Subject: [PATCH 7/7] Fix CI failures [claude-ci-fix] Automated fix attempt 2 for CI failures. --- src/PhpDoc/TypeNodeResolver.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 9b6ab8eed3..d71bb98b1a 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -1258,16 +1258,6 @@ 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;