Skip to content

Build a loop's back edge from its continue statements when the body end is unreachable - #6420

Merged
ondrejmirtes merged 2 commits into
2.3.xfrom
loop-back-edge-continue-points
Sep 11, 2026
Merged

Build a loop's back edge from its continue statements when the body end is unreachable#6420
ondrejmirtes merged 2 commits into
2.3.xfrom
loop-back-edge-continue-points

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 11, 2026

Copy link
Copy Markdown
Member

Closes phpstan/phpstan#14418
Closes phpstan/phpstan#13959
Closes phpstan/phpstan#11919
Closes phpstan/phpstan#1946

InternalStatementResult::filterOutLoopExitPoints() marks a loop body that contains break or continue as not always terminating, and the loop handlers then treated the end of the body as reachable. When the body ends in return or throw, that dead scope was merged into the next iteration and into the scope after the loop. In the issue the end of the try/catch is unreachable, TryCatchHandler lets the scope from before the try stand in for it, and $e, assigned on the only path that loops back, came out as possibly undefined.

InternalStatementResult now keeps whether the end of the statements is reachable. isEndReachable() is not reset by filterOutLoopExitPoints(), and getLoopBackEdgeScope() returns the scope the next iteration starts from: the reachable end merged with the continue statements, or null when no iteration follows. While, do-while, for and foreach loops, including the unrolled iterations of constant arrays, build their convergence passes and post-loop scopes from it. A loop whose back edge is dead is left only through break.

What changes for users

  • Variables assigned before continue are defined after the loop when the body cannot otherwise loop back. The same holds for break-only bodies, continue 2 from an inner loop or a switch, and a catch with finally. Loops that may not run at all still report the variable as possibly undefined.
  • Types after such loops are more precise. nsrt/for-loop-i-type.php now infers 1 for for ($i = 1; $i < 50; $i++) { break; }, where the increment never runs. The old expectation is the same on 2.2.x, so no branch had pinned it. The sharper types can surface real errors: in added regression test #1946 every iteration breaks, so $tag is null and passing it to strlen() is now reported.
  • New always-true reports at level 4. A loop condition evaluated only once, because the body never loops back, is now reported, for example for ($i = 0; $i < 3; $i++) { exit(1); }. These reports are correct and appear only where the end of the body is already unreachable.
  • WhileHandler narrows continue scopes by the falsey condition too, like the end of the body.

Not changed: a continue inside a try whose finally assigns a variable still reports that variable as possibly undefined after the loop, because TryCatchHandler does not apply the finally to continue exit points.

Tests

  • DefinedVariableRuleTest::testBug14418 runs the sample from the issue with the default polluteScopeWithLoopInitialAssignments: true. With that option off, a for loop that always runs merges the scope before the loop into the scope after it by design, so the report stays there.

  • nsrt/bug-14418.php covers for, while, do-while, foreach and unrolled foreach with continue, break-only bodies, while (true), continue 2, continue in a switch, catch with finally, and types after the loop and in the loop head. It also has the controls that must stay possibly undefined.

  • Regression tests for the other issues the issue bot reports as fixed:

    • nsrt/bug-13959.php: a foreach whose body otherwise throws narrows the iterated list through its continue statements only.
    • nsrt/bug-1946.php and CallToFunctionParametersRuleTest::testBug1946: every iteration of an unrolled foreach breaks, so $tag is null after the loop.
    • CallToFunctionParametersRuleTest::testBug11919: a foreach body that always ends in continue 2 no longer runs a second iteration with the reassigned $row. It requires PHP 8, because fgetcsv() can still return null before PHP 8.

The new tests and the for-loop-i-type.php expectation fail without the fix.

Verification

  • Full suite: 21797 tests, 97389 assertions, OK.
  • make phpstan: no errors.
  • phpcs and lint on the changed files: clean.

The PR targets 2.3.x because the four loop handlers differ from 2.2.x by 30 to 130 lines each. This PR and #6421 both change the convergence loops. They conflict in DoWhileHandler and ForHandler, so whichever lands second needs a rebase.

🤖 Generated with Claude Code

https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC

…nd is unreachable

filterOutLoopExitPoints() marks a loop body containing break or continue
as not always terminating, and the loop handlers then treated the end of
the body as reachable. When the body ends in return or throw, that dead
scope was merged into the next iteration and into the scope after the
loop, so a variable assigned on the only path that loops back came out
as possibly undefined.

InternalStatementResult now keeps whether the end of the statements is
reachable, and getLoopBackEdgeScope() returns the scope the next
iteration starts from: the reachable end merged with the continue
statements, or null when no iteration follows. While, do-while, for and
foreach loops, including unrolled constant-array iterations, build their
convergence passes and post-loop scopes from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC
@phpstan-bot

Copy link
Copy Markdown
Collaborator

You've opened the pull request against the latest branch 2.3.x. PHPStan 2.3 is not going to be released for months. If your code is relevant on 2.2.x and you want it to be released sooner, please rebase your pull request and change its target to 2.2.x.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC
@ondrejmirtes
ondrejmirtes merged commit 248723e into 2.3.x Sep 11, 2026
745 of 809 checks passed
@ondrejmirtes
ondrejmirtes deleted the loop-back-edge-continue-points branch September 11, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants