From ea99ca4ce2aacedd19e9f86f036cd25b507c3c33 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:14:44 +0600 Subject: [PATCH] Resolve generic static<...> property types against the fetched-on class Closes https://github.com/phpstan/phpstan/issues/15378 --- ...eUnresolvedPropertyPrototypeReflection.php | 23 +++++++ tests/PHPStan/Analyser/nsrt/bug-15378.php | 45 +++++++++++++ ...eUnresolvedPropertyPrototypeReflection.cpp | 65 ++++++++++++++++++- 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15378.php diff --git a/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php b/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php index 4886f801ee1..853c19d4e6d 100644 --- a/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php +++ b/src/Reflection/Type/CalledOnTypeUnresolvedPropertyPrototypeReflection.php @@ -7,9 +7,12 @@ use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\ResolvedPropertyReflection; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\Generic\GenericObjectType; +use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use function count; #[ShadowedByTurboExtension(implementation: __DIR__ . '/../../../turbo-ext/src/CalledOnTypeUnresolvedPropertyPrototypeReflection.cpp')] final class CalledOnTypeUnresolvedPropertyPrototypeReflection implements UnresolvedPropertyPrototypeReflection @@ -85,6 +88,26 @@ private function transformPropertyWithStaticType(ClassReflection $declaringClass private function transformStaticType(Type $type): Type { return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { + if ($type instanceof GenericStaticType) { + $fetchedOnTypeReflections = $this->fetchedOnType->getObjectClassReflections(); + if (count($fetchedOnTypeReflections) === 1) { + $staticObjectType = $type->changeBaseClass($fetchedOnTypeReflections[0])->getStaticObjectType(); + if ($staticObjectType instanceof GenericObjectType) { + // do not carry over the class reflection of the declaring class, + // it would not know the type arguments of the resolved type + $staticObjectType = new GenericObjectType( + $staticObjectType->getClassName(), + $staticObjectType->getTypes(), + $staticObjectType->getSubtractedType(), + variances: $staticObjectType->getVariances(), + ); + } + + return $traverse($staticObjectType); + } + + return $this->fetchedOnType; + } if ($type instanceof StaticType) { return $this->fetchedOnType; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15378.php b/tests/PHPStan/Analyser/nsrt/bug-15378.php new file mode 100644 index 00000000000..4b3b6d7d83d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15378.php @@ -0,0 +1,45 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15378; + +use function PHPStan\Testing\assertType; + +/** + * @template TNode of object + */ +class NodeCursor { + /** + * @var static + */ + public ?self $a = null; + + /** + * @var static + */ + public ?self $b = null; + + final public function __construct( + /** + * @var TNode + */ + public readonly object $node, + ) { + // empty + } +} + +class ANode { + +} + +class BNode { + +} + +$node = new BNode(); +$cursor = new NodeCursor($node); + +assertType('Bug15378\NodeCursor|null', $cursor->a); +assertType('Bug15378\NodeCursor|null', $cursor->b); diff --git a/turbo-ext/src/CalledOnTypeUnresolvedPropertyPrototypeReflection.cpp b/turbo-ext/src/CalledOnTypeUnresolvedPropertyPrototypeReflection.cpp index ba343e353c6..4d23e72702f 100644 --- a/turbo-ext/src/CalledOnTypeUnresolvedPropertyPrototypeReflection.cpp +++ b/turbo-ext/src/CalledOnTypeUnresolvedPropertyPrototypeReflection.cpp @@ -144,8 +144,10 @@ class CalledOnTypeUnresolvedPropertyPrototypeReflection } /* the closure of transformStaticType(): `function (Type $type, callable - * $traverse): Type` — a StaticType becomes the fetched-on type, - * everything else traverses */ + * $traverse): Type` — a GenericStaticType becomes the static object + * type of its base class changed to the single fetched-on class + * (traversed on), or the fetched-on type itself; any other StaticType + * the fetched-on type; everything else traverses */ static void traverse(zval *thisZv, zval *state1, uint32_t argc, zval *argv, zval *return_value) { (void) state1; @@ -158,6 +160,65 @@ class CalledOnTypeUnresolvedPropertyPrototypeReflection return; } zval *type = &argv[0]; + bool isGenericStatic; + pt_type_instanceof_ce(type, pt_ce_generic_static_type, isGenericStatic); + if (isGenericStatic) { + zval *fetchedOnType = CalledOnTypeUnresolvedPropertyPrototypeReflection(Z_OBJ_P(thisZv)).slot(slots::fetchedOnType, "fetchedOnType"); + if (UNEXPECTED(fetchedOnType == NULL)) return; + /* $fetchedOnTypeReflections = $this->fetchedOnType->getObjectClassReflections(); */ + zv::Val reflections = pt_type_call(Z_OBJ_P(fetchedOnType), PT_LC("getobjectclassreflections"), 0, NULL); + if (UNEXPECTED(reflections.isUndef())) return; + if (UNEXPECTED(!zv::Ref(reflections.raw()).isArray())) { + zend_type_error("phpstan_turbo: getObjectClassReflections() must return an array"); + return; + } + if (zend_hash_num_elements(Z_ARRVAL_P(reflections.raw())) == 1) { + /* $type->changeBaseClass($fetchedOnTypeReflections[0])->getStaticObjectType() */ + zval *first = zend_hash_index_find(Z_ARRVAL_P(reflections.raw()), 0); + if (UNEXPECTED(first == NULL)) { + zend_throw_error(NULL, "phpstan_turbo: getObjectClassReflections() must return a list"); + return; + } + ZVAL_DEREF(first); + zv::Val changed = pt_type_call(Z_OBJ_P(type), PT_LC("changebaseclass"), 1, first); + if (UNEXPECTED(changed.isUndef())) return; + if (UNEXPECTED(!zv::Ref(changed.raw()).isObject())) { + zend_type_error("phpstan_turbo: changeBaseClass() must return an object"); + return; + } + zv::Val staticObjectType = pt_type_call(Z_OBJ_P(changed.raw()), PT_LC("getstaticobjecttype"), 0, NULL); + if (UNEXPECTED(staticObjectType.isUndef())) return; + /* a GenericObjectType is rebuilt without the class reflection of the + * declaring class, which would not know the resolved type arguments: + * new GenericObjectType($t->getClassName(), $t->getTypes(), $t->getSubtractedType(), variances: $t->getVariances()) */ + bool isGenericObject; + pt_type_instanceof_ce(staticObjectType.raw(), pt_ce_generic_object_type, isGenericObject); + if (isGenericObject) { + zend_object *objectType = Z_OBJ_P(staticObjectType.raw()); + zv::Val className = pt_type_call(objectType, PT_LC("getclassname"), 0, NULL); + if (UNEXPECTED(className.isUndef())) return; + zv::Val objectTypes = pt_type_call(objectType, PT_LC("gettypes"), 0, NULL); + if (UNEXPECTED(objectTypes.isUndef())) return; + zv::Val subtracted = pt_type_call(objectType, PT_LC("getsubtractedtype"), 0, NULL); + if (UNEXPECTED(subtracted.isUndef())) return; + zv::Val objectVariances = pt_type_call(objectType, PT_LC("getvariances"), 0, NULL); + if (UNEXPECTED(objectVariances.isUndef())) return; + if (UNEXPECTED(Z_TYPE_P(className.raw()) != IS_STRING)) { + zend_type_error("phpstan_turbo: getClassName() must return a string"); + return; + } + zval rebuiltRaw; + if (UNEXPECTED(!pt_generic_object_type_new(&rebuiltRaw, Z_STR_P(className.raw()), objectTypes.raw(), subtracted.raw(), NULL, objectVariances.raw()))) return; + staticObjectType = zv::Val::adopt(rebuiltRaw); + } + zv::Val traversed = pt_type_call_callable(&argv[1], 1, staticObjectType.raw()); + if (UNEXPECTED(traversed.isUndef())) return; + traversed.intoReturnValue(return_value); + return; + } + ZVAL_COPY(return_value, fetchedOnType); + return; + } bool isStatic; pt_type_instanceof_ce(type, pt_ce_static_type, isStatic); if (isStatic) {