Skip to content

Widen only the variables a loop can write when its scope converges - #6421

Merged
ondrejmirtes merged 1 commit into
2.3.xfrom
loop-generalize-written-variables
Sep 11, 2026
Merged

Widen only the variables a loop can write when its scope converges#6421
ondrejmirtes merged 1 commit into
2.3.xfrom
loop-generalize-written-variables

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 11, 2026

Copy link
Copy Markdown
Member

Closes phpstan/phpstan#12666

When a loop's scope converges, MutatingScope::generalizeWith() widened every variable whose type differed between two passes. A variable the loop never writes differs only through narrowing by the loop's condition. In while ($xi < $xn) { $xi += 0.1; } the first pass narrows $xn to int<2, max>, the next pass cannot once $xi is a float, and generalizing the two turned int<1, max> into int.

Such a variable enters every iteration with its value from before the loop, so the convergence passes of while, do-while, for and foreach loops now union its types instead of generalizing them. LoopWrittenVariableNames collects the variables a loop can write:

  • From the loop's AST: assignments, increments, destructuring, foreach bindings, catch, static, global, unset and by-reference closure uses, cached on the loop node. Variable variables, extract(), parse_str(), eval and include make the set unknown, and every variable is widened as before.
  • From the variable flow of the convergence pass (Report values assigned to variables that are never read #6330, Report variable writes whose value never reaches a use, per array offset #6334), because whether an argument is passed by reference is known only from the callee's reflection. Treating every argument as a write would undo the fix as soon as the variable is passed to any call inside the loop.
  • From references created before the loop, read off the scope in generalizeWith(). $ref = &$xn; followed by $ref = $xn + 1; inside the loop still widens $xn.

The goto and closure by-reference fixpoints and the post-loop generalization in ForHandler are unchanged. The PR targets 2.3.x because the variable flow it reads exists only there.

Tests

  • nsrt/bug-12666.php is the sample from the issue.
  • nsrt/loop-generalize-written-variables.php covers do-while, for, foreach, a constant array with unsealed keys and a variable passed by value inside the loop. It also covers the writes that must still widen: an increment, destructuring, a write through a reference, and array_push() in the body and in a for update.

Both files fail without the fix. The variants file also fails when the by-reference names from the flow are dropped, and when the reference scan is dropped.

Verification

🤖 Generated with Claude Code

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.

A variable the loop never writes enters every iteration with its value
from before the loop, so its type differs between convergence passes
only through narrowing by the loop's conditions. Generalizing it lost
its type for nothing: `while ($xi < $xn) { $xi += 0.1; }` widened an
`int<1, max>` $xn to `int`.

The convergence of while, do-while, for and foreach loops now unions the
types of such variables instead. The variables a loop can write are
collected from its AST, from the arguments the pass's variable flow
marks as passed by reference, and from references created before the
loop. Variable variables, extract(), parse_str(), eval and include keep
widening every variable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014LVEGd9G9w8j64EZQ7rysC
@ondrejmirtes
ondrejmirtes force-pushed the loop-generalize-written-variables branch from 362aaef to 56ccf25 Compare September 11, 2026 15:30
@ondrejmirtes
ondrejmirtes merged commit 1f2c00c into 2.3.x Sep 11, 2026
448 of 452 checks passed
@ondrejmirtes
ondrejmirtes deleted the loop-generalize-written-variables branch September 11, 2026 15:32
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.

Operations inside of loops widens type of unrelated variable used in conditional statement

2 participants