From 075bda9052639f4f5dbc37720544126e581a8762 Mon Sep 17 00:00:00 2001 From: "erpeldinger.g" <60388866+GErpeldinger@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:36 +0200 Subject: [PATCH 1/2] Do not cache isGeneric() or the class PHPDoc while the class name scope is created A template bound that names the class itself (@template T of Error|ErrorIterator) leads FileTypeMapper::getNameScope() back to ClassReflection::isGeneric() for the same class, through TypeCombinator::union() and ObjectType::getEnumCases(). The template tags are not resolved yet at that moment, so isGeneric() cached false, and getResolvedPhpDoc() cached a PHPDoc without template tags. Both stayed wrong for the rest of the process. FileTypeMapper now tracks the classes whose name scope is being created, and ClassReflection does not cache these two values while its class is in that set. The same change is made in turbo-ext/src/ClassReflection.cpp. --- src/Reflection/ClassReflection.php | 14 +++++- src/Type/FileTypeMapper.php | 19 ++++++++ .../Analyser/AnalyserIntegrationTest.php | 12 ++--- .../WrongVariableNameInVarTagRuleTest.php | 12 +++++ .../data/bug-15448-dependency-usage.php | 15 +++++++ .../PhpDoc/data/bug-15448-dependency.php | 45 +++++++++++++++++++ tests/PHPStan/Rules/PhpDoc/data/bug-15448.php | 26 +++++++++++ turbo-ext/src/ClassReflection.cpp | 23 +++++++++- 8 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-15448-dependency-usage.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-15448-dependency.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/bug-15448.php diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 064cec277ec..dc0f262cac8 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1909,7 +1909,12 @@ public function isGeneric(): bool return $this->isGeneric = false; } - $this->isGeneric = count($this->getTemplateTags()) > 0; + $isGeneric = count($this->getTemplateTags()) > 0; + if ($this->fileTypeMapper->isClassNameScopeBeingCreated($this->getName())) { + return $isGeneric; + } + + $this->isGeneric = $isGeneric; } return $this->isGeneric; @@ -2145,7 +2150,12 @@ public function getResolvedPhpDoc(): ?ResolvedPhpDocBlock return $this->resolvedPhpDocBlock; } - return $this->resolvedPhpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc($fileName, $this->getName(), null, null, $this->reflectionDocComment); + $resolvedPhpDocBlock = $this->fileTypeMapper->getResolvedPhpDoc($fileName, $this->getName(), null, null, $this->reflectionDocComment); + if ($this->fileTypeMapper->isClassNameScopeBeingCreated($this->getName())) { + return $resolvedPhpDocBlock; + } + + return $this->resolvedPhpDocBlock = $resolvedPhpDocBlock; } public function getTraitContextResolvedPhpDoc(self $implementingClass): ?ResolvedPhpDocBlock diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index 673a2bb89ba..39c8bbbf315 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -68,6 +68,9 @@ final class FileTypeMapper /** @var array */ private array $inProcessNameScopes = []; + /** @var array */ + private array $classNameScopesBeingCreated = []; + /** @var array */ private array $resolvedPhpDocBlockCache = []; @@ -151,6 +154,15 @@ public function getResolvedPhpDoc( ); } + /** + * While a class's name scope is being created, the PHPDoc resolved for that class + * does not have its template tags yet. + */ + public function isClassNameScopeBeingCreated(string $className): bool + { + return isset($this->classNameScopesBeingCreated[$className]); + } + private function createResolvedPhpDocBlock( PhpDocNode $phpDocNode, NameScope $nameScope, @@ -240,6 +252,10 @@ public function getNameScope( $intermediaryNameScope = $nameScopeMap[$nameScopeKey]; $this->inProcess[$nameScopeKey] = true; + $classNameScope = $className !== null && $traitName === null && $functionName === null ? $className : null; + if ($classNameScope !== null) { + $this->classNameScopesBeingCreated[$classNameScope] = ($this->classNameScopesBeingCreated[$classNameScope] ?? 0) + 1; + } try { $parents = [$intermediaryNameScope]; @@ -351,6 +367,9 @@ public function getNameScope( } finally { unset($this->inProcess[$nameScopeKey]); unset($this->inProcessNameScopes[$nameScopeKey]); + if ($classNameScope !== null && --$this->classNameScopesBeingCreated[$classNameScope] === 0) { + unset($this->classNameScopesBeingCreated[$classNameScope]); + } } } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index eb6149da32e..eb674dfe08a 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1392,15 +1392,17 @@ public function testBug10049(): void { // crash $errors = $this->runAnalyse(__DIR__ . '/data/bug-10049-recursive.php'); - $this->assertCount(4, $errors); + $this->assertCount(5, $errors); $this->assertSame('PHPDoc tag @template for class Bug10049Recursive\SimpleEntity cannot have existing class Bug10049Recursive\SimpleEntity as its name.', $errors[0]->getMessage()); $this->assertSame(8, $errors[0]->getLine()); $this->assertSame('Type Bug10049Recursive\SimpleEntity in generic type Bug10049Recursive\SimpleEntity in PHPDoc tag @template Bug10049Recursive\SimpleEntity is not subtype of template type SELF of Bug10049Recursive\SimpleEntity of class Bug10049Recursive\SimpleEntity.', $errors[1]->getMessage()); $this->assertSame(8, $errors[1]->getLine()); - $this->assertSame('Type Bug10049Recursive\TestEntity in generic type Bug10049Recursive\SimpleEntity in PHPDoc tag @extends is not subtype of template type SELF of Bug10049Recursive\SimpleEntity of class Bug10049Recursive\SimpleEntity.', $errors[2]->getMessage()); - $this->assertSame(46, $errors[2]->getLine()); - $this->assertSame('Type Bug10049Recursive\AnotherEntity in generic type Bug10049Recursive\SimpleEntity in PHPDoc tag @extends is not subtype of template type SELF of Bug10049Recursive\SimpleEntity of class Bug10049Recursive\SimpleEntity.', $errors[3]->getMessage()); - $this->assertSame(59, $errors[3]->getLine()); + $this->assertSame('Method Bug10049Recursive\SimpleEntity::__construct() has parameter $table with generic class Bug10049Recursive\SimpleEntity but does not specify its types: SELF', $errors[2]->getMessage()); + $this->assertSame(13, $errors[2]->getLine()); + $this->assertSame('Type Bug10049Recursive\TestEntity in generic type Bug10049Recursive\SimpleEntity in PHPDoc tag @extends is not subtype of template type SELF of Bug10049Recursive\SimpleEntity of class Bug10049Recursive\SimpleEntity.', $errors[3]->getMessage()); + $this->assertSame(46, $errors[3]->getLine()); + $this->assertSame('Type Bug10049Recursive\AnotherEntity in generic type Bug10049Recursive\SimpleEntity in PHPDoc tag @extends is not subtype of template type SELF of Bug10049Recursive\SimpleEntity of class Bug10049Recursive\SimpleEntity.', $errors[4]->getMessage()); + $this->assertSame(59, $errors[4]->getLine()); } #[RequiresPhp('>= 8.0.0')] diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index ed6e20e2c2d..aa3c3843f0e 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -653,4 +653,16 @@ public function testUnconstrainedQueryResult(): void $this->analyse([__DIR__ . '/../Methods/data/unconstrained-query-result.php'], []); } + public function testBug15448(): void + { + $this->checkTypeAgainstPhpDocType = true; + $this->analyse([__DIR__ . '/data/bug-15448.php'], []); + } + + public function testBug15448ClassNotAnalysed(): void + { + $this->checkTypeAgainstPhpDocType = true; + $this->analyse([__DIR__ . '/data/bug-15448-dependency-usage.php'], []); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-15448-dependency-usage.php b/tests/PHPStan/Rules/PhpDoc/data/bug-15448-dependency-usage.php new file mode 100644 index 00000000000..ae0388d0e5d --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-15448-dependency-usage.php @@ -0,0 +1,15 @@ += 8.0 + +namespace Bug15448Dependency; + +class Error +{ +} + +/** + * @template T of Error|ErrorIterator + * @implements \Iterator + */ +class ErrorIterator implements \Iterator +{ + + /** @param list $errors */ + public function __construct(private array $errors) + { + } + + /** @return T */ + public function current(): Error|self + { + return $this->errors[0]; + } + + public function next(): void + { + } + + public function key(): int + { + return 0; + } + + public function valid(): bool + { + return false; + } + + public function rewind(): void + { + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/bug-15448.php b/tests/PHPStan/Rules/PhpDoc/data/bug-15448.php new file mode 100644 index 00000000000..c59dc4decfa --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/bug-15448.php @@ -0,0 +1,26 @@ += 8.0 + +namespace Bug15448; + +class Error +{ +} + +/** + * @template T of Error|ErrorIterator + * @implements \Iterator + */ +abstract class ErrorIterator implements \Iterator +{ + + /** @return T */ + abstract public function current(): Error|self; + +} + +function errors(ErrorIterator $errors): void +{ + /** @var Error $error */ + foreach ($errors as $error) { + } +} diff --git a/turbo-ext/src/ClassReflection.cpp b/turbo-ext/src/ClassReflection.cpp index 34c5e3297d7..cfe53ba5343 100644 --- a/turbo-ext/src/ClassReflection.cpp +++ b/turbo-ext/src/ClassReflection.cpp @@ -3985,11 +3985,25 @@ class ClassReflection zv::Args args{fileName.raw(), name.raw(), zv::null, zv::null, slot(PT_CR_PROP_REFLECTION_DOC_COMMENT).raw()}; zv::Val resolved = callService(PT_CR_PROP_FILE_TYPE_MAPPER, "fileTypeMapper", PT_LC("getresolvedphpdoc"), 5, args); if (UNEXPECTED(resolved.isUndef())) return zv::Val(); + bool beingCreated; + if (UNEXPECTED(!isClassNameScopeBeingCreated(beingCreated))) return zv::Val(); + if (beingCreated) return resolved; writeSlot(PT_CR_PROP_RESOLVED_PHP_DOC_BLOCK, zv::Val::copyOf(resolved.ref())); return resolved; } + /* $this->fileTypeMapper->isClassNameScopeBeingCreated($this->getName()); false = pending exception */ + bool isClassNameScopeBeingCreated(bool &out) + { + zv::Val name = getName(); + if (UNEXPECTED(name.isUndef())) return false; + zv::Val result = callService(PT_CR_PROP_FILE_TYPE_MAPPER, "fileTypeMapper", PT_LC("isclassnamescopebeingcreated"), 1, name.raw()); + if (UNEXPECTED(result.isUndef())) return false; + out = result.ref().isTrue(); + return true; + } + /* ?ResolvedPhpDocBlock */ zv::Val getTraitContextResolvedPhpDoc(zv::Ref implementingClass) { @@ -4318,7 +4332,14 @@ class ClassReflection zv::Val templateTags = getTemplateTags(); if (UNEXPECTED(templateTags.isUndef())) return false; - writeSlot(PT_CR_PROP_IS_GENERIC, zv::Val::boolean(countOf(templateTags.ref()) > 0)); + bool isGeneric_ = countOf(templateTags.ref()) > 0; + bool beingCreated; + if (UNEXPECTED(!isClassNameScopeBeingCreated(beingCreated))) return false; + if (beingCreated) { + out = isGeneric_; + return true; + } + writeSlot(PT_CR_PROP_IS_GENERIC, zv::Val::boolean(isGeneric_)); } out = slot(PT_CR_PROP_IS_GENERIC).isTrue(); From 59997a6108d3684b8e832d33e9fce884c62c6aa1 Mon Sep 17 00:00:00 2001 From: "erpeldinger.g" <60388866+GErpeldinger@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:14:37 +0200 Subject: [PATCH 2/2] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 88c83cac563..f636fa80fce 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '28c29b8'; + public const EXPECTED_EXTENSION_VERSION = '075bda9'; private static bool $active = false;