Keep list type when writing to $list[count($list)] and compare the offset's array through its printed expression - #6226
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*`
SanderMuller
left a comment
There was a problem hiding this comment.
I went through this one and rebuilt the claims rather than reading them, since the diff reaches a fair way past the issue it closes. Short version: the parts I expected to be risky measured out fine, and the one thing I would want your call on is the index question the description already raises.
Verified
The tests do fail without the source change. Reverting only AssignHandler.php and keeping both test files: ReturnTypeRuleTest::testBug15080 fails with the message from the issue, and the NSRT file errors out. Full suite green with the change (21331 tests), self analysis clean.
The index mismatch is real. At the construction site $dimFetchStack is reversed before $offsetTypes is built from it, so $offsetTypes[$k][1] === $dimFetchStack[$k]. The consumer then walks array_reverse($offsetTypes) while indexing $dimFetchStack[$i], so from depth 2 the two point at opposite ends of the chain. At depth 1 they coincide, which is why this only ever showed up nested.
The "out of scope" note is honest, and understated. Aligning $arrayDimFetch with $writtenDimFetch on the line above regresses five NSRT files, not the two named: pr-4390, bug-13786, bug-13637, assign-nested-arrays, bug-14084.
Concerns I had and could not substantiate
Recording these so nobody has to re-litigate them.
Comparing offsets through the printed expression is not on a hot path. This was my main worry, since isSameVariable() was two instanceof checks and a string compare. Counting calls with the branch instrumented:
| corpus | files | isSameArrayExpr() |
printExpr pairs |
|---|---|---|---|
phpstan-src src/ |
1746 | 3 | 3 |
vendor/ |
3003 | 0 | 0 |
| PHPStan extension packages | 1090 | 0 | 0 |
Three calls across about 5800 files of real code. The function name checks in isFuncCallOnSameArray() run first and reject nearly everything, so printing is reached only for a write shaped like $x[count(...)] on a list.
The re-evaluation added to every additional expression is cheap too. 4531 extra getType() calls across all of src/, 5495 across vendor/, all memoized lookups.
No new constructor dependency (exprPrinter was already injected), and AssignHandler carries no #[ShadowedByTurboExtension], so there is no native mirror to keep in step.
The new skip guard does not change any finding on real code. Analysing vendor/ (3003 files, which contains every site where the guard fires) gives 29250 findings, identical with and without the change. Nothing gained, nothing lost.
One thing worth adding to the description
The guard fires on real code, but never for the reason given. Across vendor/ it fires 20 times, on five distinct expressions and no count() call among them:
10x $result[++$i] phpunit/php-code-coverage
7x $matches[$numMatches++] symfony/console
1x $lines[key($lines)] ondrejmirtes/php-merge
1x $merged[key($merged)] ondrejmirtes/php-merge
1x $conflicts[key($conflicts)] ondrejmirtes/php-merge
The increments are clearly right to skip: re-evaluating $i++ after the write cannot describe the offset that was written.
The key() ones are a different case, and they are the reason I would reword rather than change anything. Those sites are the end($lines); $lines[key($lines)] = $x; idiom, so the offset does not move; the guard fires because the recomputed type widened once the container changed, not because the expression stopped designating the same element. The condition therefore conflates "the offset moved" with "the offset's type got wider". It happens to be inert either way, per the diff above, so this is about the description being accurate rather than about the code being wrong.
Questions
-
The change leaves two adjacent lines disagreeing about which index is right:
hasExpressionType($dimFetchStack[$i])above,shouldKeepList($writtenDimFetch)below. Both cannot be correct. Either the mirrored index happens to select the right link for the cases those five NSRT files cover, or the loop's index convention is wrong and those files have been encoding the compensation. Worth settling before this lands, because the next person in that loop will hit it. -
This is five fixes in one PR. The
array_search()argument count fix stands alone and could land on its own:$list[array_search($list)]aborts analysis on current 2.2.x withArgument #2 ($b) must be of type PhpParser\Node\Expr, null given. The call is malformed either way, but an internal error rather than a reported one is a different class of problem to the rest of this.
… write The comment named only the case where the offset is derived from the container it is written into, e.g. $list[count($list)]. The condition is an equality on the offset type, so it also fires when the offset expression has a side effect ($list[$i++]) and when the offset merely widened because the container changed (end($a); $a[key($a)] = ...), where the expression still designates the same element. That equality is the only cheap evidence the expression still points at what was written, so all of them are skipped alike - say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two adjacent lines index $dimFetchStack differently, and only one of them can be the link $offsetType belongs to. Reconstructing the intent: the index was introduced for the list-keeping check alone (9efcdf5) and later reused for the setExistingOffsetValueType() branch, both written for the non-nested case where the mirrored and the written index coincide. Handing the written dim fetch to that branch as well is not a no-op. At the outermost level of a nested write the mirror is the whole left-hand side, which is untracked before the assignment, so the container's value type is replaced; the written dim fetch is tracked there, so it would be union'ed instead and every nested write widens back to what it was before - measured as failures in assign-nested-arrays.php, pr-4390.php, bug-13637.php, bug-13786.php and bug-14084.php. Indexing the container instead trades those for the non-nested list writes in bug-12274.php, so no index alone settles it: the branch has to tell replacing a known element from widening a maybe-existing one first. Rename $arrayDimFetch to $mirroredDimFetch and write that down where the next person in this loop will read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SanderMuller please send a separate PR |
|
Pushed. Here's the response to the review. Thanks for rebuilding rather than reading — that made both questions answerable with data instead of opinion. Question 1 — the index disagreementI went and settled it. You were right that both cannot be correct, and the answer is neither convenient nor a one-line fix. Where the index came from. What the loop actually holds. I instrumented it to stop guessing. At iteration So Why the five files regress, concretely. It is And the other index doesn't work either. I measured all four candidates:
So the mirror is not selecting the right link — it is standing in for a distinction the branch cannot currently make. The condition wants "this write replaces an element I already know about", and I've left the behaviour alone and instead made the trap legible (c097427): the variable is now The
|
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