From 07d602e648fa0bf86b0621dabcc44a67622fdd79 Mon Sep 17 00:00:00 2001 From: Claude Sundragon Date: Mon, 2 Nov 2026 22:49:01 +0100 Subject: [PATCH 1/2] Keep an array shape that a union reduces back to without growing When two branches narrow one key of a large array shape and merge again, reduceArrays() merges the two variants back into the declared shape, but optimizeConstantArrays() then counts that single shape recursively and, once its nested shapes bring it over ARRAY_COUNT_LIMIT, generalizes it to non-empty-array. The shape is no larger than the ones that went into the union, so generalizing it only loses precision. An array that grows on every merge is larger than each of its inputs and is still generalized. Mirrored in the turbo extension. Co-Authored-By: Claude Opus 5.5 --- src/Type/TypeCombinator.php | 37 +++++++++- tests/PHPStan/Analyser/nsrt/bug-15649.php | 83 +++++++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 34 +++++++++- 3 files changed, 152 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15649.php diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 7a2e6e49666..af5870c9ba9 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1300,7 +1300,10 @@ private static function processArrayTypes(array $arrayTypes): array ]; } - $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); + $reducedArrayTypes = self::reduceArrays($arrayTypes, true); + if (!self::isReducedToNoLargerShape($reducedArrayTypes, $arrayTypes)) { + $reducedArrayTypes = self::optimizeConstantArrays($reducedArrayTypes); + } foreach ($reducedArrayTypes as $idx => $reducedArray) { $applied = $accessoryTypes; if ($reducedArray->isIterableAtLeastOnce()->no()) { @@ -1318,6 +1321,38 @@ private static function processArrayTypes(array $arrayTypes): array return $reducedArrayTypes; } + /** + * Whether the union reduced to a single array shape that holds no more + * values than the largest shape that went into it, as when two branches + * that narrowed one key of the same shape are merged again. + * + * Such a shape grew nothing, so generalizing it would only lose precision. + * A shape that grows on every merge, like an array built up in a loop, + * is larger than each of its inputs and is still generalized. + * + * @param Type[] $reducedArrayTypes + * @param Type[] $arrayTypes + */ + private static function isReducedToNoLargerShape(array $reducedArrayTypes, array $arrayTypes): bool + { + if (count($reducedArrayTypes) !== 1 || !$reducedArrayTypes[array_key_first($reducedArrayTypes)]->isConstantArray()->yes()) { + return false; + } + + $reducedCount = self::countConstantArrayValueTypes($reducedArrayTypes); + if ($reducedCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + return false; + } + + foreach ($arrayTypes as $arrayType) { + if (self::countConstantArrayValueTypes([$arrayType]) >= $reducedCount) { + return true; + } + } + + return false; + } + /** * @param Type[] $types * @return Type[] diff --git a/tests/PHPStan/Analyser/nsrt/bug-15649.php b/tests/PHPStan/Analyser/nsrt/bug-15649.php new file mode 100644 index 00000000000..22ad89e3901 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15649.php @@ -0,0 +1,83 @@ +, + * strings: list, + * } + */ +class Foo +{ + + /** @param Booking $data */ + public function mergedAfterIfElse(array $data): void + { + if ($data['version'] === '') { + $version = null; + } else { + $version = $data['version']; + } + + assertType("''|int", $data['version']); + assertType('string', $data['name']); + assertType('list', array_column($data['ints'], 'a127')); + assertType('list', array_column($data['strings'], 'b1')); + } + + /** @param Booking $data */ + public function earlyReturn(array $data): void + { + if ($data['version'] === '') { + return; + } + + assertType('int', $data['version']); + assertType('string', $data['name']); + assertType('list', array_column($data['ints'], 'a127')); + assertType('list', array_column($data['strings'], 'b1')); + } + +} diff --git a/turbo-ext/src/TypeCombinator.cpp b/turbo-ext/src/TypeCombinator.cpp index fa52a6fc038..15400b620f2 100644 --- a/turbo-ext/src/TypeCombinator.cpp +++ b/turbo-ext/src/TypeCombinator.cpp @@ -2185,7 +2185,9 @@ class TypeCombinator zv::Arr arrayTypesArray = listOf(arrayTypes); zv::Val reduced = reduceArrays(arrayTypesArray.raw(), true); PT_FAIL_IF_UNDEF(reduced); - zv::Val reducedArrayTypes = optimizeConstantArrays(reduced.raw()); + int noLarger = isReducedToNoLargerShape(reduced.raw(), arrayTypesArray.raw()); + PT_FAIL_IF_NEG(noLarger); + zv::Val reducedArrayTypes = noLarger ? std::move(reduced) : optimizeConstantArrays(reduced.raw()); PT_FAIL_IF_UNDEF(reducedArrayTypes); zv::Arr result = zv::Arr::adoptVal(std::move(reducedArrayTypes)); result.separate(); @@ -2390,6 +2392,36 @@ class TypeCombinator traversed.intoReturnValue(return_value); } + /* private static isReducedToNoLargerShape(Type[] $reducedArrayTypes, + * Type[] $arrayTypes): bool; -1 = pending exception */ + [[nodiscard]] static int isReducedToNoLargerShape(zval *reducedArrayTypes, zval *arrayTypes) + { + if (countOf(reducedArrayTypes) != 1) return 0; + zval *reducedArray = nullptr; + for (zv::ArrayEntry entry : zv::ArrRef(reducedArrayTypes)) { + reducedArray = entry.value().raw(); + } + zend_long isConstantArray = callTrinary(reducedArray, PT_LC("isconstantarray")); + if (isConstantArray < 0) return -1; + if (isConstantArray != PT_TRI_YES) return 0; + + zend_long reducedCount = countConstantArrayValueTypes(reducedArrayTypes); + if (reducedCount < 0) return -1; + zend_long limit = arrayCountLimit(); + if (limit < 0) return -1; + if (reducedCount <= limit) return 0; + + for (zv::ArrayEntry entry : zv::ArrRef(arrayTypes)) { + zv::Arr single = zv::Arr::create(1); + single.push(copy(entry.value().raw())); + zend_long count = countConstantArrayValueTypes(single.raw()); + if (count < 0) return -1; + if (count >= reducedCount) return 1; + } + + return 0; + } + /* private static optimizeConstantArrays(Type[] $types): Type[] */ static zv::Val optimizeConstantArrays(zval *typesArray) { From 4002e162dbef28638fedfa898abecf42d16c30d9 Mon Sep 17 00:00:00 2001 From: Claude Sundragon Date: Mon, 2 Nov 2026 22:57:44 +0100 Subject: [PATCH 2/2] Regenerate the turbo TypeCombinator declarations turbo-ext/bin/generate-declarations.php, for the new isReducedToNoLargerShape() method. Co-Authored-By: Claude Opus 5.5 --- turbo-ext/src/generated/TypeCombinator.h | 98 +++++++++++++----------- 1 file changed, 52 insertions(+), 46 deletions(-) diff --git a/turbo-ext/src/generated/TypeCombinator.h b/turbo-ext/src/generated/TypeCombinator.h index 4d4020447c3..97e756308fa 100644 --- a/turbo-ext/src/generated/TypeCombinator.h +++ b/turbo-ext/src/generated/TypeCombinator.h @@ -62,27 +62,29 @@ inline constexpr char strings[] = "processArrayAccessoryTypes\0" /* 564 */ "findEmptyArrayNextToOversizedArray\0" /* 591 */ "processArrayTypes\0" /* 626 */ - "optimizeConstantArrays\0" /* 644 */ - "countConstantArrayValueTypes\0" /* 667 */ - "array\0" /* 696 */ - "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 702 */ - "getConstantScalarValuesByKey\0" /* 742 */ - "oneUnrelatedKeyAllowed\0" /* 771 */ - "haveDisjointConstantScalarValues\0" /* 794 */ - "constantArrays\0" /* 827 */ - "preserveTaggedUnions\0" /* 842 */ - "reduceArrays\0" /* 863 */ - "isPlainOrBenevolentUnion\0" /* 876 */ - "PHPStan\\Type\\UnionType\0" /* 901 */ - "intersectFiniteUnions\0" /* 924 */ - "finiteUnionMembers\0" /* 946 */ - "intersect\0" /* 965 */ - "doIntersect\0" /* 975 */ - "intersectDefiniteConstantArrays\0" /* 987 */ - "PHPStan\\Type\\IntersectionType\0" /* 1019 */ - "mergeIntersectionsForUnion\0" /* 1049 */ - "removeFalsey\0" /* 1076 */ - "removeTruthy"; /* 1089 */ + "reducedArrayTypes\0" /* 644 */ + "isReducedToNoLargerShape\0" /* 662 */ + "optimizeConstantArrays\0" /* 687 */ + "countConstantArrayValueTypes\0" /* 710 */ + "array\0" /* 739 */ + "PHPStan\\Type\\Constant\\ConstantArrayType\0" /* 745 */ + "getConstantScalarValuesByKey\0" /* 785 */ + "oneUnrelatedKeyAllowed\0" /* 814 */ + "haveDisjointConstantScalarValues\0" /* 837 */ + "constantArrays\0" /* 870 */ + "preserveTaggedUnions\0" /* 885 */ + "reduceArrays\0" /* 906 */ + "isPlainOrBenevolentUnion\0" /* 919 */ + "PHPStan\\Type\\UnionType\0" /* 944 */ + "intersectFiniteUnions\0" /* 967 */ + "finiteUnionMembers\0" /* 989 */ + "intersect\0" /* 1008 */ + "doIntersect\0" /* 1018 */ + "intersectDefiniteConstantArrays\0" /* 1030 */ + "PHPStan\\Type\\IntersectionType\0" /* 1062 */ + "mergeIntersectionsForUnion\0" /* 1092 */ + "removeFalsey\0" /* 1119 */ + "removeTruthy"; /* 1132 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_VOID), /* clearCache return */ reg::packed(12, 0, 17), /* addNull $type */ @@ -137,35 +139,38 @@ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_NULL, 17), /* findEmptyArrayNextToOversizedArray return */ reg::packed(553, MAY_BE_ARRAY), /* processArrayTypes $arrayTypes */ reg::packed(0, MAY_BE_ARRAY), /* processArrayTypes return */ + reg::packed(644, MAY_BE_ARRAY), /* isReducedToNoLargerShape $reducedArrayTypes */ + reg::packed(553, MAY_BE_ARRAY), /* isReducedToNoLargerShape $arrayTypes */ + reg::packed(0, MAY_BE_BOOL), /* isReducedToNoLargerShape 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(696, 0, 702), /* getConstantScalarValuesByKey $array */ + reg::packed(739, 0, 745), /* 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(771, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ + reg::packed(814, MAY_BE_BOOL), /* haveDisjointConstantScalarValues $oneUnrelatedKeyAllowed */ reg::packed(0, MAY_BE_BOOL), /* haveDisjointConstantScalarValues return */ - reg::packed(827, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ - reg::packed(842, MAY_BE_BOOL), /* reduceArrays $preserveTaggedUnions */ + reg::packed(870, MAY_BE_ARRAY), /* reduceArrays $constantArrays */ + reg::packed(885, 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, 901), /* intersectFiniteUnions $a */ - reg::packed(265, 0, 901), /* intersectFiniteUnions $b */ + reg::packed(263, 0, 944), /* intersectFiniteUnions $a */ + reg::packed(265, 0, 944), /* intersectFiniteUnions $b */ reg::packed(0, MAY_BE_NULL, 17), /* intersectFiniteUnions return */ - reg::packed(132, 0, 901), /* finiteUnionMembers $union */ + reg::packed(132, 0, 944), /* 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, 702), /* intersectDefiniteConstantArrays $a */ - reg::packed(265, 0, 702), /* intersectDefiniteConstantArrays $b */ + reg::packed(263, 0, 745), /* intersectDefiniteConstantArrays $a */ + reg::packed(265, 0, 745), /* intersectDefiniteConstantArrays $b */ reg::packed(0, 0, 17), /* intersectDefiniteConstantArrays return */ - reg::packed(263, 0, 1019), /* mergeIntersectionsForUnion $a */ - reg::packed(265, 0, 1019), /* mergeIntersectionsForUnion $b */ + reg::packed(263, 0, 1062), /* mergeIntersectionsForUnion $a */ + reg::packed(265, 0, 1062), /* 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 */ @@ -199,20 +204,21 @@ inline constexpr sigtab::Sig intersectWithSubtractedType = { { 525 /* intersectW inline constexpr sigtab::Sig processArrayAccessoryTypes = { { 564 /* processArrayAccessoryTypes */, 1, 47, 1, 48, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig findEmptyArrayNextToOversizedArray = { { 591 /* findEmptyArrayNextToOversizedArray */, 1, 49, 1, 50, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; inline constexpr sigtab::Sig processArrayTypes = { { 626 /* processArrayTypes */, 1, 51, 1, 52, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig optimizeConstantArrays = { { 644 /* optimizeConstantArrays */, 1, 53, 1, 54, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 667 /* countConstantArrayValueTypes */, 1, 55, 1, 56, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 742 /* getConstantScalarValuesByKey */, 1, 57, 1, 58, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 794 /* haveDisjointConstantScalarValues */, 3, 59, 3, 62, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig reduceArrays = { { 863 /* reduceArrays */, 2, 63, 2, 65, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 876 /* isPlainOrBenevolentUnion */, 1, 66, 1, 67, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectFiniteUnions = { { 924 /* intersectFiniteUnions */, 2, 68, 2, 70, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig finiteUnionMembers = { { 946 /* finiteUnionMembers */, 1, 71, 1, 72, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersect = { { 965 /* intersect */, 0, 73, 1, 74, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig doIntersect = { { 975 /* doIntersect */, 0, 75, 1, 76, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 987 /* intersectDefiniteConstantArrays */, 2, 77, 2, 79, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 1049 /* mergeIntersectionsForUnion */, 2, 80, 2, 82, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeFalsey = { { 1076 /* removeFalsey */, 1, 83, 1, 84, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; -inline constexpr sigtab::Sig removeTruthy = { { 1089 /* removeTruthy */, 1, 85, 1, 86, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig isReducedToNoLargerShape = { { 662 /* isReducedToNoLargerShape */, 2, 53, 2, 55, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig optimizeConstantArrays = { { 687 /* optimizeConstantArrays */, 1, 56, 1, 57, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig countConstantArrayValueTypes = { { 710 /* countConstantArrayValueTypes */, 1, 58, 1, 59, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig getConstantScalarValuesByKey = { { 785 /* getConstantScalarValuesByKey */, 1, 60, 1, 61, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig haveDisjointConstantScalarValues = { { 837 /* haveDisjointConstantScalarValues */, 3, 62, 3, 65, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig reduceArrays = { { 906 /* reduceArrays */, 2, 66, 2, 68, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig isPlainOrBenevolentUnion = { { 919 /* isPlainOrBenevolentUnion */, 1, 69, 1, 70, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectFiniteUnions = { { 967 /* intersectFiniteUnions */, 2, 71, 2, 73, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig finiteUnionMembers = { { 989 /* finiteUnionMembers */, 1, 74, 1, 75, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersect = { { 1008 /* intersect */, 0, 76, 1, 77, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig doIntersect = { { 1018 /* doIntersect */, 0, 78, 1, 79, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig intersectDefiniteConstantArrays = { { 1030 /* intersectDefiniteConstantArrays */, 2, 80, 2, 82, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig mergeIntersectionsForUnion = { { 1092 /* mergeIntersectionsForUnion */, 2, 83, 2, 85, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeFalsey = { { 1119 /* removeFalsey */, 1, 86, 1, 87, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; +inline constexpr sigtab::Sig removeTruthy = { { 1132 /* removeTruthy */, 1, 88, 1, 89, ZEND_ACC_PUBLIC | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::TypeCombinator