Do not merge dead scope into break exit points when loop body always terminates#5541
Open
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
Open
Do not merge dead scope into break exit points when loop body always terminates#5541phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
Conversation
…terminates - When all paths in a loop body terminate (break/return/throw), the scope returned by getScope() is the unreachable "dead" scope after the always-terminating statement. This dead scope was being merged into the final post-loop scope, polluting it with stale types. - Fix unrolled constant-array foreach: skip dead iterEndScope when body always terminated; when no iteration completed normally, build endScope from only break scopes instead of merging into chainScope. - Fix non-unrolled foreach: when body always terminated, start finalScope from null and build it from only continue/break scopes. - Fix while loop: capture isAlwaysTerminating before filterOutLoopExitPoints; when body always terminated, start break scope from null instead of dead finalScope. - Fix do-while loop: same pattern — start break scope from null when body always terminated. - Fix for loop: same pattern — start break scope from null when body always terminated. - Update for-loop-i-type.php test: immediate break in for($i=1;$i<50) now correctly gives int<1,49> instead of int<1,max> since the dead scope's $i>=50 range no longer leaks through.
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
When all paths in a loop body terminate (e.g. every branch of an if/else ends with
break),NodeScopeResolverwas merging the unreachable "dead" scope fromgetScope()into the post-loop scope. This caused stale variable types to leak into the result. For example, in aforeach (["a", "b", "c"] as $tag)where every branch sets$tag = nulland breaks, PHPStan reported$tagas'c'|nullinstead ofnull.Changes
Unrolled constant-array foreach (
tryProcessUnrolledConstantArrayForeachinsrc/Analyser/NodeScopeResolver.php):isAlwaysTerminating()beforefilterOutLoopExitPoints()to detect dead scopes$iterEndScopeto null (skip dead scope) and only use continue exit point scopes$chainScopewhen no normal completion occurred for an iterationendScopefrom only break scopes instead of merging breaks into the pre-loopchainScopeNon-unrolled foreach (main foreach body processing):
isAlwaysTerminating()before filtering$finalScopefrom null when body always terminatedWhile loop: capture
isAlwaysTerminating()before filtering; when body always terminated, start break scope from null instead of dead$finalScopeDo-while loop: same fix — start break scope from null when body always terminated
For loop: same fix — start break scope from null when body always terminated
Updated test
tests/PHPStan/Analyser/nsrt/for-loop-i-type.php: immediatebreakinfor($i=1; $i<50; $i++)now correctly givesint<1, 49>instead ofint<1, max>(the dead scope's$i >= 50range no longer leaks)Root cause
When all paths in a loop body terminate (break/return/throw),
processStmtNodesInternalreturns the scope after the always-terminating statement — a "dead" scope representing unreachable code. This scope still carries variable types from before the terminating statement. When break exit points were merged with this dead scope (viamergeWith), the stale types became a union with the break scope types, producing incorrect wider types.The pattern affected all five loop constructs:
foreach(both unrolled and non-unrolled paths),while,do-while, andfor. The fix capturesisAlwaysTerminating()beforefilterOutLoopExitPoints()(which strips the flag for break/continue-based termination) and uses it to exclude the dead scope from the final result.Test
tests/PHPStan/Analyser/nsrt/bug-1946.php: comprehensive regression test covering:Fixes phpstan/phpstan#1946