Skip to content

Compare conditional expressions in MutatingScope::equals() - #6344

Open
janedbal wants to merge 2 commits into
phpstan:2.2.xfrom
janedbal:array-shift-loop-list-offset
Open

Compare conditional expressions in MutatingScope::equals()#6344
janedbal wants to merge 2 commits into
phpstan:2.2.xfrom
janedbal:array-shift-loop-list-offset

Conversation

@janedbal

@janedbal janedbal commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Since 2.2.10 the loop handlers skip a verification pass when the entry scope is unchanged, and they replay the recorded pass instead of the final walk. Both shortcuts compare entry scopes with MutatingScope::equals(), which ignored conditional expressions. Two scopes with equal expression types but different conditional expressions do not walk identically, because a later condition narrows through them.

Reproducer: a while nested in another loop. The first pass enters with if $toCurrent is int<1, max> then $items is non-empty-list from the preceding if/else merge. The body invalidates it, so the second entry lacks it, but the two entries compare equal. The first pass is replayed, and array_shift($items) inside the loop is reported as never null (identical.alwaysFalse).

Bisected to 31a3604 ("Replay the recorded fixpoint pass instead of repeating the final loop walk"). 2.2.9 infers list<Item> and Item|null here.

equals() now also compares the conditional expressions. Full test suite passes.

Closes phpstan/phpstan#15157

The loop handlers call equals() to detect an unchanged entry scope. Since
2.2.10 they skip the verification pass on an unchanged entry and replay
the recorded pass instead of the final walk. Both shortcuts assume that
equal scopes walk identically. But equals() ignored the conditional
expressions, and those narrow types when a later condition is specified.

A while loop nested in another loop shows the gap. The entry of the first
pass still holds "if $toCurrent is int<1, max> then $items is
non-empty-list" from the preceding if/else merge. The body invalidates it,
so the second entry lacks it, but the expression types match and the
second entry compares equal. The first pass is replayed, and
array_shift($items) inside the loop is reported as never null.

Co-Authored-By: Claude Code
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.

array_shift() in a nested while typed as never null since 2.2.10 (stale conditional narrowing replayed)

1 participant