From 6f86a58ff1dbd774629e1e6e79f7153998aecc7f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:41:15 +0000 Subject: [PATCH] Do not generalize a union of constant arrays that is no bigger than its largest member in `TypeCombinator::optimizeConstantArrays()` - `optimizeConstantArrays()` generalized any union whose constant array value count exceeded `ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT`, even when the union reduced to a single shape no bigger than one of its own members. A declared shape with more than 256 values (nested ones included) is kept as-is on its own, but after `$r['opt'] ?? null` (or `isset`, `array_key_exists`, `empty`, `!== null`) the scope merge unions it with its narrowed variant, and the merged result was degraded to `non-empty-array<..., ...>&oversized-array`. - The threshold is now raised to the largest per-member count of the unioned types (new `getConstantArrayValueTypesLimit()`), computed only once the total is already over the plain limit. Both callers in `processArrayTypes()` pass the types being unioned. - Ported the same change to the native mirror in `turbo-ext/src/TypeCombinator.cpp`. - Probed: big list shapes (fixed by the same change, test added); declared unions of a large shape with another shape (already generalized when they are declared, which is long-standing behavior and left unchanged); shapes with more than 256 *keys* (degraded by `ConstantArrayTypeBuilder::createFromConstantArray()`, an older design decision, left unchanged). --- src/Type/TypeCombinator.php | 33 +- tests/PHPStan/Analyser/nsrt/bug-15459.php | 545 ++++++++++++++++++++++ turbo-ext/src/TypeCombinator.cpp | 28 +- 3 files changed, 598 insertions(+), 8 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..f70f0e3961c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -44,6 +44,7 @@ use function get_class; use function implode; use function in_array; +use function max; use function spl_object_id; use function sprintf; use function usort; @@ -1221,7 +1222,7 @@ private static function processArrayTypes(array $arrayTypes): array $arrayType = new ArrayType( self::union(...$keyTypesForGeneralArray), - self::union(...self::optimizeConstantArrays($valueTypesForGeneralArray)), + self::union(...self::optimizeConstantArrays($valueTypesForGeneralArray, $valueTypesForGeneralArray)), ); if ($templateArrayType !== null) { @@ -1240,7 +1241,7 @@ private static function processArrayTypes(array $arrayTypes): array ]; } - $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true)); + $reducedArrayTypes = self::optimizeConstantArrays(self::reduceArrays($arrayTypes, true), $arrayTypes); foreach ($reducedArrayTypes as $idx => $reducedArray) { $applied = $accessoryTypes; if ($reducedArray->isIterableAtLeastOnce()->no()) { @@ -1259,10 +1260,29 @@ private static function processArrayTypes(array $arrayTypes): array } /** + * The count of constant array value types above which a union of the given + * types gets generalized. A single type already holding more than + * ARRAY_COUNT_LIMIT of them raises it - a union no bigger than one of its + * members does not grow the type, generalizing it would only lose precision. + * * @param Type[] $types + */ + private static function getConstantArrayValueTypesLimit(array $types): int + { + $limit = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT; + foreach ($types as $type) { + $limit = max($limit, self::countConstantArrayValueTypes([$type])); + } + + return $limit; + } + + /** + * @param Type[] $types + * @param Type[] $unionedTypes the types whose union $types represent * @return Type[] */ - private static function optimizeConstantArrays(array $types): array + private static function optimizeConstantArrays(array $types, array $unionedTypes): array { $constantArrayValuesCount = self::countConstantArrayValueTypes($types); @@ -1270,6 +1290,11 @@ private static function optimizeConstantArrays(array $types): array return $types; } + $limit = self::getConstantArrayValueTypesLimit($unionedTypes); + if ($constantArrayValuesCount <= $limit) { + return $types; + } + // Stage 1: collapse same-key-set ConstantArrayType variants per-position // before the (lossy) generalization below kicks in. Variants with the // same key signature mergeWith losslessly into a single shape whose @@ -1311,7 +1336,7 @@ private static function optimizeConstantArrays(array $types): array if ($anyMerged) { $types = array_values($collapsed); $constantArrayValuesCount = self::countConstantArrayValueTypes($types); - if ($constantArrayValuesCount <= ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT) { + if ($constantArrayValuesCount <= $limit) { return $types; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15459.php b/tests/PHPStan/Analyser/nsrt/bug-15459.php new file mode 100644 index 00000000000..407dea28caa --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15459.php @@ -0,0 +1,545 @@ + limit) limit = count; + } + return limit; + } + + /* private static optimizeConstantArrays(Type[] $types, Type[] $unionedTypes): Type[] */ + static zv::Val optimizeConstantArrays(zval *typesArray, zval *unionedTypes) { zv::Val types = copy(typesArray); zend_long limit = arrayCountLimit(); @@ -2308,6 +2324,10 @@ class TypeCombinator if (constantArrayValuesCount <= limit) return types; + limit = getConstantArrayValueTypesLimit(unionedTypes); + PT_FAIL_IF_NEG(limit); + if (constantArrayValuesCount <= limit) return types; + /* Stage 1: collapse same-key-set ConstantArrayType variants * per-position before the (lossy) generalization below kicks in */ zv::Arr signatureGroups = zv::Arr::create(0);