Skip to content

Compare constant scalars refined by accessory types against other union members in TypeCombinator::union() - #6863

Merged
staabm merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-cc6qwzi
Oct 28, 2026
Merged

staabm merged 2 commits into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-cc6qwzi

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Analysing method_exists($class, $method) inside a foreach over a constant array, where $class is a constant class-string defined before the loop, took time exponential in the array size (12 elements never finished). The type of $class after the loop was a union of every subset of 'Foo'&hasMethod(mX) refinements — 2^N members. This change lets TypeCombinator::union() collapse 'Foo'|('Foo'&hasMethod(m1)) to 'Foo', so the type stays 'Foo' and analysis is instant again.

Changes

  • src/Type/TypeCombinator.php: in doUnion(), only plain constant scalars go into the scalar buckets. Intersections whose isConstantScalarValue() is yes (a constant scalar refined by an accessory type such as hasMethod()/hasProperty()) stay with the regular types.
  • turbo-ext/src/TypeCombinator.cpp: same change in the native mirror.
  • phpstan-baseline.neon: bumped the instanceof IntersectionType count for TypeCombinator.php by one.

Root cause

doUnion() moves every type with isConstantScalarValue()->yes() into $scalarTypes, grouped by get_class() and keyed by description. Members of these groups are compared only against the non-scalar types, never against each other or against other groups. 'Foo' went into the ConstantStringType group and 'Foo'&hasMethod(m1) into the IntersectionType group, so the subtype relation between them was never checked and both stayed in the union. Every later method_exists() check doubled the number of union members. Now these intersections take the general path, where compareTypesInUnion() absorbs them into the plain constant ('Foo' is a supertype of 'Foo'&hasMethod(m1)) or into a less refined intersection.

Analogous cases probed:

  • property_exists() on a constant class-string (hasProperty()): had the same problem and is fixed by this change. It is covered by the test.
  • Enum cases, objects and integers passed to method_exists()/property_exists(): already collapsed correctly.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15591.php has three cases: the reproducer from the issue (asserts $class is 'Foo' after the loop), straight-line method_exists() checks, and property_exists() checks. Without the fix the loop case hangs: the test was killed after 60s.
  • tests/bench/data/bug-15591.php adds the reproducer from the issue to the regression benchmark.

Fixes phpstan/phpstan#15591

🤖 Generated with Claude Code

Comment thread src/Type/TypeCombinator.php Outdated
// a constant scalar refined by an accessory type ('Foo'&hasMethod(bar))
// stays with the other types so it is compared against the plain
// constant scalars and other refinements of the same value
if ($types[$i]->isConstantScalarValue()->yes() && !$types[$i] instanceof IntersectionType) {

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.

do the cheaper !$types[$i] instanceof IntersectionType check first

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.

I made the change staabm asked for and pushed it to the PR branch as commit 1747fa9df.

  • src/Type/TypeCombinator.php: the condition in doUnion() now checks !$types[$i] instanceof IntersectionType first. isConstantScalarValue() is only called for types that pass that check.
  • turbo-ext/src/TypeCombinator.cpp: I made the same change in the native copy. It now checks for an intersection type first and only calls isConstantScalarValue() when that check passes.

Checks run:

  • make tests: 22,632 tests passed, 106 skipped.
  • make phpstan: passed.
  • Native extension: builds with the strict warning flags, and its smoke test prints ALL OK.
  • With the native extension loaded: the bug-15591 test and TypeCombinatorTest pass. The full test suite was not run with the extension loaded.

@staabm staabm left a comment

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 verified it speeds up the repro considerably.

before the PR it did not finish in a useful time.
after the fix it takes 1,7s on my machine

@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-cc6qwzi branch from 1747fa9 to b423181 Compare October 27, 2026 18:45
phpstan-bot and others added 2 commits October 28, 2026 07:05
…on members in `TypeCombinator::union()`

- `TypeCombinator::union()` buckets every type whose `isConstantScalarValue()` is yes into
  per-class scalar groups that are never compared with each other. An intersection such as
  `'Foo'&hasMethod(m1)` (produced by `method_exists()` / `property_exists()` on a constant
  class-string) landed in the `IntersectionType` bucket, so `'Foo'|('Foo'&hasMethod(m1))`
  was never collapsed to `'Foo'`.
- After N `method_exists($class, $method)` checks (e.g. a foreach over a constant array
  being unrolled) the type of `$class` grew to 2^N union members, making analysis time
  exponential.
- Such intersections now stay with the regular types, where `compareTypesInUnion()`
  absorbs them into the plain constant scalar or into a less refined intersection.
- Ported the same change to the native mirror in `turbo-ext/src/TypeCombinator.cpp`.
- Probed analogous constructs: `property_exists()` on a constant class-string had the same
  problem and is fixed by the same change (covered by the test); enum cases, objects and
  integers passed to `method_exists()`/`property_exists()` already collapsed correctly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TypeCombinator::union()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-cc6qwzi branch from b423181 to bb1e834 Compare October 28, 2026 06:05
@staabm
staabm merged commit 7e4d49c into phpstan:2.3.x Oct 28, 2026
1031 checks passed
@staabm
staabm deleted the create-pull-request/patch-cc6qwzi branch October 28, 2026 06:38
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.

Analysis time grows exponentially with method_exists() on a class-string variable inside a foreach over a constant array (regression in 2.3.0)

2 participants