Repository navigation
Compare constant scalars refined by accessory types against other union members in TypeCombinator::union() - #6863
Merged
staabm merged 2 commits intoOct 28, 2026
Conversation
staabm
reviewed
Oct 27, 2026
| // 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) { |
Contributor
There was a problem hiding this comment.
do the cheaper !$types[$i] instanceof IntersectionType check first
Collaborator
Author
There was a problem hiding this comment.
I made the change staabm asked for and pushed it to the PR branch as commit 1747fa9df.
src/Type/TypeCombinator.php: the condition indoUnion()now checks!$types[$i] instanceof IntersectionTypefirst.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 callsisConstantScalarValue()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-15591test andTypeCombinatorTestpass. The full test suite was not run with the extension loaded.
staabm
approved these changes
Oct 27, 2026
staabm
left a comment
Contributor
There was a problem hiding this comment.
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
force-pushed
the
create-pull-request/patch-cc6qwzi
branch
from
October 27, 2026 18:45
1747fa9 to
b423181
Compare
…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
force-pushed
the
create-pull-request/patch-cc6qwzi
branch
from
October 28, 2026 06:05
b423181 to
bb1e834
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Analysing
method_exists($class, $method)inside aforeachover a constant array, where$classis a constant class-string defined before the loop, took time exponential in the array size (12 elements never finished). The type of$classafter the loop was a union of every subset of'Foo'&hasMethod(mX)refinements — 2^N members. This change letsTypeCombinator::union()collapse'Foo'|('Foo'&hasMethod(m1))to'Foo', so the type stays'Foo'and analysis is instant again.Changes
src/Type/TypeCombinator.php: indoUnion(), only plain constant scalars go into the scalar buckets. Intersections whoseisConstantScalarValue()is yes (a constant scalar refined by an accessory type such ashasMethod()/hasProperty()) stay with the regular types.turbo-ext/src/TypeCombinator.cpp: same change in the native mirror.phpstan-baseline.neon: bumped theinstanceof IntersectionTypecount forTypeCombinator.phpby one.Root cause
doUnion()moves every type withisConstantScalarValue()->yes()into$scalarTypes, grouped byget_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 theConstantStringTypegroup and'Foo'&hasMethod(m1)into theIntersectionTypegroup, so the subtype relation between them was never checked and both stayed in the union. Every latermethod_exists()check doubled the number of union members. Now these intersections take the general path, wherecompareTypesInUnion()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.method_exists()/property_exists(): already collapsed correctly.Test
tests/PHPStan/Analyser/nsrt/bug-15591.phphas three cases: the reproducer from the issue (asserts$classis'Foo'after the loop), straight-linemethod_exists()checks, andproperty_exists()checks. Without the fix the loop case hangs: the test was killed after 60s.tests/bench/data/bug-15591.phpadds the reproducer from the issue to the regression benchmark.Fixes phpstan/phpstan#15591
🤖 Generated with Claude Code