Keep list type when writing to $list[count($list)] and compare the offset's array through its printed expression - #6226
Open
phpstan-bot wants to merge 1 commit into
Conversation
…offset's array through its printed expression - `AssignHandler::shouldKeepList()` now recognizes `$list[count($list)]` / `$list[sizeof($list)]` as a write right behind the last element, so the list type survives the assignment - Replaced `isSameVariable()` with `isSameArrayExpr()`, which compares side-effect-free expressions (variables, property fetches, static property fetches and dim fetches over them) through `ExprPrinter`. All existing heuristics - `count($list) - n`, `array_key_last()`/`array_key_first()`, `array_search()` - now work on `$this->list`, `self::$list` and `$data['x']` too, not just on plain variables - Added `$list[array_key_last($list) + 1]` as another append idiom, guarded by non-emptiness because `array_key_last()` returns `null` on an empty list - Function arguments are now read through a helper that rejects unpacked and named args, so `count(...$list)` no longer looks like `count($list)` - Fixed a crash: `$list[array_search($list)]` (too few arguments) read `getArgs()[1]` unconditionally and blew up with a `TypeError` - `shouldKeepList()` is now handed the dim fetch the written offset actually belongs to; the reversed loop indexed `$dimFetchStack` the other way round, which broke every heuristic for nested writes like `$data['x'][count($data['x'])]` - Additional expression types are no longer registered when the offset expression resolves differently after the write. `$list = [1, 2, 3]; $list[count($list)] = 37;` re-evaluated `count($list)` against the already-updated array and intersected the result with `hasOffsetValue(4, 37)`, collapsing the whole array to `*NEVER*`
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
Assigning
$list[count($list)] = $valueappends right behind the last element and therefore keeps the array a list, but PHPStan degradedlist<int>tonon-empty-array<int<0, max>, int>and reportednon-empty-array<int<0, max>, int> might not be a list.AssignHandler::shouldKeepList()already knew a family of list-preserving offset idioms (count($list) - n,array_key_last(),array_key_first(),array_search(),$index + 1) but was missing the plaincount($list)one. Probing the neighbours of that heuristic turned up four more defects in the same code, all fixed here.Changes
All in
src/Analyser/ExprHandler/AssignHandler.php:shouldKeepList()recognizes$list[count($list)]and$list[sizeof($list)].isSameVariable()is replaced byisSameArrayExpr()/isStableExpr(), which compare two array expressions by their printed form after checking they are side-effect free and stable (variables, property fetches, static property fetches, and dim fetches over those with scalar or stable dims). Every heuristic inshouldKeepList()now also fires for$this->list,self::$staticList,$this->nested['x']and$data[$key].$list[array_key_last($list) + 1]append idiom, guarded byisIterableAtLeastOnce()becausearray_key_last()returnsnullfor an empty list, makingnull + 1 === 1leave a hole.isFuncCallOnSameArray()rejects unpacked (count(...$list)) and named arguments, so they no longer masquerade ascount($list).array_search()branch required>= 1arguments but readgetArgs()[1];$list[array_search($list)]crashed withAssignHandler::isSameVariable(): Argument #2 ($b) must be of type PhpParser\Node\Expr, null given. It now requires>= 2.produceArrayDimFetchAssignValueToWrite()handsshouldKeepList()the dim fetch carried by$offsetTypesinstead of$dimFetchStack[$i]. The loop walks$offsetTypesreversed, so for a nested write the two indexes point at different links of the chain. ThehasExpressionType()check above it deliberately keeps using$dimFetchStack[$i]— changing it there regresses nested constant-array precision (assign-nested-arrays.php,pr-4390.php) and is out of scope here.Root cause
Two independent root causes.
A missing member of a heuristic family, plus a too-narrow expression comparison.
shouldKeepList()enumerates offset expressions that provably keep an array a list.count($list)was simply absent from that list. The comparison that decides "is this the same array?" wasisSameVariable(), which only ever returnedtruefor twoVariablenodes — so none of the heuristics worked when the array was a property, a static property, or a nested offset. The same narrowness meant nested writes never matched either, compounded by the wrong$dimFetchStackindex.The offset expression is re-evaluated after the container changed.
applyWrite()registers the written dim fetch in the scope viaassignExpression().MutatingScope::specifyExpressionTypeInPlace()then re-reads$expr->dimin the post-assignment scope and intersects the container withHasOffsetValueType. When the dim depends on the container —count($list)— it resolves to a different value than the one that was written, and the intersection is unsatisfiable:The same collapse happened for
$list[count($list) + 1] = 37. Skipping the registration when the recomputed offset type differs from the written one fixes both.Test
tests/PHPStan/Analyser/nsrt/bug-15080.phpholds the playground reproducer verbatim plus the analogous cases found while probing:count()andsizeof()on a local variable, a property, a static property, a nested property, a nested local array, and a dim fetch with a variable key$list[count($list)] ??= $v(assign-op path),count($list) - 0, and appending in aforlooparray_key_last($list) + 1and1 + array_key_last($list)on a non-empty list, and the possibly-empty-list counterpart that must not stay a list*NEVER*regressions:$list = [1, 2, 3]; $list[count($list)] = 37;and$list[count($list) + 1] = 37;array_key_last(),count($list) - 1andarray_search()on$this->list, which the existing heuristics missednon-empty-array:count($other),count($list, COUNT_RECURSIVE),count(...$list)$list[array_search($list)], which used to abort the analysis with an internal errortests/PHPStan/Rules/Functions/ReturnTypeRuleTest::testBug15080()locks the reported symptom itself — the reproducer must analyse without thereturn.typeerror.Both fail without the fix (the rule test with the exact message from the issue, the NSRT file with 18 wrong types and an internal error) and pass with it.
make tests,make phpstanandmake csare green.Fixes phpstan/phpstan#15080