Skip to content

Pass the dim fetch paired with the offset to shouldKeepList() and guard the tracked-link setExistingOffsetValueType() shortcut - #6466

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-5kqlgoj
Sep 17, 2026
Merged

ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-5kqlgoj

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A nested offset write like $a[X][Y] = … checked the wrong link of the dim-fetch chain at each dimension. produceArrayDimFetchAssignValueToWrite() walks array_reverse($offsetTypes), so at iteration $i the offset belongs to $dimFetchStack[$lastDimKey - $i], but the code looked up $dimFetchStack[$i]. With two or more dimensions the outermost offset was paired with the innermost link and vice versa, which produced both a false positive and unsound list<…> types:

  • $groups[array_key_last($groups)][] = $value; lost the outer list (return.type false positive), because shouldKeepList() was asked about $groups[…][] while the outer write was being composed.
  • $matrix[array_key_last($matrix)][$column] = $value; kept the row as a list, because the array_key_last() pattern was matched while $column was written into the row.
  • isset($matrix[$row]); $matrix[$row][$column] = $value; kept the row as a list, because the tracked $matrix[$row] selected setExistingOffsetValueType() for the write of $column into the row.

Changes

src/Analyser/ExprHandler/AssignHandler.php:

  • produceArrayDimFetchAssignValueToWrite() destructures the dim fetch from its own $offsetTypes entry (foreach (… as $i => [$offsetType, $writtenDimFetch])) and passes that to shouldKeepList(), so every list-keeping pattern is matched against the dimension it belongs to. The $arrayDimFetch !== null checks became unreachable and were dropped — $offsetTypes and $dimFetchStack always have the same length.
  • The setExistingOffsetValueType() shortcut keeps consulting the tracked chain link, but when that link is not the one being written, the new trackedLinkImpliesOffset() requires the written offset to stay within the container's key type. A tracked neighbouring link does not prove the written offset exists; it is only the usual evidence when the key comes from the written structure itself (foreach ($rows as $k => $v) { $matrix[$i][$k] = …; }).
  • isSameVariable() (used by the count(), array_key_first()/array_key_last() and array_search() patterns) now also compares property fetches, static property fetches and nested dim fetches, with isSameOffset() comparing literal and variable offsets. Only side-effect-free forms are compared.

Analogous cases probed and fixed by the same change (each covered by a test in tests/PHPStan/Analyser/nsrt/bug-15245.php):

  • read-modify-write forms of the same nested target: +=, ++, ??=
  • three-dimensional chains, and appends ($cube[…][0][] = …) at any depth
  • $matrix[count($matrix) - 1][$column] = … (the count() pattern at the outer dimension)
  • writes through a property root ($h->matrix[array_key_last($h->matrix)][$column]) and through a static property root
  • list-keeping patterns whose container is a property, a static property or a nested dim fetch, which previously only worked for plain variables

Probed and found already correct, so no test was kept for them: isset($matrix[$row][$column]) followed by a write to the same cell (both links tracked, so both pairings agree), and unset()'s ExistingArrayDimFetch write path, which does not index $dimFetchStack by the loop variable.

Deliberately left alone: $list[array_key_first($list)] = … on a possibly empty list still yields non-empty-list<…> even though array_key_first([]) is null. tests/PHPStan/Analyser/nsrt/bug-14245.php::overwriteKeyFirstMaybeEmptyArray() documents that trade-off explicitly, so it is not treated as part of this bug.

Root cause

One index mismatch with two consequences, both in produceArrayDimFetchAssignValueToWrite():

  1. shouldKeepList() received a dim fetch from the opposite end of the chain, so the $list[array_key_last($list)], $list[count($list) - n], $list[array_search(…)] and $list[$i + 1] patterns were matched at the wrong dimension — dropping list where it holds and adding it where it does not.
  2. The same mismatched dim fetch decided between setExistingOffsetValueType() (which keeps list and adds non-empty) and setOffsetValueType().

Only the middle link of an odd-length chain happened to line up, which is why one-dimensional writes were always fine. The fix pairs each offset with its own link; for the write-semantics decision the historical lookup is kept but is no longer accepted when the written offset can leave the container's key range, which is exactly the case the issue reports.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15245.php — the three functions from the issue verbatim, asserting non-empty-list<non-empty-list<int>>, non-empty-list<array<int, int>> and non-empty-list<non-empty-array<int, int>>, plus the analogous cases listed above. 14 of its assertions fail without the fix.
  • tests/PHPStan/Rules/Functions/ReturnTypeRuleTest::testBug15245() with tests/PHPStan/Rules/Functions/data/bug-15245.php — locks in that the return.type false positive on appendToLastGroup() is gone and that the two false negatives on setInLastRow() and setInExistingRow() are now reported.

Fixes phpstan/phpstan#15245

…uard the tracked-link `setExistingOffsetValueType()` shortcut

- `AssignHandler::produceArrayDimFetchAssignValueToWrite()` walks the chain of a
  nested offset write with `array_reverse($offsetTypes)`, so at iteration `$i`
  the offset belongs to `$dimFetchStack[$lastDimKey - $i]`, while the code looked
  up `$dimFetchStack[$i]`. For chains of two or more dimensions the outermost
  offset was paired with the innermost link and vice versa. The dim fetch is now
  destructured from its own `$offsetTypes` entry and handed to `shouldKeepList()`,
  so `$list[array_key_last($list)]`, `$list[count($list) - 1]`,
  `$list[array_search(...)]` and `$list[$i + 1]` are recognised for the dimension
  they actually belong to.
- The `setExistingOffsetValueType()` shortcut still consults the tracked chain
  link, but when that link is not the one being written it now only counts as
  evidence while the written offset stays within the container's key type
  (`trackedLinkImpliesOffset()`). This removes the false negative of
  `isset($matrix[$row]); $matrix[$row][$column] = ...` keeping the row a list,
  while nested `foreach` writes such as `$convert[$outerKey][$key] = ...` keep
  theirs.
- `isSameVariable()` now also compares property fetches, static property fetches
  and nested dim fetches, so the list-keeping patterns work for
  `$this->list[array_key_last($this->list)]`, `Foo::$list[...]` and
  `$matrix[0][count($matrix[0]) - 1]`, not only for plain variables.
- Analogous cases covered by the same fix: `+=`/`??=`/`++` on a nested offset,
  three-dimensional chains, appends (`[]`) at any depth, and writes through
  property and static property roots.
Comment on lines +2255 to +2257
if ($a instanceof Node\Scalar\String_ && $b instanceof Node\Scalar\String_) {
return $a->value === $b->value;
}

@staabm staabm Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in arrays numeric strings and int scalars can also be the same: https://3v4l.org/etr8b#v

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The monitor was only watching the test run, which already finished green — no need to re-arm.

Work is complete: the review comment is addressed, committed as f32c5b5c0, and pushed to create-pull-request/patch-5kqlgoj.

`isSameOffset()` compared literal offsets per node kind, so `$a[1]` and
`$a['1']` were seen as different offsets even though they address the
same element. Convert each literal to its array key type via
`toArrayKey()` and compare those instead, which also handles float
literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes merged commit 35877fd into phpstan:2.2.x Sep 17, 2026
858 of 891 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-5kqlgoj branch September 17, 2026 09:38
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.

3 participants