From f26473704864c31bc717cacfb150f77b18118dc1 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:58:27 +0600 Subject: [PATCH 1/2] Do not generalize an array union that is no larger than its members When the scope branches of a check like `$r['o0'] ?? null` merge back, the shape and its narrowed variant are unioned. reduceArrays() merges them into the original shape, but optimizeConstantArrays() then counts the constant array values in the result (nested shapes included) and, above ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT, turns it into an oversized general array. A declared shape with more than 256 values in total therefore lost its keys after the first `??` on it. 2.2 did not reach optimizeConstantArrays() here: from the 63rd distinct key on, its key numbering overflowed and the arrays went through reduceArrays() without the optimization. 2.3 removed that numbering. The union is now kept as it is when it reduces to a single array that holds no more constant array values than the largest of the arrays it was made of - nothing grows, so there is nothing for the limit to bound. Closes https://github.com/phpstan/phpstan/issues/15459 --- src/Type/TypeCombinator.php | 30 ++- tests/PHPStan/Analyser/nsrt/bug-15459.php | 234 ++++++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 38 +++- turbo-ext/src/generated/TypeCombinator.h | 98 ++++----- 4 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15459.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1c375aae070..4390e24a4e7 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1240,7 +1240,10 @@ private static function processArrayTypes(array $arrayTypes): array ]; } - $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); + $reducedArrayTypes = self::reduceArrays($arrayTypes, true); + if (count($reducedArrayTypes) !== 1 || !self::isNoLargerThanLargestArray($reducedArrayTypes[0], $arrayTypes)) { + $reducedArrayTypes = self::optimizeConstantArrays($reducedArrayTypes); + } foreach ($reducedArrayTypes as $idx => $reducedArray) { $applied = $accessoryTypes; if ($reducedArray->isIterableAtLeastOnce()->no()) { @@ -1258,6 +1261,31 @@ private static function processArrayTypes(array $arrayTypes): array return $reducedArrayTypes; } + /** + * Whether the single array a union reduced to holds no more constant array + * values than the largest of the arrays it was made of - e.g. a shape and + * its narrowed variant merged back together. Each of those arrays was kept + * as it is, so generalizing their union would lose a shape that the limit + * did not apply to and that does not grow. + * + * @param Type[] $arrayTypes + */ + private static function isNoLargerThanLargestArray(Type $reducedArrayType, array $arrayTypes): bool + { + $constantArrayValuesCount = self::countConstantArrayValueTypes([$reducedArrayType]); + if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return true; + } + + foreach ($arrayTypes as $arrayType) { + if (self::countConstantArrayValueTypes([$arrayType]) >= $constantArrayValuesCount) { + return true; + } + } + + return false; + } + /** * @param Type[] $types * @return Type[] diff --git a/tests/PHPStan/Analyser/nsrt/bug-15459.php b/tests/PHPStan/Analyser/nsrt/bug-15459.php new file mode 100644 index 00000000000..42060f29d26 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15459.php @@ -0,0 +1,234 @@ += constantArrayValuesCount) return 1; + } + + return 0; + } + /* private static optimizeConstantArrays(Type[] $types): Type[] */ static zv::Val optimizeConstantArrays(zval *typesArray) { diff --git a/turbo-ext/src/generated/TypeCombinator.h b/turbo-ext/src/generated/TypeCombinator.h index aaf3b79ef1d..244619e0d06 100644 --- a/turbo-ext/src/generated/TypeCombinator.h +++ b/turbo-ext/src/generated/TypeCombinator.h @@ -59,27 +59,29 @@ inline constexpr char strings[] = "processArrayAccessoryTypes\0" /* 509 */ "findEmptyArrayNextToOversizedArray\0" /* 536 */ "processArrayTypes\0" /* 571 */ - "optimizeConstantArrays\0" /* 589 */ - "countConstantArrayValueTypes\0" /* 612 */ - "array\0" /* 641 */ - "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 647 */ - "getConstantScalarValuesByKey\0" /* 687 */ - "oneUnrelatedKeyAllowed\0" /* 716 */ - "haveDisjointConstantScalarValues\0" /* 739 */ - "constantArrays\0" /* 772 */ - "preserveTaggedUnions\0" /* 787 */ - "reduceArrays\0" /* 808 */ - "isPlainOrBenevolentUnion\0" /* 821 */ - "PHPStan\\Type\\UnionType\0" /* 846 */ - "intersectFiniteUnions\0" /* 869 */ - "finiteUnionMembers\0" /* 891 */ - "intersect\0" /* 910 */ - "doIntersect\0" /* 920 */ - "intersectDefiniteConstantArrays\0" /* 932 */ - "PHPStan\\Type\\IntersectionType\0" /* 964 */ - "mergeIntersectionsForUnion\0" /* 994 */ - "removeFalsey\0" /* 1021 */ - "removeTruthy"; /* 1034 */ + "reducedArrayType\0" /* 589 */ + "isNoLargerThanLargestArray\0" /* 606 */ + "optimizeConstantArrays\0" /* 633 */ + "countConstantArrayValueTypes\0" /* 656 */ + "array\0" /* 685 */ + "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 691 */ + "getConstantScalarValuesByKey\0" /* 731 */ + "oneUnrelatedKeyAllowed\0" /* 760 */ + "haveDisjointConstantScalarValues\0" /* 783 */ + "constantArrays\0" /* 816 */ + "preserveTaggedUnions\0" /* 831 */ + "reduceArrays\0" /* 852 */ + "isPlainOrBenevolentUnion\0" /* 865 */ + "PHPStan\\Type\\UnionType\0" /* 890 */ + "intersectFiniteUnions\0" /* 913 */ + "finiteUnionMembers\0" /* 935 */ + "intersect\0" /* 954 */ + "doIntersect\0" /* 964 */ + "intersectDefiniteConstantArrays\0" /* 976 */ + "PHPStan\\Type\\IntersectionType\0" /* 1008 */ + "mergeIntersectionsForUnion\0" /* 1038 */ + "removeFalsey\0" /* 1065 */ + "removeTruthy"; /* 1078 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_VOID), /* clearCache return */ reg::packed(12, 0, 17), /* addNull $type */ @@ -131,35 +133,38 @@ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_NULL, 17), /* findEmptyArrayNextToOversizedArray return */ reg::packed(498, MAY_BE_ARRAY), /* processArrayTypes $arrayTypes */ reg::packed(0, MAY_BE_ARRAY), /* processArrayTypes return */ + reg::packed(589, 0, 17), /* isNoLargerThanLargestArray $reducedArrayType */ + reg::packed(498, MAY_BE_ARRAY), /* isNoLargerThanLargestArray $arrayTypes */ + reg::packed(0, MAY_BE_BOOL), /* isNoLargerThanLargestArray return */ reg::packed(126, MAY_BE_ARRAY), /* optimizeConstantArrays $types */ reg::packed(0, MAY_BE_ARRAY), /* optimizeConstantArrays return */ reg::packed(126, MAY_BE_ARRAY), /* countConstantArrayValueTypes $types */ reg::packed(0, MAY_BE_LONG), /* countConstantArrayValueTypes return */ - reg::packed(641, 0, 647), /* getConstantScalarValuesByKey $array */ + reg::packed(685, 0, 691), /* getConstantScalarValuesByKey $array */ reg::packed(0, MAY_BE_NULL | MAY_BE_ARRAY), /* getConstantScalarValuesByKey return */ reg::packed(263, MAY_BE_NULL | MAY_BE_ARRAY), /* haveDisjointConstantScalarValues $a */ reg::packed(265, MAY_BE_NULL | MAY_BE_ARRAY), /* haveDisjointConstantScalarValues $b */ - reg::packed(716, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ + reg::packed(760, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ reg::packed(0, MAY_BE_BOOL), /* haveDisjointConstantScalarValues return */ - reg::packed(772, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ - reg::packed(787, MAY_BE_BOOL), /* reduceArrays $preserveTaggedUnions */ + reg::packed(816, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ + reg::packed(831, MAY_BE_BOOL), /* reduceArrays $preserveTaggedUnions */ reg::packed(0, MAY_BE_ARRAY), /* reduceArrays return */ reg::packed(12, 0, 17), /* isPlainOrBenevolentUnion $type */ reg::packed(0, MAY_BE_BOOL), /* isPlainOrBenevolentUnion return */ - reg::packed(263, 0, 846), /* intersectFiniteUnions $a */ - reg::packed(265, 0, 846), /* intersectFiniteUnions $b */ + reg::packed(263, 0, 890), /* intersectFiniteUnions $a */ + reg::packed(265, 0, 890), /* intersectFiniteUnions $b */ reg::packed(0, MAY_BE_NULL, 17), /* intersectFiniteUnions return */ - reg::packed(132, 0, 846), /* finiteUnionMembers $union */ + reg::packed(132, 0, 890), /* finiteUnionMembers $union */ reg::packed(0, MAY_BE_NULL | MAY_BE_ARRAY), /* finiteUnionMembers return */ reg::packed(126, 0, 17, false, true), /* intersect $types */ reg::packed(0, 0, 17), /* intersect return */ reg::packed(126, 0, 17, false, true), /* doIntersect $types */ reg::packed(0, 0, 17), /* doIntersect return */ - reg::packed(263, 0, 647), /* intersectDefiniteConstantArrays $a */ - reg::packed(265, 0, 647), /* intersectDefiniteConstantArrays $b */ + reg::packed(263, 0, 691), /* intersectDefiniteConstantArrays $a */ + reg::packed(265, 0, 691), /* intersectDefiniteConstantArrays $b */ reg::packed(0, 0, 17), /* intersectDefiniteConstantArrays return */ - reg::packed(263, 0, 964), /* mergeIntersectionsForUnion $a */ - reg::packed(265, 0, 964), /* mergeIntersectionsForUnion $b */ + reg::packed(263, 0, 1008), /* mergeIntersectionsForUnion $a */ + reg::packed(265, 0, 1008), /* mergeIntersectionsForUnion $b */ reg::packed(0, MAY_BE_NULL, 17), /* mergeIntersectionsForUnion return */ reg::packed(12, 0, 17), /* removeFalsey $type */ reg::packed(0, 0, 17), /* removeFalsey return */ @@ -192,20 +197,21 @@ inline constexpr sigtab::Sig intersectWithSubtractedType = { { 470 /* intersectW inline constexpr sigtab::Sig processArrayAccessoryTypes = { { 509 /* processArrayAccessoryTypes */, 1, 44, 1, 45, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig findEmptyArrayNextToOversizedArray = { { 536 /* findEmptyArrayNextToOversizedArray */, 1, 46, 1, 47, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig processArrayTypes = { { 571 /* processArrayTypes */, 1, 48, 1, 49, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig optimizeConstantArrays = { { 589 /* optimizeConstantArrays */, 1, 50, 1, 51, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 612 /* countConstantArrayValueTypes */, 1, 52, 1, 53, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 687 /* getConstantScalarValuesByKey */, 1, 54, 1, 55, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 739 /* haveDisjointConstantScalarValues */, 3, 56, 3, 59, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig reduceArrays = { { 808 /* reduceArrays */, 2, 60, 2, 62, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 821 /* isPlainOrBenevolentUnion */, 1, 63, 1, 64, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectFiniteUnions = { { 869 /* intersectFiniteUnions */, 2, 65, 2, 67, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig finiteUnionMembers = { { 891 /* finiteUnionMembers */, 1, 68, 1, 69, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersect = { { 910 /* intersect */, 0, 70, 1, 71, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig doIntersect = { { 920 /* doIntersect */, 0, 72, 1, 73, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 932 /* intersectDefiniteConstantArrays */, 2, 74, 2, 76, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 994 /* mergeIntersectionsForUnion */, 2, 77, 2, 79, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeFalsey = { { 1021 /* removeFalsey */, 1, 80, 1, 81, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeTruthy = { { 1034 /* removeTruthy */, 1, 82, 1, 83, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig isNoLargerThanLargestArray = { { 606 /* isNoLargerThanLargestArray */, 2, 50, 2, 52, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig optimizeConstantArrays = { { 633 /* optimizeConstantArrays */, 1, 53, 1, 54, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 656 /* countConstantArrayValueTypes */, 1, 55, 1, 56, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 731 /* getConstantScalarValuesByKey */, 1, 57, 1, 58, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 783 /* haveDisjointConstantScalarValues */, 3, 59, 3, 62, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig reduceArrays = { { 852 /* reduceArrays */, 2, 63, 2, 65, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 865 /* isPlainOrBenevolentUnion */, 1, 66, 1, 67, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectFiniteUnions = { { 913 /* intersectFiniteUnions */, 2, 68, 2, 70, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig finiteUnionMembers = { { 935 /* finiteUnionMembers */, 1, 71, 1, 72, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersect = { { 954 /* intersect */, 0, 73, 1, 74, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig doIntersect = { { 964 /* doIntersect */, 0, 75, 1, 76, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 976 /* intersectDefiniteConstantArrays */, 2, 77, 2, 79, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 1038 /* mergeIntersectionsForUnion */, 2, 80, 2, 82, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeFalsey = { { 1065 /* removeFalsey */, 1, 83, 1, 84, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeTruthy = { { 1078 /* removeTruthy */, 1, 85, 1, 86, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::TypeCombinator From 19ebaf83ac11ca97745892f409cd8481c02887b4 Mon Sep 17 00:00:00 2001 From: HawkForge <75696486+HawkForge@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:59:48 +0600 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 374d29043e6..1e818bcf7a7 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = 'a0f7086'; + public const EXPECTED_EXTENSION_VERSION = 'f264737'; private static bool $active = false;