Skip to content

Fix two regressions from NarrowedSubjectType narrowing - #6586

Merged
ondrejmirtes merged 2 commits into
2.2.xfrom
fix-narrowed-subject
Sep 25, 2026
Merged

ondrejmirtes merged 2 commits into
2.2.xfrom
fix-narrowed-subject

Conversation

@ondrejmirtes

Copy link
Copy Markdown
Member

Closes phpstan/phpstan#15310
Closes phpstan/phpstan#15296

Two regressions from #6480, one commit each.

Keep the normalized branches of a traversed ConditionalType normalized (#15310)

ConditionalType::traverse() built a new ConditionalType from the mapped normalized branches, and the new object normalized them again. When an enclosing conditional type on the same template type narrows the subject, every nested level gets a new subject and is rebuilt. Each rebuilt level then traverses the rest of the chain again. Analysis time grew about 4× per nesting level: 9 levels took 5 s, 11 took 68 s, and Symfony's 15-level BuiltinType exhausted memory. Before #6480, identity (===) matching never matched the nested subjects, so nothing was rebuilt.

The mapped branches are images of normalized branches, and the narrowed references follow the subject's mapping on their own. So the new object now keeps them as its normalized branches. 15 levels: 0.74 s.

Test: AnalyserIntegrationTest::testBug15310 with the reporter's standalone snippet. It runs out of 1 GB on 2.2.x.

Narrow the parameter for nested conditions on the same parameter (#15296)

($key is Model ? A : ($key is Arrayable ? B : C)): the inner condition used to see ?User ~ Model. That only worked because the enclosing ConditionalType matched the inner subject by identity, since both levels were built from the same argument type object. #6480 removed identity matching on purpose (#15268).

ConditionalTypeForParameter::resolveInType() now resolves the parameter as subject & target inside the if branch and subject ~ target inside the else branch. It recognises the nested conditions by parameter name, at any depth. This covers everything that goes through resolveInType(): @return, @param-out, @phpstan-assert, @throws, @phpstan-self-out.

Test: nsrt/bug-15296.php with the reporter's snippet, plus is not, if-branch and 3-level variants. 4 assertions fail on 2.2.x.

Both tests fail without their fix. The full suite, make phpstan and make cs pass.

2.3.x needs the C++ counterparts in ConditionalType.cpp and ConditionalTypeForParameter.cpp after the merge-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Uob4ZH23SzXKt3uM5tDUrQ

ondrejmirtes and others added 2 commits September 25, 2026 07:17
ConditionalType::traverse() maps the normalized branches and builds a new
ConditionalType from them, which normalized them again. When the subject is
narrowed in a branch of an enclosing conditional type on the same template
type, every nested conditional type gets a new subject, is rebuilt, and each
rebuilt level normalizes - traverses - the rest of the chain again. A chain
of n nested conditions took about 4^n: 15 levels exhausted any memory limit.

The mapped branches are images of normalized branches: the references to the
subject in them were already narrowed, and follow whatever the subject maps
to. Normalizing them again against the mapped subject would only narrow
unrelated types that happen to equal it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uob4ZH23SzXKt3uM5tDUrQ
In `($key is Model ? A : ($key is Arrayable ? B : C))` the inner condition
asks about the part of the argument that is not a Model. That narrowing
used to happen only because both conditions were resolved against the same
argument type object, which the enclosing ConditionalType matched by
identity - gone since branches are narrowed through NarrowedSubjectType.

ConditionalTypeForParameter::resolveInType() now resolves the parameter as
`subject & target` inside the if branch and `subject ~ target` inside the
else branch, recognising the nested conditions by the parameter name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uob4ZH23SzXKt3uM5tDUrQ
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.

1 participant