Repository navigation
Pass the dim fetch paired with the offset to shouldKeepList() and guard the tracked-link setExistingOffsetValueType() shortcut - #6466
Merged
ondrejmirtes merged 2 commits intoSep 17, 2026
Conversation
…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.
staabm
reviewed
Sep 17, 2026
Comment on lines
+2255
to
+2257
| if ($a instanceof Node\Scalar\String_ && $b instanceof Node\Scalar\String_) { | ||
| return $a->value === $b->value; | ||
| } |
Contributor
There was a problem hiding this comment.
in arrays numeric strings and int scalars can also be the same: https://3v4l.org/etr8b#v
Collaborator
Author
There was a problem hiding this comment.
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>
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.
Summary
A nested offset write like
$a[X][Y] = …checked the wrong link of the dim-fetch chain at each dimension.produceArrayDimFetchAssignValueToWrite()walksarray_reverse($offsetTypes), so at iteration$ithe 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 unsoundlist<…>types:$groups[array_key_last($groups)][] = $value;lost the outerlist(return.typefalse positive), becauseshouldKeepList()was asked about$groups[…][]while the outer write was being composed.$matrix[array_key_last($matrix)][$column] = $value;kept the row as alist, because thearray_key_last()pattern was matched while$columnwas written into the row.isset($matrix[$row]); $matrix[$row][$column] = $value;kept the row as alist, because the tracked$matrix[$row]selectedsetExistingOffsetValueType()for the write of$columninto the row.Changes
src/Analyser/ExprHandler/AssignHandler.php:produceArrayDimFetchAssignValueToWrite()destructures the dim fetch from its own$offsetTypesentry (foreach (… as $i => [$offsetType, $writtenDimFetch])) and passes that toshouldKeepList(), so every list-keeping pattern is matched against the dimension it belongs to. The$arrayDimFetch !== nullchecks became unreachable and were dropped —$offsetTypesand$dimFetchStackalways have the same length.setExistingOffsetValueType()shortcut keeps consulting the tracked chain link, but when that link is not the one being written, the newtrackedLinkImpliesOffset()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 thecount(),array_key_first()/array_key_last()andarray_search()patterns) now also compares property fetches, static property fetches and nested dim fetches, withisSameOffset()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):+=,++,??=$cube[…][0][] = …) at any depth$matrix[count($matrix) - 1][$column] = …(thecount()pattern at the outer dimension)$h->matrix[array_key_last($h->matrix)][$column]) and through a static property rootProbed 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), andunset()'sExistingArrayDimFetchwrite path, which does not index$dimFetchStackby the loop variable.Deliberately left alone:
$list[array_key_first($list)] = …on a possibly empty list still yieldsnon-empty-list<…>even thougharray_key_first([])isnull.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():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 — droppinglistwhere it holds and adding it where it does not.setExistingOffsetValueType()(which keepslistand adds non-empty) andsetOffsetValueType().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, assertingnon-empty-list<non-empty-list<int>>,non-empty-list<array<int, int>>andnon-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()withtests/PHPStan/Rules/Functions/data/bug-15245.php— locks in that thereturn.typefalse positive onappendToLastGroup()is gone and that the two false negatives onsetInLastRow()andsetInExistingRow()are now reported.Fixes phpstan/phpstan#15245