From bc654da42534b610308b3b9c3f10b4e2509bd73c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:12:25 +0000 Subject: [PATCH] Fix phpstan/phpstan#13828: static::CONST in PHPDoc now uses late static binding - Added ClassConstantAccessType as a LateResolvableType that wraps a class type and constant name - Modified TypeNodeResolver::resolveConstTypeNode to return ClassConstantAccessType for static::CONST - When the type gets resolved (e.g., via transformStaticType), StaticType is replaced with the actual class, and the constant is looked up from the correct subclass - New regression test in tests/PHPStan/Analyser/nsrt/bug-13828.php --- src/PhpDoc/TypeNodeResolver.php | 14 +++ src/Type/ClassConstantAccessType.php | 112 ++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13828.php | 34 +++++++ 3 files changed, 160 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 8af74bf9264..4a14a7cdb4c 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; @@ -1188,9 +1189,13 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc throw new ShouldNotHappenException(); // global constant should get parsed as class name in IdentifierTypeNode } + $isStaticConst = false; if ($nameScope->getClassName() !== null) { switch (strtolower($constExpr->className)) { case 'static': + $className = $nameScope->getClassName(); + $isStaticConst = true; + break; case 'self': $className = $nameScope->getClassName(); break; @@ -1220,6 +1225,15 @@ private function resolveConstTypeNode(ConstTypeNode $typeNode, NameScope $nameSc $classReflection = $this->getReflectionProvider()->getClass($className); $constantName = $constExpr->name; + + if ($isStaticConst && !$classReflection->isFinal() && !Strings::contains($constantName, '*')) { + if (!$classReflection->hasConstant($constantName)) { + return new ErrorType(); + } + + return new ClassConstantAccessType(new StaticType($classReflection), $constantName); + } + if (Strings::contains($constantName, '*')) { // convert * into .*? and escape everything else so the constants can be matched against the pattern $pattern = '{^' . str_replace('\\*', '.*?', preg_quote($constantName)) . '$}D'; diff --git a/src/Type/ClassConstantAccessType.php b/src/Type/ClassConstantAccessType.php new file mode 100644 index 00000000000..71cd39eb14f --- /dev/null +++ b/src/Type/ClassConstantAccessType.php @@ -0,0 +1,112 @@ +classType->getReferencedClasses(); + } + + public function getObjectClassNames(): array + { + return []; + } + + public function getObjectClassReflections(): array + { + return []; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + return $this->classType->getReferencedTemplateTypes($positionVariance); + } + + public function equals(Type $type): bool + { + return $type instanceof self + && $this->classType->equals($type->classType) + && $this->constantName === $type->constantName; + } + + public function describe(VerbosityLevel $level): string + { + return sprintf('%s::%s', $this->classType->describe($level), $this->constantName); + } + + public function isResolvable(): bool + { + return !TypeUtils::containsTemplateType($this->classType) + && !($this->classType instanceof StaticType); + } + + protected function getResult(): Type + { + if ($this->classType->hasConstant($this->constantName)->yes()) { + return $this->classType->getConstant($this->constantName)->getValueType(); + } + + return new ErrorType(); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $classType = $cb($this->classType); + + if ($this->classType === $classType) { + return $this; + } + + return new self($classType, $this->constantName); + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $classType = $cb($this->classType, $right->classType); + + if ($this->classType === $classType) { + return $this; + } + + return new self($classType, $this->constantName); + } + + public function toPhpDocNode(): TypeNode + { + return new ConstTypeNode( + new ConstFetchNode( + $this->classType->describe(VerbosityLevel::typeOnly()), + $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 00000000000..5b079fa0431 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13828.php @@ -0,0 +1,34 @@ +test()); + assertType("'bar'", (new Bar())->test()); +}