Do not let a trailing comment or an infinite loop hide a terminated block - #8476
Merged
TomasVotruba merged 1 commit intoSep 9, 2026
Conversation
…lock
isTerminatedInLastStmts() judges a block by its last stmt and accepts only a
Return_ or an exit/throw Expression. Two shapes slip past it:
try {
return something();
// nothing to do here <- parsed as Nop, and it is the last stmt
} catch (Exception $e) {
return null;
}
try {
while (true) { <- never falls through, but is not a Return_
if ($a) { return 'A'; }
$a = doSomething();
}
} catch (Exception $e) {
return null;
}
Both blocks always terminate, so isAlwaysTerminated() should say so, and callers
that ask "does this method fall through?" get false. Reported in
rectorphp/rector#9897, where it makes ConsoleExecuteReturnIntRector append an
unreachable `return Command::SUCCESS;` that PHPStan then flags as
deadCode.unreachable.
Trailing Nops are now popped before the last stmt is read, and a trailing
infinite loop goes through isTerminatedInfiniteLoop(), the same helper
isAlwaysTerminated() already uses one level up -- so a loop carrying a
break/goto still counts as falling through.
Three fixtures for RemoveUnreachableStatementRector: a try/catch with a
trailing comment, a try/catch ending in while (true), and an if/else with a
trailing comment.
Member
|
LGTM, thanks |
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.
Half of rectorphp/rector#9897. This is the analyzer half; the
hasChangedhalf is inrector-symfonyand is a separate PR.The bug
TerminatedNodeAnalyzer::isTerminatedInLastStmts()decides whether a block always terminates by looking at its last statement, and accepts only aReturn_or anexit/throwExpression. Two shapes get past it:Both blocks always terminate, so
isAlwaysTerminated()should returntruefor theTryCatchand returnsfalse. In #9897 that is what makesConsoleExecuteReturnIntRectorappend an unreachablereturn Command::SUCCESS;, which PHPStan then reports asdeadCode.unreachable.The infinite-loop case is the odd one, because
isAlwaysTerminated()already handles it — viaisTerminatedInfiniteLoop()— but only for a loop at the top level of the enclosing block. Nested one level down, inside a try, the same loop is judged byisTerminatedInLastStmts(), which has never known about loops.The fix
Trailing
Nops are popped before the last statement is read, and a trailing loop is routed through the existingisTerminatedInfiniteLoop()— so the break/goto escape analysis that helper already does is reused rather than duplicated.Verified
composer installneeds PHP ≥ 8.4.1 forphpunit/phpunit ^13.3, and this machine has 8.3.6, so I could not run the suite — CI will have to be the judge of the three fixtures. I did not want to leave the change unproven, so I checked the analyzer logic directly instead: a script that parses each shape withnikic/php-parser5.8 and runs a faithful copy ofisTerminatedInLastStmts()before and after the change, withisTerminatedInfiniteLoop()inlined for these shapes.The two reported shapes flip; the three controls do not move. The last row is the one worth pointing at — a
while (true)carrying abreakstill counts as falling through, becauseisTerminatedInfiniteLoop()does that check, which is exactly why the fix delegates to it rather than pattern-matching on the loop.Fixtures
Three, in
rules-tests/DeadCode/Rector/Stmt/RemoveUnreachableStatementRector/Fixture/, next to the existingalways_terminated_*set:always_terminated_try_catch_with_trailing_comment.php.incalways_terminated_try_catch_with_infinite_while.php.incalways_terminated_if_else_with_trailing_comment.php.incThe third one covers
isTerminatedInLastStmtsIf(), which reaches the same helper — the bug is in the shared leaf, not in the try/catch branch.Deliberately not done
isTerminatedInLastStmts()still does not recurse into a nestedTryCatch,If_orSwitch_sitting as the last statement of a block, even thoughisAlwaysTerminated()handles all three one level up. That is the same class of gap, but widening it would change the verdict for a lot of existing code, and #9897 does not need it. Happy to follow up separately if you want it closed properly.Note
This PR was drafted with AI assistance. I understand the change and can defend or revise it; the evidence table above is a run I did rather than a claim.