From 6f9d6394240def919e5995279abe90fd777d329b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:35:09 +0000 Subject: [PATCH 1/2] Keep the narrowed bound of a template type that `TemplateTypeHelper::resolveTemplateTypes()` maps to itself - In the key of `array`, TypeNodeResolver narrows `K of string|int|object` to a `K of int|string` occurrence. Methods, parameters and properties of the declaring class are then resolved against the class's active template type map, which maps `K` to the declared `K` and so widened the key bound back to `int|string|object`. `TypehintHelper::decideType()` then found the PHPDoc type not to be a subtype of the native `array` and fell back to `array`. - When the standin is the same template type (same name and scope), resolveTemplateTypes() now keeps the occurrence (converted to its argument form when the standin is an argument), so the narrowed bound survives. - Mirrored the change in turbo-ext/src/TemplateTypeHelper.cpp. - Covered: plain and conditional return types, non-empty-array, @param, @var and promoted properties inside the class, inherited interface methods and trait methods. Function-level templates were not affected (they are not resolved against a class map). --- src/Type/Generic/TemplateTypeHelper.php | 11 ++ tests/PHPStan/Analyser/nsrt/bug-15321.php | 127 ++++++++++++++++++++++ turbo-ext/src/TemplateTypeHelper.cpp | 33 ++++++ 3 files changed, 171 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15321.php diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 90c983455cd..c3662134b41 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -52,6 +52,17 @@ public static function resolveTemplateTypes( return $traverse($type); } + // the template type standing in for itself, e.g. in the class that declares it, + // keeps the bound narrowed at this occurrence, like `int|string` of a `K of int|string|object` + // in the key of `array` + if ( + $newType instanceof TemplateType + && $newType->getName() === $type->getName() + && $newType->getScope()->equals($type->getScope()) + ) { + return $traverse($newType->isArgument() ? $type->toArgument() : $type); + } + if ($newType instanceof ErrorType && !$keepErrorTypes) { return $traverse($type->getDefault() ?? $type->getBound()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15321.php b/tests/PHPStan/Analyser/nsrt/bug-15321.php new file mode 100644 index 00000000000..f7fc3026709 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15321.php @@ -0,0 +1,127 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug15321; + +use function PHPStan\Testing\assertType; + +/** + * @template K of string|int|object + * @template V + */ +interface Map +{ + /** @return (K is array-key ? array : array) */ + public function toArray(): array; + + /** @return array */ + public function toArrayPlain(): array; + + /** @return non-empty-array */ + public function toNonEmptyArray(): array; + + /** @param array $array */ + public function merge(array $array): void; +} + +/** @param Map $map */ +function test(Map $map): void +{ + assertType('array', $map->toArray()); + assertType('array', $map->toArrayPlain()); + assertType('non-empty-array', $map->toNonEmptyArray()); +} + +/** + * @template K of string|int|object + * @template V + */ +final class ArrayMap +{ + + /** @var array */ + private array $items; + + /** + * @param array $promoted + * @param array $items + */ + public function __construct(private array $promoted, array $items) + { + $this->items = $items; + } + + /** @param array $array */ + public function merge(array $array): void + { + assertType('array', $array); + assertType('array', $this->items); + assertType('array', $this->promoted); + } + + /** @return array */ + public function toArray(): array + { + return $this->items; + } + +} + +/** @param ArrayMap $map */ +function testArrayMap(ArrayMap $map): void +{ + assertType('array', $map->toArray()); +} + +/** + * @template K of string|int|object + * @template V + */ +trait MapTrait +{ + + /** @return array */ + public function traitToArray(): array + { + return []; + } + +} + +/** + * @template K of string|int|object + * @template V + * @extends Map + */ +interface SortedMap extends Map +{ + + /** @return array */ + public function toSortedArray(): array; + +} + +/** + * @template K of string|int|object + * @template V + */ +abstract class MapWithTrait +{ + + /** @use MapTrait */ + use MapTrait; + +} + +/** + * @param SortedMap $sortedMap + * @param MapWithTrait $mapWithTrait + */ +function testInherited(SortedMap $sortedMap, MapWithTrait $mapWithTrait): void +{ + assertType('array', $sortedMap->toArray()); + assertType('array', $sortedMap->toArrayPlain()); + assertType('array', $sortedMap->toSortedArray()); + assertType('array', $mapWithTrait->traitToArray()); +} diff --git a/turbo-ext/src/TemplateTypeHelper.cpp b/turbo-ext/src/TemplateTypeHelper.cpp index ecc2a561e53..7dc93b002d3 100644 --- a/turbo-ext/src/TemplateTypeHelper.cpp +++ b/turbo-ext/src/TemplateTypeHelper.cpp @@ -260,6 +260,39 @@ void TemplateTypeHelper::resolveTemplateTypesCallback(zval *state0, zval *state1 return; } + /* the template type standing in for itself keeps the bound + * narrowed at this occurrence - see the twin */ + bool newIsTemplate; + if (UNEXPECTED(!pt_tth_is_template(newType.raw(), newIsTemplate))) return; + if (newIsTemplate) { + zv::Val newName = pt_tth_name(newType.raw()); + if (UNEXPECTED(newName.isUndef())) return; + if (zend_string_equals(Z_STR_P(newName.raw()), Z_STR_P(name.raw()))) { + zv::Val newScope = pt_type_call(Z_OBJ_P(newType.raw()), PT_LC("getscope"), 0, NULL); + if (UNEXPECTED(newScope.isUndef())) return; + zv::Val scope = pt_type_call(Z_OBJ_P(type), PT_LC("getscope"), 0, NULL); + if (UNEXPECTED(scope.isUndef())) return; + if (UNEXPECTED(!zv::Ref(newScope.raw()).isObject())) { + zend_type_error("phpstan_turbo: getScope() must return an object"); + return; + } + zv::Val scopesEqual = pt_type_call(Z_OBJ_P(newScope.raw()), PT_LC("equals"), 1, scope.raw()); + if (UNEXPECTED(scopesEqual.isUndef())) return; + if (zend_is_true(scopesEqual.raw())) { + zv::Val newIsArgument = pt_type_call(Z_OBJ_P(newType.raw()), PT_LC("isargument"), 0, NULL); + if (UNEXPECTED(newIsArgument.isUndef())) return; + if (!zend_is_true(newIsArgument.raw())) { + (void) pt_type_traverser_traverse(return_value, traverse, type); + return; + } + zv::Val argument = pt_type_call(Z_OBJ_P(type), PT_LC("toargument"), 0, NULL); + if (UNEXPECTED(argument.isUndef())) return; + (void) pt_type_traverser_traverse(return_value, traverse, argument.raw()); + return; + } + } + } + bool isError; if (UNEXPECTED(!pt_type_instanceof_ce(newType.raw(), pt_ce_error_type, isError))) return; if (isError && !zend_is_true(keepErrorTypes)) { From ef76c49c53756b8835cf276056dd44d0e55588a8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 28 Sep 2026 18:23:08 +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 e77f9fdcba9..621eac414e5 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '6888887'; + public const EXPECTED_EXTENSION_VERSION = '6f9d639'; private static bool $active = false;