Skip to content

Skip HasOffsetValueType propagation in specifyExpressionType when parent type is a large union#5453

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-n3inuru
Open

Skip HasOffsetValueType propagation in specifyExpressionType when parent type is a large union#5453
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-n3inuru

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When analyzing code with multiple if conditions checking nested array offsets (like $config['menu']['key']), PHPStan's type tracking causes exponential growth in the parent type's union members. Each condition doubles the number of union members because specifyExpressionType recursively propagates HasOffsetValueType intersections upward, and TypeCombinator::union preserves all distinct combinations. With 8 conditions, the parent type has 2^8 = 256 union members, making analysis take 37+ seconds.

Changes

  • Added ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT = 16 constant to MutatingScope
  • Added hasOverflowingHasOffsetValueTypes() helper that checks if a type is a union with more than 16 members
  • In specifyExpressionType(), skip adding HasOffsetValueType to the parent array expression when the parent's type already exceeds this union member limit (src/Analyser/MutatingScope.php)
  • Added PHPBench regression test (tests/bench/data/bug-14462.php) reproducing the issue from the bug report

Analogous cases probed

  • #14319 pattern (array|object with many offset checks): also fixed by this change, since the limit applies to all types regardless of whether they are definitively arrays. The original fix for #14319 was reverted because it only applied to non-array types; this fix is more general.
  • Triple-nested array access ($config['a']['b']['key']): tested with 8 conditions, completes in ~3s instead of potentially exponential time.
  • Flat (non-nested) array access: not affected (no recursive propagation), confirmed fast both before and after the fix.
  • Property fetches: not affected (specifyExpressionType only recurses for ArrayDimFetch, not PropertyFetch).

Root cause

The root cause is the recursive HasOffsetValueType propagation in MutatingScope::specifyExpressionType(). When narrowing $config['menu']['key'] to a specific type:

  1. HasOffsetValueType('key', true) is intersected with $config['menu']'s type
  2. The recursive call then intersects HasOffsetValueType('menu', <updated_type>) with $config's type
  3. After branch merging, TypeCombinator::union creates a union of all possible HasOffsetValueType combinations
  4. Each subsequent condition doubles the union size: 1 -> 2 -> 4 -> 8 -> 16 -> 32 -> ...

The fix stops adding HasOffsetValueType once the parent type is already a union with more than 16 members, preventing the exponential explosion while still preserving precise type tracking for the first several conditions.

Test

  • tests/bench/data/bug-14462.php - PHPBench regression test with the exact code from the issue report (8 conditional array dim fetch checks on nested array offsets). Before the fix: ~37s. After the fix: ~1.8s.

Fixes phpstan/phpstan#14462

… 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
return $scope;
}

private function hasOverflowingHasOffsetValueTypes(Type $type): bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the method name is misleading since it only count the types from the UnionType ; we could have a big union type without HasOffsetValueType.

We could think this method will count the number of HasOffsetValueType

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I renamed hasOverflowingHasOffsetValueTypes() to isLargeUnionType() and the constant from ARRAY_DIM_FETCH_UNION_HAS_OFFSET_VALUE_TYPE_LIMIT to ARRAY_DIM_FETCH_UNION_MEMBER_LIMIT. The new names accurately reflect that the check is about the union type's member count, not specifically about HasOffsetValueType instances. Tests and static analysis both pass.

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 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@ondrejmirtes ondrejmirtes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Want to block the merge here - I want to try some alternative approaches

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

The review from ondrejmirtes doesn't request any specific code changes. The reviewer is explicitly blocking the merge because they want to explore alternative approaches to solving the exponential union growth problem.

There's nothing actionable to implement here - the maintainer wants to investigate different solutions before deciding on the right approach. The PR should remain open but not be merged until ondrejmirtes has had a chance to try their alternative ideas.

No commits or pushes are needed since no code changes were requested.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants