Skip to content

Make offsets written in every iteration of a non-unrolled constant-array foreach required after the loop - #6928

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-2drxnd5
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-2drxnd5

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A foreach over a constant array with more than 16 entries is not unrolled. So after $result[$value] = ... in the body, every key of $result was optional, and since 2.2.0 reading one of them reports "Offset … might not exist". With this change, the keys written in every iteration are required after the loop, the same as with 16 or fewer entries.

Changes

  • New src/Analyser/StmtHandler/ForeachIterationOffsetWritesHelper.php. On the non-unrolled path with no break, it makes the iterated keys or values required offsets of the written target.
  • src/Analyser/StmtHandler/ForeachHandler.php: calls the helper with the back-edge scopes (end of body plus continue scopes) and the body's impure points.
  • turbo-ext/src/ForeachHandler.cpp, turbo-ext/src/support.{h,cpp}: the native mirror makes the same call through a new foreachIterationOffsetWritesHelper class ref.
  • Analogous cases also fixed:
    • other write forms: ??= and other compound assignments, =&, $t[$v][] =, $t[$v]['x'] =, [$t[$v]] = ...
    • $this->prop[$v] = ... targets, only when the body has no calls, which could reset the property

Root cause

The general loop analysis merges all iterations, so the loop variable is a union of every iterated value. Writing $t[$union] can only add optional keys, and the information about which iteration wrote which key is lost. Only unrolling (capped at 16 keys) kept it.

The fix proves key presence directly instead of raising the limit:

  • every iteration ends with $t[$loopVar] set (checked in the back-edge scopes, with the loop variable unmodified);
  • the body changes $t only through offset writes, which never remove keys (checked syntactically);
  • the iteratee is a sealed constant array with only required keys, so every value is iterated.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15657.php covers:
    • the reported case, plus key-variable and value-variable targets, a loop without a key variable, the extra write forms and $this property targets;
    • negative cases that must stay optional: continue before the write, conditional write, break, unset of another key, reassignment, by-ref passing, reassigned loop variable, method calls resetting a property.
  • tests/PHPStan/Rules/Arrays/data/bug-15657.php: the issue's reproducer reports no "might not exist" error.

Fixes phpstan/phpstan#15657

…ray `foreach` required after the loop

- Add ForeachIterationOffsetWritesHelper. After a foreach over a sealed constant array with no optional keys that was not unrolled (more than FOREACH_UNROLL_LIMIT entries), it makes every iterated key or value a required offset of `$target`. This applies when each back-edge scope (end of body and every `continue`) still tracks the unmodified loop variable and `$target[$loopVar]`. It also requires the body to touch `$target` only through offsets: no reassignment, unset, by-ref passing, closure use, variable-variables, extract or include. Offset writes never remove keys, so every iterated offset exists after the loop.
- Supported write forms: `=`, compound assignments including `??=`, `=&`, nested `$t[$v][...] =` / `$t[$v][] =`, and list destructuring.
- Analogous case: `$this->prop[$loopVar] = ...` gets the same treatment, but only when the body has no method/function calls, `new`, property hooks, include/eval or yield, because those can reset the property without the scope seeing it.
- Wired into ForeachHandler and its turbo-ext mirror (ForeachHandler.cpp calls the PHP helper through a new class-ref entry).
- Probed and left unchanged: loops with `break`, conditional writes, `continue` before the write, unsetting another key, reassigning the target or the loop variable, and passing the target by reference all still keep the keys optional.
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.

Keys assigned in foreach over a constant array with more than 16 entries are optional, "Offset might not exist" since 2.2.0

1 participant