Skip to content

Mark for/while loops as never exiting when every fall-through iteration keeps the condition satisfied#5907

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-4dc0xyv
Open

Mark for/while loops as never exiting when every fall-through iteration keeps the condition satisfied#5907
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-4dc0xyv

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan reported a false Method ... should return ... but return statement is missing. error for methods whose only body is a for/while loop containing a try { return ...; } where the boundary iteration's catch always terminates (e.g. a match($i) with a default => throw, or an if ($i >= 5) throw). In such loops the condition can never become false because the only iteration that could let it exit always returns or throws, so the code after the loop is in fact unreachable.

The root cause is that loop-scope generalization widens the loop variable ($iint), which loses the bound that proves the loop never exits normally. As a result PHPStan thought execution could fall through the loop.

Changes

  • src/Analyser/NodeScopeResolver.phpFor_ handling: after building the non-generalized $loopScope (body fall-through scope with the loop expressions applied), if the last condition is still always true there, set $alwaysIterates = TrinaryLogic::createYes(). The check is gated on $context->isTopLevel(), $isIterableAtLeastOnce->yes(), and the body not already being always-terminating.
  • src/Analyser/NodeScopeResolver.phpWhile_ handling: mirror the same check using $finalScopeResult->getScope() (the body-end scope, which already includes the in-body increment) to re-evaluate the condition; set $alwaysIterates = true when it is always true.
  • do-while (Do_) already produced the correct result and was left unchanged; foreach has no value-narrowable continuation condition, so it is unaffected.

Root cause

After the loop-body fixpoint, the body's fall-through scope is a sound over-approximation of every non-terminating iteration. If applying the loop's continuation expression to that scope still satisfies the loop condition, then any iteration that does not return/throw immediately re-enters the loop — the loop can never terminate via its condition. The existing code only recognized this for literal infinite loops (while (true)), because for a counter-based condition the generalized loop variable made the condition maybe rather than true. The fix re-evaluates the condition on the precise (pre-generalization) post-iteration scope instead, where the in-body narrowing (match/if with throw) still constrains the variable. Because the post-iteration scope is an over-approximation, the condition is only treated as always-true when it genuinely is, so no real missing-return is suppressed.

Test

  • tests/PHPStan/Rules/Missing/data/bug-9444.php + MissingReturnRuleTest::testBug9444 reproduce the reported case (for loop with a match whose default arm throws) plus two analogous cases: a for loop with if ($i >= 5) throw and a while ($i <= 5) variant. All three were false positives before the fix and report no error after it.
  • Verified that genuine missing returns are still reported (a for/while whose catch only logs and continues, and a loop that can exit via its condition), and that $codes-style accumulator inference inside nested loops (regression in nsrt/bug-13705.php) is preserved by guarding the for check to the top-level pass.
  • make tests (17434 tests), make phpstan, and make cs-fix all pass.

Fixes phpstan/phpstan#9444

…ration keeps the condition satisfied

- In NodeScopeResolver's `For_` handling, after computing the body's
  fall-through scope and applying the loop expressions, check whether the
  loop condition is still always true. If so, the loop can only ever leave
  via `break`/`return`/`throw`, so it is treated as always-iterating and the
  code after it becomes unreachable (no false `return.missing`).
- Apply the same reasoning to `While_`, using the body-end scope to
  re-evaluate the condition.
- Both checks are guarded to the top-level loop pass, require the loop to
  iterate at least once, and only fire when the body is not already
  always-terminating, so genuine missing-return cases (e.g. a catch that
  merely logs and continues) keep being reported.
- `do-while` already handled this correctly and needed no change; `foreach`
  has no value-narrowable condition and is unaffected.
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.

return statement is missing

2 participants