From 13ff9e27fe9b307bd53f0cd69f6172ba3d31e277 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 16:24:40 +0200 Subject: [PATCH 1/4] Fix phpstan/phpstan#14501: flaky `mixed` in `@extends>` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolving a class-constant fetch called `ClassReflection::getConstant()` solely to read `getPhpDocType()`. That pulls in the ancestor walk (`getAncestors()` → `getParentClass()` → `getFirstExtendsTag()`), which eagerly resolves `@extends` type nodes. When `@extends` contains `value-of`, the resolution re-enters constant resolution, hits the recursion guard, and the `MixedType` it returns gets baked into the cached `ResolvedPhpDocBlock::$extendsTags`. Introduce `ClassReflection::getConstantPhpDocType()`, which reads just the constant's own `@var` tag without walking ancestors or applying PHPDoc inheritance. Use it from both `getClassConstFetchTypeByReflection` call sites. Extract `resolveConstantVarPhpDocType()` to share the @var tag extraction with `getConstant()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Reflection/ClassReflection.php | 92 ++++++++++++++++--- .../InitializerExprTypeResolver.php | 5 +- .../Analyser/AnalyserIntegrationTest.php | 6 ++ tests/PHPStan/Analyser/data/bug-14501.php | 36 ++++++++ 4 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/bug-14501.php diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 3df31e38aff..f4d24fa025f 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1311,18 +1311,7 @@ public function getConstant(string $name): ClassConstantReflection } $isInternal = $resolvedPhpDoc->isInternal(); $isFinal = $resolvedPhpDoc->isFinal(); - $varTags = $resolvedPhpDoc->getVarTags(); - if (isset($varTags[0]) && count($varTags) === 1) { - $varTag = $varTags[0]; - if ($varTag->isExplicit() || $nativeType === null || $nativeType->isSuperTypeOf($varTag->getType())->yes()) { - $phpDocType = TemplateTypeHelper::resolveTemplateTypes( - $varTag->getType(), - $declaringClass->getActiveTemplateTypeMap(), - $declaringClass->getCallSiteVarianceMap(), - TemplateTypeVariance::createInvariant(), - ); - } - } + $phpDocType = self::resolveConstantVarPhpDocType($resolvedPhpDoc, $nativeType, $declaringClass); } $this->constants[$name] = new RealClassClassConstantReflection( @@ -1342,6 +1331,85 @@ public function getConstant(string $name): ClassConstantReflection return $this->constants[$name]; } + /** + * Like getConstant() but only resolves the @var type — skips the ancestor + * walk that can recurse through @extends back into constant resolution. + * + * @internal + */ + public function getConstantPhpDocType(string $name): ?Type + { + $reflectionConstant = $this->getNativeReflection()->getReflectionConstant($name); + if ($reflectionConstant === false) { + return null; + } + + $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); + + $resolvedPhpDoc = $this->stubPhpDocProvider->findClassConstantPhpDoc( + $declaringClassName, + $name, + ); + + if ($resolvedPhpDoc === null) { + $docComment = $reflectionConstant->getDocComment(); + if ($docComment === false) { + return null; + } + $fileName = $reflectionConstant->getDeclaringClass()->getFileName(); + if ($fileName === false) { + return null; + } + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $fileName, + $declaringClassName, + null, + null, + $docComment, + ); + } + + $nativeType = null; + if ($reflectionConstant->getType() !== null) { + $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType()); + } + + if ($declaringClassName === $this->getName()) { + $declaringClass = $this; + } else { + $declaringClass = $this->getAncestorWithClassName($declaringClassName); + if ($declaringClass === null) { + return null; + } + } + + return self::resolveConstantVarPhpDocType($resolvedPhpDoc, $nativeType, $declaringClass); + } + + private static function resolveConstantVarPhpDocType( + ResolvedPhpDocBlock $resolvedPhpDoc, + ?Type $nativeType, + self $declaringClass, + ): ?Type + { + $varTags = $resolvedPhpDoc->getVarTags(); + if (!isset($varTags[0]) || count($varTags) !== 1) { + return null; + } + + $varTag = $varTags[0]; + if (!$varTag->isExplicit() && $nativeType !== null && !$nativeType->isSuperTypeOf($varTag->getType())->yes()) { + return null; + } + + return TemplateTypeHelper::resolveTemplateTypes( + $varTag->getType(), + $declaringClass->getActiveTemplateTypeMap(), + $declaringClass->getCallSiteVarianceMap(), + TemplateTypeVariance::createInvariant(), + ); + } + public function hasTraitUse(string $traitName): bool { return in_array($traitName, $this->getTraitNames(), true); diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index de9649776c0..8a9fa17b3aa 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -2544,13 +2544,12 @@ function (Type $type, callable $traverse): Type { if ($reflectionConstant->getType() !== null) { $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType(), selfClass: $constantClassReflection); } - $phpDocType = $constantClassReflection->getConstant($constantName)->getPhpDocType(); $types[] = $this->constantResolver->resolveClassConstantType( $constantClassReflection->getName(), $constantName, $constantType, $nativeType, - $phpDocType, + $constantClassReflection->getConstantPhpDocType($constantName), ); unset($this->currentlyResolvingClassConstant[$resolvingName]); continue; @@ -2579,7 +2578,7 @@ function (Type $type, callable $traverse): Type { $constantName, $constantType, $nativeType, - $constantReflection->getPhpDocType(), + $constantClassReflection->getConstantPhpDocType($constantName), ); unset($this->currentlyResolvingClassConstant[$resolvingName]); $types[] = $constantType; diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 9949f3f9a25..986c2ae18f1 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1536,6 +1536,12 @@ public function testBugInfiniteLoopOnFileTypeMapper(): void $this->assertCount(0, $errors); } + public function testBug14501(): void + { + $errors = $this->runAnalyse(__DIR__ . '/data/bug-14501.php'); + $this->assertNoErrors($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/data/bug-14501.php b/tests/PHPStan/Analyser/data/bug-14501.php new file mode 100644 index 00000000000..b9aaa50c2eb --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14501.php @@ -0,0 +1,36 @@ +> */ +final class Bug14501Sub extends Bug14501Base +{ + public const string ATTR_A = 'A'; + public const string ATTR_B = 'B'; + + public const array ATTRIBUTES = [ + self::ATTR_A, + self::ATTR_B, + ]; + + public function check(string $attribute): bool + { + return $attribute === self::ATTR_A; + } +} From 0e09c5ce311e7f194f90f77dd268aa186759e66f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 16:29:12 +0200 Subject: [PATCH 2/4] Require PHP >= 8.3 for testBug14501 The fixture uses typed class constants (`const string`, `const array`), which are a PHP 8.3 feature. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/PHPStan/Analyser/AnalyserIntegrationTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 986c2ae18f1..6826995d4db 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1536,6 +1536,7 @@ public function testBugInfiniteLoopOnFileTypeMapper(): void $this->assertCount(0, $errors); } + #[RequiresPhp('>= 8.3.0')] public function testBug14501(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-14501.php'); From fff7d8c155bf8fc701867b8b60ca98085aa676f4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 16:30:39 +0200 Subject: [PATCH 3/4] Rewrite getConstantPhpDocType() docblock Describe what it does, not what it resembles. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Reflection/ClassReflection.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index f4d24fa025f..d6d7b2abb2f 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1332,8 +1332,9 @@ public function getConstant(string $name): ClassConstantReflection } /** - * Like getConstant() but only resolves the @var type — skips the ancestor - * walk that can recurse through @extends back into constant resolution. + * Returns the @var PHPDoc type of a class constant, if any. + * Does not walk ancestors, so it's safe to call from class-constant + * type resolution (which re-enters via @extends>). * * @internal */ From 0a056e63aba43449bc83aee2f1c7c15d595ae22b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 21 Apr 2026 16:34:50 +0200 Subject: [PATCH 4/4] Extract findConstantResolvedPhpDoc() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deduplicate the "stub → docComment → fileTypeMapper" fetch shared by getConstant() and getConstantPhpDocType(). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Reflection/ClassReflection.php | 69 ++++++++++++++---------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index d6d7b2abb2f..229df8daee6 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -10,6 +10,7 @@ use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\OutOfClassScope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; +use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClassConstant; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnumBackedCase; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod; @@ -1272,22 +1273,7 @@ public function getConstant(string $name): ClassConstantReflection } $fileName = $declaringClass->getFileName(); $phpDocType = null; - $currentResolvedPhpDoc = $this->stubPhpDocProvider->findClassConstantPhpDoc( - $declaringClass->getName(), - $name, - ); - if ($currentResolvedPhpDoc === null) { - if ($reflectionConstant->getDocComment() !== false) { - $currentResolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( - $fileName, - $declaringClass->getName(), - null, - null, - $reflectionConstant->getDocComment(), - ); - } - - } + $currentResolvedPhpDoc = $this->findConstantResolvedPhpDoc($reflectionConstant); $nativeType = null; if ($reflectionConstant->getType() !== null) { @@ -1345,29 +1331,9 @@ public function getConstantPhpDocType(string $name): ?Type return null; } - $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); - - $resolvedPhpDoc = $this->stubPhpDocProvider->findClassConstantPhpDoc( - $declaringClassName, - $name, - ); - + $resolvedPhpDoc = $this->findConstantResolvedPhpDoc($reflectionConstant); if ($resolvedPhpDoc === null) { - $docComment = $reflectionConstant->getDocComment(); - if ($docComment === false) { - return null; - } - $fileName = $reflectionConstant->getDeclaringClass()->getFileName(); - if ($fileName === false) { - return null; - } - $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( - $fileName, - $declaringClassName, - null, - null, - $docComment, - ); + return null; } $nativeType = null; @@ -1375,6 +1341,7 @@ public function getConstantPhpDocType(string $name): ?Type $nativeType = TypehintHelper::decideTypeFromReflection($reflectionConstant->getType()); } + $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); if ($declaringClassName === $this->getName()) { $declaringClass = $this; } else { @@ -1387,6 +1354,32 @@ public function getConstantPhpDocType(string $name): ?Type return self::resolveConstantVarPhpDocType($resolvedPhpDoc, $nativeType, $declaringClass); } + private function findConstantResolvedPhpDoc(ReflectionClassConstant $reflectionConstant): ?ResolvedPhpDocBlock + { + $declaringClassName = $reflectionConstant->getDeclaringClass()->getName(); + + $resolvedPhpDoc = $this->stubPhpDocProvider->findClassConstantPhpDoc( + $declaringClassName, + $reflectionConstant->getName(), + ); + if ($resolvedPhpDoc !== null) { + return $resolvedPhpDoc; + } + + $docComment = $reflectionConstant->getDocComment(); + if ($docComment === false) { + return null; + } + + return $this->fileTypeMapper->getResolvedPhpDoc( + $reflectionConstant->getDeclaringClass()->getFileName() ?: null, + $declaringClassName, + null, + null, + $docComment, + ); + } + private static function resolveConstantVarPhpDocType( ResolvedPhpDocBlock $resolvedPhpDoc, ?Type $nativeType,