From 4728624659e38b93c26dc42bbde540dfb2e98dcc Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:23:29 +0000 Subject: [PATCH 1/6] Erase generic type arguments when comparing a `class-string` against a constant class-string - A `class-string` value carries only a class name, never type arguments, so `GenericObjectType::isSuperTypeOf()` returning `Maybe` for an unparameterized class name must not make `'X'` a non-member of `class-string>`. - Added `GenericClassStringType::isValueOfGenericType()`, which strips generic type arguments (via `TypeTraverser`) from the generic type before comparing it against `ObjectType($className)`, and used it from both places that did the constant class-string comparison by hand: `GenericClassStringType::isSuperTypeOf()` and `ConstantStringType::isSuperTypeOf()`. - Subtracted object types keep their precision because erasure only replaces `GenericObjectType` with its unparameterized `ObjectType` counterpart. - Fixes the same family of misses that all funnelled through those two methods: `class-string>` star projections, unions nested inside a single `class-string|Y>`, `===` narrowing (which produced a stray `'X'&class-string>` intersection), `switch`/`default` narrowing, `in_array()` narrowing, `match` exhaustiveness false positives, constant-array offset access keyed by `X::class` (reported "Offset does not exist" and `*ERROR*`), and `TypeCombinator::union()` failing to absorb `'X'` into `class-string>`. - Probed and found already correct: `GenericClassStringType::accepts()` (an accepts context already ignores the type arguments of an unparameterized class name), `is_a()`/`is_subclass_of()` narrowing, `instanceof $class`, and template type inference from `X::class`. --- src/Type/Constant/ConstantStringType.php | 19 +-- src/Type/Generic/GenericClassStringType.php | 56 +++++--- tests/PHPStan/Analyser/nsrt/bug-15266.php | 127 ++++++++++++++++++ .../Comparison/MatchExpressionRuleTest.php | 5 + .../Rules/Comparison/data/bug-15266.php | 42 ++++++ 5 files changed, 216 insertions(+), 33 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15266.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-15266.php diff --git a/src/Type/Constant/ConstantStringType.php b/src/Type/Constant/ConstantStringType.php index c321a84d04c..e928db4c63c 100644 --- a/src/Type/Constant/ConstantStringType.php +++ b/src/Type/Constant/ConstantStringType.php @@ -33,7 +33,6 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; -use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\InstanceofDeprecated; use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntersectionType; @@ -42,7 +41,6 @@ use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; -use PHPStan\Type\StaticType; use PHPStan\Type\StringType; use PHPStan\Type\Traits\ConstantScalarTypeTrait; use PHPStan\Type\Type; @@ -166,24 +164,9 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($genericType instanceof MixedType) { return IsSuperTypeOfResult::createMaybe(); } - if ($genericType instanceof StaticType) { - $genericType = $genericType->getStaticObjectType(); - } - - // We are transforming constant class-string to ObjectType. But we need to filter out - // an uncertainty originating in possible ObjectType's class subtypes. - $objectType = $this->getObjectType(); - - // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType - // uncertainty into account. - if ($genericType instanceof TemplateType) { - $isSuperType = $genericType->getBound()->isSuperTypeOf($objectType); - } else { - $isSuperType = $genericType->isSuperTypeOf($objectType); - } // Explicitly handle the uncertainty for Yes & Maybe. - if ($isSuperType->yes()) { + if (GenericClassStringType::isValueOfGenericType($genericType, $this->value)->yes()) { return IsSuperTypeOfResult::createMaybe(); } return IsSuperTypeOfResult::createNo(); diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 5bc5216e5a8..5809e8d7640 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -23,6 +23,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeTraverser; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use function count; @@ -111,6 +112,45 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $this->type->accepts($objectType, $strictTypes); } + /** + * Whether a class named $className can be the value behind `class-string<$genericType>`. + * + * @internal + */ + public static function isValueOfGenericType(Type $genericType, string $className): IsSuperTypeOfResult + { + if ($genericType instanceof StaticType) { + $genericType = $genericType->getStaticObjectType(); + } + + // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType + // uncertainty into account. + if ($genericType instanceof TemplateType) { + $genericType = $genericType->getBound(); + } + + // We are transforming constant class-string to ObjectType. But we need to filter out + // an uncertainty originating in possible ObjectType's class subtypes. + return self::eraseTypeArguments($genericType)->isSuperTypeOf(new ObjectType($className)); + } + + /** + * A class-string carries a class name and never its type arguments, so the type + * arguments must not take part in the comparison against a constant class-string: + * `X::class` is a value of `class-string>` and of `class-string>` just + * like it is of `class-string`. + */ + private static function eraseTypeArguments(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof GenericObjectType) { + return new ObjectType($type->getClassName(), $type->getSubtractedType()); + } + + return $traverse($type); + }); + } + public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof CompoundType) { @@ -123,21 +163,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult return IsSuperTypeOfResult::createYes(); } - if ($genericType instanceof StaticType) { - $genericType = $genericType->getStaticObjectType(); - } - - // We are transforming constant class-string to ObjectType. But we need to filter out - // an uncertainty originating in possible ObjectType's class subtypes. - $objectType = new ObjectType($type->getValue()); - - // Do not use TemplateType's isSuperTypeOf handling directly because it takes ObjectType - // uncertainty into account. - if ($genericType instanceof TemplateType) { - $isSuperType = $genericType->getBound()->isSuperTypeOf($objectType); - } else { - $isSuperType = $genericType->isSuperTypeOf($objectType); - } + $isSuperType = self::isValueOfGenericType($genericType, $type->getValue()); if (!$type->isClassString()->yes()) { $isSuperType = $isSuperType->and(IsSuperTypeOfResult::createMaybe()); diff --git a/tests/PHPStan/Analyser/nsrt/bug-15266.php b/tests/PHPStan/Analyser/nsrt/bug-15266.php new file mode 100644 index 00000000000..d61bcea0317 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15266.php @@ -0,0 +1,127 @@ +> | class-string> $class + * @return class-string> | class-string> + */ +function parametrized(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string> | class-string> $class + * @return class-string> | class-string> + */ +function star(string $class): string +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + return $class; +} + +/** + * @param class-string | class-string $class + * @return class-string | class-string + */ +function raw(string $class): string +{ + if ($class !== X::class) { + assertType('class-string', $class); + } + + return $class; +} + +/** + * @param class-string|Y> $class + */ +function genericUnionInsideClassString(string $class): void +{ + if ($class !== X::class) { + assertType('class-string>', $class); + } + + echo $class; +} + +/** + * @param class-string>|class-string> $class + */ +function identical(string $class): void +{ + if ($class === X::class) { + assertType('\'Bug15266\\\\X\'', $class); + } else { + assertType('class-string>', $class); + } +} + +/** + * @param class-string>|class-string> $class + */ +function switchOnClassString(string $class): void +{ + switch ($class) { + case X::class: + assertType('\'Bug15266\\\\X\'', $class); + break; + default: + assertType('class-string>', $class); + } +} + +/** + * @param class-string>|class-string> $class + */ +function inArrayOnClassString(string $class): void +{ + if (in_array($class, [X::class], true)) { + assertType('\'Bug15266\\\\X\'', $class); + } +} + +/** + * @param class-string>|class-string> $class + */ +function offsetOnClassString(string $class): void +{ + $map = [X::class => 1, Y::class => 2]; + assertType('1|2', $map[$class]); +} + +/** + * @param class-string> $class + */ +function unionWithConstantClassString(string $class, bool $bool): void +{ + assertType('class-string>', $bool ? $class : X::class); +} + +/** + * @param class-string>&literal-string $class + */ +function classStringWithAccessoryType(string $class): void +{ + if ($class !== X::class) { + assertType('*NEVER*', $class); + } + + echo $class; +} diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 3171da7f961..72fbc8c253e 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -575,6 +575,11 @@ public function testInTrait(): void ]); } + public function testBug15266(): void + { + $this->analyse([__DIR__ . '/data/bug-15266.php'], []); + } + public function testMatchArmComparisonNotSuppressedByImpossibleCheck(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-15266.php b/tests/PHPStan/Rules/Comparison/data/bug-15266.php new file mode 100644 index 00000000000..771ab86d617 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-15266.php @@ -0,0 +1,42 @@ +>|class-string> $class + */ +function parametrized(string $class): string +{ + return match ($class) { + X::class => 'x', + Y::class => 'y', + }; +} + +/** + * @param class-string>|class-string> $class + */ +function star(string $class): string +{ + return match ($class) { + X::class => 'x', + Y::class => 'y', + }; +} + +/** + * @param class-string|class-string $class + */ +function raw(string $class): string +{ + return match ($class) { + X::class => 'x', + Y::class => 'y', + }; +} From 36e6600d79b36de86654504624a6a3f3c329e572 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 20 Sep 2026 14:42:04 +0000 Subject: [PATCH 2/6] Inline the single-use type argument erasure into isValueOfGenericType Co-Authored-By: Claude Opus 5 --- src/Type/Generic/GenericClassStringType.php | 22 ++++++++------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/src/Type/Generic/GenericClassStringType.php b/src/Type/Generic/GenericClassStringType.php index 5809e8d7640..70fd1864d63 100644 --- a/src/Type/Generic/GenericClassStringType.php +++ b/src/Type/Generic/GenericClassStringType.php @@ -129,26 +129,20 @@ public static function isValueOfGenericType(Type $genericType, string $className $genericType = $genericType->getBound(); } - // We are transforming constant class-string to ObjectType. But we need to filter out - // an uncertainty originating in possible ObjectType's class subtypes. - return self::eraseTypeArguments($genericType)->isSuperTypeOf(new ObjectType($className)); - } - - /** - * A class-string carries a class name and never its type arguments, so the type - * arguments must not take part in the comparison against a constant class-string: - * `X::class` is a value of `class-string>` and of `class-string>` just - * like it is of `class-string`. - */ - private static function eraseTypeArguments(Type $type): Type - { - return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + // A class-string carries a class name and never its type arguments, so the type + // arguments must not take part in the comparison: `X::class` is a value of + // `class-string>` and of `class-string>` just like it is of `class-string`. + $genericType = TypeTraverser::map($genericType, static function (Type $type, callable $traverse): Type { if ($type instanceof GenericObjectType) { return new ObjectType($type->getClassName(), $type->getSubtractedType()); } return $traverse($type); }); + + // We are transforming constant class-string to ObjectType. But we need to filter out + // an uncertainty originating in possible ObjectType's class subtypes. + return $genericType->isSuperTypeOf(new ObjectType($className)); } public function isSuperTypeOf(Type $type): IsSuperTypeOfResult From 2b78e04cfa7e02bbe1ec52094c593a96dd0493a1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 15:29:02 +0000 Subject: [PATCH 3/6] Lint the match() reproducer only on PHP 8.0+ Co-Authored-By: Claude Opus 5 --- tests/PHPStan/Rules/Comparison/data/bug-15266.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Comparison/data/bug-15266.php b/tests/PHPStan/Rules/Comparison/data/bug-15266.php index 771ab86d617..1dc8f58ff94 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-15266.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-15266.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug15266Match; From 4102d836a88a09067925398bf01c33193833f958 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 15:29:02 +0000 Subject: [PATCH 4/6] Added regression test for static::class comparison in a generic class Co-Authored-By: Claude Opus 5 --- ...rictComparisonOfDifferentTypesRuleTest.php | 5 +++ .../Rules/Comparison/data/bug-10498.php | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-10498.php diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index debc27ee4e3..16490b399e6 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1264,6 +1264,11 @@ public function testBug14966(): void $this->analyse([__DIR__ . '/data/bug-14966.php'], []); } + public function testBug10498(): void + { + $this->analyse([__DIR__ . '/data/bug-10498.php'], []); + } + public function testBug14847(): void { $this->analyse([__DIR__ . '/data/bug-14847.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-10498.php b/tests/PHPStan/Rules/Comparison/data/bug-10498.php new file mode 100644 index 00000000000..ea5a943fdee --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-10498.php @@ -0,0 +1,34 @@ + + */ +class B extends A +{ + +} + +class C +{ + +} From 740aaebd5ebddf21e0f07e08d62f8388e8f5d870 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 17:07:53 +0000 Subject: [PATCH 5/6] Mirror the class-string type argument erasure in the native Type classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isValueOfGenericType()` and its type argument erasure landed in the PHP GenericClassStringType, but both it and ConstantStringType are shadowed by the turbo extension, so the native mirrors still ran the old comparison — with the extension loaded the fix had no effect. GenericClassStringType.cpp gains the static method (registered under the twin's generated signature, so a `self::` call from PHP reaches it) with the erasure as a stateless native TypeTraverser::map() callback: a GenericObjectType becomes the ObjectType of the class it names, keeping the subtracted type. Its isSuperTypeOf() delegates to it, and ConstantStringType.cpp reaches the same body through the new pt_generic_class_string_is_value_of_generic_type() entry point. The ObjectType of the compared class name is built before the traversal: the twin takes `$className` by value, while the native borrows it from a property slot that the calls in between must not outlive. type-family.php covers the new method directly over an object, a parameterized, star-projected, subtracted and union generic type, a StaticType and a TemplateType, and adds parameterized, star-projected and union class-strings to the string family's subjects and comparison targets. Co-Authored-By: Claude Opus 5 --- turbo-ext/src/ConstantStringType.cpp | 32 +--- turbo-ext/src/GenericClassStringType.cpp | 154 ++++++++++++++---- turbo-ext/src/TypeTraits.h | 5 + .../src/generated/GenericClassStringType.h | 3 + turbo-ext/tests/type-family.php | 26 +++ 5 files changed, 160 insertions(+), 60 deletions(-) diff --git a/turbo-ext/src/ConstantStringType.cpp b/turbo-ext/src/ConstantStringType.cpp index f0180540e4f..9a73a8688d9 100644 --- a/turbo-ext/src/ConstantStringType.cpp +++ b/turbo-ext/src/ConstantStringType.cpp @@ -256,9 +256,8 @@ class ConstantStringType return exported; } - /* maybe/no for a GenericClassStringType by whether its generic type (a - * StaticType's object type, a TemplateType's bound; mixed is maybe) is - * a supertype of this value's ObjectType; maybe/no for a + /* maybe/no for a GenericClassStringType by whether this value can be a + * value of its generic type (mixed is maybe); maybe/no for a * ClassStringType by whether this is a class-string; yes/no against * another ConstantStringType's value; maybe for any other StringType; * the CompoundType callback; no otherwise; UNDEF = pending exception */ @@ -269,31 +268,10 @@ class ConstantStringType zv::Val genericType = pt_type_call(Z_OBJ_P(type), PT_LC("getgenerictype"), 0, NULL); if (UNEXPECTED(genericType.isUndef())) return zv::Val(); if (zv::Ref(genericType.raw()).instanceOf(pt_ce_mixed_type)) return pt_type_is_super_type_of_result(PT_TRI_MAYBE); - /* $genericType instanceof StaticType — the shadowing class */ - bool isStatic = zv::Ref(genericType.raw()).instanceOf(pt_ce_static_type); - if (isStatic) { - genericType = pt_type_call(Z_OBJ_P(genericType.raw()), PT_LC("getstaticobjecttype"), 0, NULL); - if (UNEXPECTED(genericType.isUndef())) return zv::Val(); - } - /* We are transforming constant class-string to ObjectType. But - * we need to filter out an uncertainty originating in possible - * ObjectType's class subtypes. */ - zval *objectType = getObjectType(); - if (UNEXPECTED(objectType == NULL)) return zv::Val(); - - /* Do not use TemplateType's isSuperTypeOf handling directly - * because it takes ObjectType uncertainty into account. */ - bool isTemplate; - if (UNEXPECTED(!pt_type_instanceof(genericType.raw(), PT_CLASS_TEMPLATE_TYPE, isTemplate))) return zv::Val(); - zv::Val isSuperType; - if (isTemplate) { - zv::Val bound = pt_type_call(Z_OBJ_P(genericType.raw()), PT_LC("getbound"), 0, NULL); - if (UNEXPECTED(bound.isUndef())) return zv::Val(); - isSuperType = pt_type_op(Z_OBJ_P(bound.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, objectType); - } else { - isSuperType = pt_type_op(Z_OBJ_P(genericType.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, objectType); - } + zend_string *v = value(); + if (UNEXPECTED(v == NULL)) return zv::Val(); + zv::Val isSuperType = pt_generic_class_string_is_value_of_generic_type(genericType.raw(), v); if (UNEXPECTED(isSuperType.isUndef())) return zv::Val(); zend_long verdict = pt_type_result_trinary(isSuperType.raw()); if (UNEXPECTED(verdict < 0)) return zv::Val(); diff --git a/turbo-ext/src/GenericClassStringType.cpp b/turbo-ext/src/GenericClassStringType.cpp index 65a3af623ff..d88415a3d0c 100644 --- a/turbo-ext/src/GenericClassStringType.cpp +++ b/turbo-ext/src/GenericClassStringType.cpp @@ -152,13 +152,56 @@ class GenericClassStringType return callOnType(PT_LC("accepts"), 2, args); } + /* Whether a class named $className can be the value behind + * `class-string<$genericType>`: the generic type (a StaticType's object + * type, a TemplateType's bound) with its type arguments erased, judged + * against ObjectType($className); UNDEF = pending exception */ + static zv::Val isValueOfGenericType(zval *genericType, zend_string *className) + { + /* We are transforming constant class-string to ObjectType. But we + * need to filter out an uncertainty originating in possible + * ObjectType's class subtypes. Built up front: $className is a + * by-value parameter of the twin, the borrowed name here would + * have to outlive the calls below. */ + zval objectTypeRaw; + if (UNEXPECTED(!pt_object_type_new(&objectTypeRaw, className))) return zv::Val(); + zv::Val objectType = zv::Val::adopt(objectTypeRaw); + + zv::Val type = zv::Val::copyOf(zv::Ref(genericType)); + + /* $genericType instanceof StaticType — the shadowing class */ + if (zv::Ref(type.raw()).instanceOf(pt_ce_static_type)) { + type = pt_type_call(Z_OBJ_P(type.raw()), PT_LC("getstaticobjecttype"), 0, NULL); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + } + + /* Do not use TemplateType's isSuperTypeOf handling directly + * because it takes ObjectType uncertainty into account. */ + bool isTemplate; + if (UNEXPECTED(!pt_type_instanceof(type.raw(), PT_CLASS_TEMPLATE_TYPE, isTemplate))) return zv::Val(); + if (isTemplate) { + type = pt_type_call(Z_OBJ_P(type.raw()), PT_LC("getbound"), 0, NULL); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + } + + /* A class-string carries a class name and never its type arguments, + * so the type arguments must not take part in the comparison: + * `X::class` is a value of `class-string>` and of + * `class-string>` just like it is of `class-string`. */ + zv::Val callback = pt_type_native_callback(eraseTypeArgumentsCallback, NULL, NULL); + if (UNEXPECTED(callback.isUndef())) return zv::Val(); + type = pt_type_traverser_map_of(type.raw(), callback.raw()); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + + return pt_type_op(Z_OBJ_P(type.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, objectType.raw()); + } + /* the CompoundType callback; for a ConstantStringType yes under a mixed - * generic type, else the generic type's (a StaticType's object type, a - * TemplateType's bound) verdict on its ObjectType, and'ed with maybe - * unless it is a class-string; the generic types' verdict for another - * GenericClassStringType; the verdict on ObjectWithoutClassType for a - * ClassStringType; maybe for a StringType; no otherwise; UNDEF = - * pending exception */ + * generic type, else the verdict of isValueOfGenericType() on its value, + * and'ed with maybe unless it is a class-string; the generic types' + * verdict for another GenericClassStringType; the verdict on + * ObjectWithoutClassType for a ClassStringType; maybe for a StringType; + * no otherwise; UNDEF = pending exception */ zv::Val isSuperTypeOf(zval *type) const { bool compound; @@ -173,34 +216,11 @@ class GenericClassStringType if (instanceof_function(typeCe, pt_ce_constant_string_type)) { zval *ownType = this->type(); if (UNEXPECTED(ownType == NULL)) return zv::Val(); - zv::Val genericType = zv::Val::copyOf(zv::Ref(ownType)); - if (zv::Ref(genericType.raw()).instanceOf(pt_ce_mixed_type)) return pt_type_is_super_type_of_result(PT_TRI_YES); - - /* $genericType instanceof StaticType — the shadowing class */ - bool isStatic = zv::Ref(genericType.raw()).instanceOf(pt_ce_static_type); - if (isStatic) { - genericType = pt_type_call(Z_OBJ_P(genericType.raw()), PT_LC("getstaticobjecttype"), 0, NULL); - if (UNEXPECTED(genericType.isUndef())) return zv::Val(); - } - - /* We are transforming constant class-string to ObjectType. But - * we need to filter out an uncertainty originating in possible - * ObjectType's class subtypes. */ - zv::Val objectType = objectTypeOfConstantString(Z_OBJ_P(type)); - if (UNEXPECTED(objectType.isUndef())) return zv::Val(); + if (zv::Ref(ownType).instanceOf(pt_ce_mixed_type)) return pt_type_is_super_type_of_result(PT_TRI_YES); - /* Do not use TemplateType's isSuperTypeOf handling directly - * because it takes ObjectType uncertainty into account. */ - bool isTemplate; - if (UNEXPECTED(!pt_type_instanceof(genericType.raw(), PT_CLASS_TEMPLATE_TYPE, isTemplate))) return zv::Val(); - zv::Val isSuperType; - if (isTemplate) { - zv::Val bound = pt_type_call(Z_OBJ_P(genericType.raw()), PT_LC("getbound"), 0, NULL); - if (UNEXPECTED(bound.isUndef())) return zv::Val(); - isSuperType = pt_type_op(Z_OBJ_P(bound.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, objectType.raw()); - } else { - isSuperType = pt_type_op(Z_OBJ_P(genericType.raw()), PT_OP_IS_SUPER_TYPE_OF, 1, objectType.raw()); - } + zv::Val value = pt_constant_string_get_value(Z_OBJ_P(type)); + if (UNEXPECTED(value.isUndef())) return zv::Val(); + zv::Val isSuperType = isValueOfGenericType(ownType, zv::Ref(value.raw()).asString()); if (UNEXPECTED(isSuperType.isUndef())) return zv::Val(); zend_long isClassString = pt_type_call_trinary(Z_OBJ_P(type), PT_LC("isclassstring"), 0, NULL); @@ -487,6 +507,62 @@ class GenericClassStringType return pt_type_call(self, PT_LC("getgenerictype"), 0, NULL); } + /* the `static function (Type $type, callable $traverse)` of + * isValueOfGenericType(): a GenericObjectType becomes the ObjectType of + * the class it names, everything else is traversed — stateless, both + * state slots unused */ + static void eraseTypeArgumentsCallback(zval *state0, zval *state1, uint32_t argc, zval *argv, zval *return_value) + { + (void) state0; + (void) state1; + if (UNEXPECTED(argc < 2)) { + zend_argument_count_error("Too few arguments to function %s::{closure}(), %u passed and exactly 2 expected", ZSTR_VAL(pt_ce_generic_class_string_type->name), argc); + return; + } + zval *type = &argv[0]; + zval *traverse = &argv[1]; + if (Z_TYPE_P(type) == IS_OBJECT && instanceof_function(Z_OBJCE_P(type), pt_ce_generic_object_type)) { + zv::Val erased = eraseTypeArguments(Z_OBJ_P(type)); + if (UNEXPECTED(erased.isUndef())) return; + erased.intoReturnValue(return_value); + return; + } + + (void) pt_type_traverser_traverse(return_value, traverse, type); + } + + /* new ObjectType($type->getClassName(), $type->getSubtractedType()) for + * a GenericObjectType — its slots when the object is exactly the native + * class, its methods otherwise (a subclass may override them); UNDEF = + * pending exception */ + static zv::Val eraseTypeArguments(zend_object *genericObject) + { + zv::Val classNameHold, subtractedHold; + zend_string *className; + zval *subtractedType; + if (EXPECTED(genericObject->ce == pt_ce_generic_object_type)) { + className = pt_object_type_class_name(genericObject); + if (UNEXPECTED(className == NULL)) return zv::Val(); + subtractedType = pt_object_type_subtracted_type(genericObject); + if (UNEXPECTED(subtractedType == NULL)) return zv::Val(); + } else { + classNameHold = pt_type_call(genericObject, PT_LC("getclassname"), 0, NULL); + if (UNEXPECTED(classNameHold.isUndef())) return zv::Val(); + if (UNEXPECTED(!zv::Ref(classNameHold.raw()).isString())) { + zend_type_error("phpstan_turbo: %s::getClassName() must return string", ZSTR_VAL(genericObject->ce->name)); + return zv::Val(); + } + className = zv::Ref(classNameHold.raw()).asString(); + subtractedHold = pt_type_call(genericObject, PT_LC("getsubtractedtype"), 0, NULL); + if (UNEXPECTED(subtractedHold.isUndef())) return zv::Val(); + subtractedType = subtractedHold.raw(); + } + + zval erased; + if (UNEXPECTED(!pt_object_type_new(&erased, className, subtractedType))) return zv::Val(); + return zv::Val::adopt(erased); + } + /* new ObjectType($type->getValue()) for a ConstantStringType */ static zv::Val objectTypeOfConstantString(zend_object *constantString) { @@ -530,6 +606,11 @@ zv::Val pt_type_new_generic_class_string(zval *type) return GenericClassStringType::create(type); } +zv::Val pt_generic_class_string_is_value_of_generic_type(zval *genericType, zend_string *className) +{ + return GenericClassStringType::isValueOfGenericType(genericType, className); +} + /* {{{ engine ABI glue: parameter parsing + registration */ #define PT_THIS GenericClassStringType(Z_OBJ_P(ZEND_THIS)) @@ -568,6 +649,13 @@ void pt_register_generic_class_string_type() cls.method<&GenericClassStringType::accepts, zp::Obj, zp::Bool>(sigs::accepts); cls.op(PT_OP_ACCEPTS, PT_OP_LAMBDA { return GenericClassStringType(self).accepts(argv, (Z_TYPE(argv[1]) == IS_TRUE)); }); + cls.method(sigs::isValueOfGenericType, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *genericType; + zend_string *className; + if (!zp::parse(execute_data, genericType, className)) RETURN_THROWS(); + PT_RETURN_VAL(GenericClassStringType::isValueOfGenericType(genericType, className)); + }); + cls.method<&GenericClassStringType::isSuperTypeOf, zp::Obj>(sigs::isSuperTypeOf); cls.op(); diff --git a/turbo-ext/src/TypeTraits.h b/turbo-ext/src/TypeTraits.h index 0946741ae13..de51f708347 100644 --- a/turbo-ext/src/TypeTraits.h +++ b/turbo-ext/src/TypeTraits.h @@ -1085,6 +1085,11 @@ zv::Val pt_template_type_parameter_strategy_accepts(zval *left, zval *right, boo * GenericClassStringType.cpp); UNDEF = pending exception */ zv::Val pt_type_new_generic_class_string(zval *type); +/* GenericClassStringType::isValueOfGenericType($genericType, $className) — + * the static method's native body (a `self::` call, never overridden); + * UNDEF = pending exception */ +zv::Val pt_generic_class_string_is_value_of_generic_type(zval *genericType, zend_string *className); + /* }}} */ /* the Template*Type twins' shared bodies: new (...) with the bound diff --git a/turbo-ext/src/generated/GenericClassStringType.h b/turbo-ext/src/generated/GenericClassStringType.h index 806294c4a07..fec458223ee 100644 --- a/turbo-ext/src/generated/GenericClassStringType.h +++ b/turbo-ext/src/generated/GenericClassStringType.h @@ -47,6 +47,9 @@ inline constexpr reg::Sig describe = { "describe", ZEND_ACC_PUBLIC, 1, describe_ inline constexpr reg::Arg accepts_args[] = { reg::typed("type", 0, "PHPStan\\Type\\Type"), reg::typed("strictTypes", MAY_BE_BOOL) }; inline constexpr reg::Arg accepts_return = reg::typed("", 0, "PHPStan\\Type\\AcceptsResult"); inline constexpr reg::Sig accepts = { "accepts", ZEND_ACC_PUBLIC, 2, accepts_args, 2, &accepts_return }; +inline constexpr reg::Arg isValueOfGenericType_args[] = { reg::typed("genericType", 0, "PHPStan\\Type\\Type"), reg::typed("className", MAY_BE_STRING) }; +inline constexpr reg::Arg isValueOfGenericType_return = reg::typed("", 0, "PHPStan\\Type\\IsSuperTypeOfResult"); +inline constexpr reg::Sig isValueOfGenericType = { "isValueOfGenericType", ZEND_ACC_PUBLIC | ZEND_ACC_STATIC, 2, isValueOfGenericType_args, 2, &isValueOfGenericType_return }; inline constexpr reg::Arg isSuperTypeOf_args[] = { reg::typed("type", 0, "PHPStan\\Type\\Type") }; inline constexpr reg::Arg isSuperTypeOf_return = reg::typed("", 0, "PHPStan\\Type\\IsSuperTypeOfResult"); inline constexpr reg::Sig isSuperTypeOf = { "isSuperTypeOf", ZEND_ACC_PUBLIC, 1, isSuperTypeOf_args, 1, &isSuperTypeOf_return }; diff --git a/turbo-ext/tests/type-family.php b/turbo-ext/tests/type-family.php index fc533f69066..a06f590d845 100644 --- a/turbo-ext/tests/type-family.php +++ b/turbo-ext/tests/type-family.php @@ -418,8 +418,10 @@ 'stringTrinary' => new $constString(\PHPStan\TrinaryLogic::class), 'stringTrinaryClass' => new $constString(\PHPStan\TrinaryLogic::class, true), 'stringStrlen' => new $constString('strlen'), + 'stringArrayObject' => new $constString(\ArrayObject::class, true), 'classString' => new $classString(), 'genericTrinary' => new $genericClassString(new \PHPStan\Type\ObjectType(\PHPStan\TrinaryLogic::class)), + 'genericArrayObject' => new $genericClassString(new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()])), 'genericType' => new $genericClassString(new \PHPStan\Type\ObjectType(\PHPStan\Type\Type::class)), 'genericNonexistent' => new $genericClassString(new \PHPStan\Type\ObjectType('NonexistentClass')), 'genericMixed' => new $genericClassString(new \PHPStan\Type\MixedType()), @@ -481,6 +483,7 @@ 'constTrinary' => new $constStringClass(\PHPStan\TrinaryLogic::class), 'constTrinaryClass' => new $constStringClass(\PHPStan\TrinaryLogic::class, true), 'constStrlen' => new $constStringClass('strlen'), + 'constArrayObject' => new $constStringClass(\ArrayObject::class, true), 'constStaticMethod' => new $constStringClass(\PHPStan\TrinaryLogic::class . '::createYes'), 'constInstanceMethod' => new $constStringClass(\PHPStan\TrinaryLogic::class . '::yes'), 'constMissingMethod' => new $constStringClass(\PHPStan\TrinaryLogic::class . '::nonexistent'), @@ -499,6 +502,12 @@ 'genericStatic' => new $genericClassStringClass(new \PHPStan\Type\StaticType($stringReflectionProvider->getClass(\PHPStan\TrinaryLogic::class))), 'genericTemplate' => new $genericClassStringClass(\PHPStan\Type\Generic\TemplateTypeFactory::create(\PHPStan\Type\Generic\TemplateTypeScope::createWithFunction('foo'), 'T', new \PHPStan\Type\ObjectType(\PHPStan\Type\Type::class), \PHPStan\Type\Generic\TemplateTypeVariance::createInvariant())), 'genericUnion' => new $genericClassStringClass(new \PHPStan\Type\UnionType([new \PHPStan\Type\ObjectType(\PHPStan\TrinaryLogic::class), new \PHPStan\Type\ObjectType(\PHPStan\Type\VerbosityLevel::class)])), + // a class-string never carries type arguments: these must behave + // exactly like the unparameterized class-string + 'genericArrayObject' => new $genericClassStringClass(new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()])), + 'genericArrayObjectStar' => new $genericClassStringClass(new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\MixedType(), new \PHPStan\Type\MixedType()], null, null, [\PHPStan\Type\Generic\TemplateTypeVariance::createBivariant(), \PHPStan\Type\Generic\TemplateTypeVariance::createBivariant()])), + 'genericArrayObjectBare' => new $genericClassStringClass(new \PHPStan\Type\ObjectType(\ArrayObject::class)), + 'genericGenericUnion' => new $genericClassStringClass(new \PHPStan\Type\UnionType([new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()]), new \PHPStan\Type\Generic\GenericObjectType(\ArrayIterator::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()])])), ]; $outOfClassScope = new \PHPStan\Analyser\OutOfClassScope(); foreach ($subjects as $name => $subject) { @@ -599,6 +608,23 @@ $r["$name getGenericType"] = $view($subject->getGenericType()); } } + // GenericClassStringType::isValueOfGenericType() — the static behind both + // isSuperTypeOf() directions between a class-string and a constant one + $isValueOfGenericTypes = [ + 'object' => new \PHPStan\Type\ObjectType(\ArrayObject::class), + 'generic' => new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()]), + 'genericStar' => new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\MixedType(), new \PHPStan\Type\MixedType()], null, null, [\PHPStan\Type\Generic\TemplateTypeVariance::createBivariant(), \PHPStan\Type\Generic\TemplateTypeVariance::createBivariant()]), + 'genericSubtracted' => new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()], new \PHPStan\Type\ObjectType(\ArrayIterator::class)), + 'genericUnion' => new \PHPStan\Type\UnionType([new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()]), new \PHPStan\Type\Generic\GenericObjectType(\ArrayIterator::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()])]), + 'objectWithoutClass' => new \PHPStan\Type\ObjectWithoutClassType(), + 'static' => new \PHPStan\Type\StaticType($stringReflectionProvider->getClass(\PHPStan\TrinaryLogic::class)), + 'template' => \PHPStan\Type\Generic\TemplateTypeFactory::create(\PHPStan\Type\Generic\TemplateTypeScope::createWithFunction('foo'), 'T', new \PHPStan\Type\Generic\GenericObjectType(\ArrayObject::class, [new \PHPStan\Type\IntegerType(), new \PHPStan\Type\StringType()]), \PHPStan\Type\Generic\TemplateTypeVariance::createInvariant()), + ]; + foreach ($isValueOfGenericTypes as $genericName => $genericType) { + foreach ([\ArrayObject::class, \ArrayIterator::class, \PHPStan\TrinaryLogic::class, 'NonexistentClass', ''] as $className) { + $r["isValueOfGenericType $genericName $className"] = $view($genericClassStringClass::isValueOfGenericType($genericType, $className)); + } + } $r['const equals const'] = [(new $constStringClass('a'))->equals(new $constStringClass('a')), (new $constStringClass('a'))->equals(new $constStringClass('b')), (new $constStringClass('a'))->equals(new $constStringClass('a', true))]; $r['generic equals generic'] = [$subjects['genericTrinary']->equals(new $genericClassStringClass(new \PHPStan\Type\ObjectType(\PHPStan\TrinaryLogic::class))), $subjects['genericTrinary']->equals($subjects['genericType']), $subjects['genericTrinary']->equals($subjects['classString'])]; // an uninitialized instance: every typed-slot read raises the same Error From 18003c71cd23055e0518483aaec5348bbb8f2e6b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 22 Sep 2026 17:07:55 +0000 Subject: [PATCH 6/6] Bump expected turbo version Co-Authored-By: Claude Opus 5 --- 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 c7cc1371ce1..477c29407a0 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '3ff623f'; + public const EXPECTED_EXTENSION_VERSION = '740aaeb'; private static bool $active = false;