From 735e8709f05649749f79c30357e0e1ae921c7cdc Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:20:16 +0000 Subject: [PATCH 1/2] Skip `HasOffsetValueType` propagation in `specifyExpressionType` when parent type is a large union - When narrowing a nested array dim fetch like `$config['menu']['key']`, `specifyExpressionType` recursively propagates `HasOffsetValueType` to parent expressions (`$config['menu']` and `$config`) - Each condition doubles the union members of the parent type because `TypeCombinator::union` preserves distinct `HasOffsetValueType` combinations - After N conditions, the parent type has 2^N union members, causing exponential growth in scope merging and type checking time - Fix: skip adding `HasOffsetValueType` to the parent when the parent's current type is already a union with more than 16 members - This also fixes the previously-reverted #14319 (array|object with many offset checks) since the limit now applies to all types - Added PHPBench regression test reproducing the original issue --- src/Analyser/MutatingScope.php | 20 +++++++++++--- tests/bench/data/bug-14462.php | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/bench/data/bug-14462.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 122340cc27b..de040d863a9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -138,6 +138,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME = 'containsSuperGlobal'; + private const ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT = 16; /** @var Type[] */ private array $resolvedTypes = []; @@ -2743,10 +2744,12 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $varType = TypeCombinator::intersect( - $varType, - new HasOffsetValueType($dimType, $type), - ); + if (!$this->hasOverflowingHasOffsetValueTypes($exprVarType)) { + $varType = TypeCombinator::intersect( + $varType, + new HasOffsetValueType($dimType, $type), + ); + } } $scope = $scope->specifyExpressionType( @@ -2795,6 +2798,15 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, return $scope; } + private function hasOverflowingHasOffsetValueTypes(Type $type): bool + { + if (!$type instanceof UnionType) { + return false; + } + + return count($type->getTypes()) > self::ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT; + } + public function assignExpression(Expr $expr, Type $type, Type $nativeType): self { $scope = $this; diff --git a/tests/bench/data/bug-14462.php b/tests/bench/data/bug-14462.php new file mode 100644 index 00000000000..f8e40cc5466 --- /dev/null +++ b/tests/bench/data/bug-14462.php @@ -0,0 +1,48 @@ +} */ +function get_config(): array { + return ['menu' => []]; +} + +$config = get_config(); + +$data = [ ]; +if ($config['menu']['notefrais']) { + $data[] = [ 'name' => 'notefrais', 'menu' => 'notefrais_base' ]; +} +if ($config['menu']['achat']) { + $data[] = [ 'name' => 'achat', 'menu' => 'achat_base' ]; +} + +if ($config['menu']['vente-commande_planning'] || $config['menu']['vente-commande']) { + $data[] = [ 'name' => 'vente' , 'menu' => 'vente_order_recent' ]; +} +if ($config['menu']['vente-commande_planning']) { + $data[] = [ 'name' => 'vente', 'menu' => 'vente_base_planned' ]; +} +if ($config['menu']['vente-commande']) { + $data[] = [ 'name' => 'vente', 'menu' => 'vente_base_com' ]; +} +if ($config['menu']['carte']) { + $data[] = [ 'name' => 'carte', 'menu' => '' ]; +} +if ($config['menu']['crm']) { + $data[] = [ 'name' => 'crm', 'menu' => 'crm_suivi' ]; +} +if ($config['menu']['inventaire']) { + $data[] = [ 'name' => 'inventaire', 'menu' => 'inventaire_base' ]; +} + + +foreach ($data as $row) { + $stack = [ ]; + if ($row['menu'] === 'vente_order_recent') { + $stack[] = 'f'; + } + else { + $stack[] = 'g'; + } +} From 929a40dee0791cc1fe6a40003739bba658c92166 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 07:35:10 +0000 Subject: [PATCH 2/2] Rename hasOverflowingHasOffsetValueTypes to isLargeUnionType The previous name was misleading: it suggested counting HasOffsetValueType members specifically, but it actually checks whether the union type has too many members overall. Rename the method and constant to accurately reflect what they check. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index de040d863a9..057a7f3bcbf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -138,7 +138,7 @@ class MutatingScope implements Scope, NodeCallbackInvoker public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME = 'containsSuperGlobal'; - private const ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT = 16; + private const ARRAY_DIM_FETCH_UNION_MEMBER_LIMIT = 16; /** @var Type[] */ private array $resolvedTypes = []; @@ -2744,7 +2744,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - if (!$this->hasOverflowingHasOffsetValueTypes($exprVarType)) { + if (!$this->isLargeUnionType($exprVarType)) { $varType = TypeCombinator::intersect( $varType, new HasOffsetValueType($dimType, $type), @@ -2798,13 +2798,13 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, return $scope; } - private function hasOverflowingHasOffsetValueTypes(Type $type): bool + private function isLargeUnionType(Type $type): bool { if (!$type instanceof UnionType) { return false; } - return count($type->getTypes()) > self::ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT; + return count($type->getTypes()) > self::ARRAY_DIM_FETCH_UNION_MEMBER_LIMIT; } public function assignExpression(Expr $expr, Type $type, Type $nativeType): self