Advance the implicit index by an unpacked item's integer keys in array literals - #6462
Merged
ondrejmirtes merged 1 commit intoSep 17, 2026
Conversation
ondrejmirtes
requested changes
Sep 17, 2026
ondrejmirtes
left a comment
Member
There was a problem hiding this comment.
Conflicts after merging #6461
ondrejmirtes
force-pushed
the
create-pull-request/patch-hbtwp05
branch
from
September 17, 2026 15:37
69491f7 to
c713dd9
Compare
…y literals
- Add `PHPStan\Type\ArrayUnpackingHelper`, which resolves how many implicit indices
an unpacked item (`...$a`) takes up and which keys it contributes: integer keys are
always renumbered, string keys keep their name since PHP 8.1.
- `AssignHandler::processArrayByRefItems()` counts an unpacked item's integer keys through
the helper, replacing `advanceImplicitIndexByUnpackedArray()`. Besides a single sealed
constant array, this also keeps the implicit index known after a union of constant
arrays with the same integer key count and after an array with only string keys, which
takes up no index. The index still stops at `PHP_INT_MAX` through `advanceImplicitIndex()`.
- By-reference items inside an unpacked array literal whose items are all keyless
(`[...[&$x], &$y]`) are flattened into the surrounding literal, so `$x` is linked to
`$a[0]` instead of losing its link: `$x = 10` now gives `array{10, int}`.
- `DuplicateKeysInLiteralArraysRule` no longer stops tracking keys after every unpacked
item. It asks the helper for the keys the unpacked item really contributes, so
`[...[1, 2], 0 => 'x']` and `['a' => 1, ...['a' => 2]]` are reported, and stops
tracking the auto-generated index only when they can't be determined or would pass
`PHP_INT_MAX`.
- In the same rule, an explicit integer key no longer resurrects the auto-generated
index after it became unknown (`max(false, 5)` used to evaluate to `5`), which fixes
false positives like `[$k => 'z', 5 => 'a', 'b', 6 => 'c']` and
`[...$list, 5 => 'a', 'b', 6 => 'c']`.
- String keys contributed by an unpacked item are printed with quotes in the rule's
error message.
- `testBug15244` now requires PHP 8.1 like its `// lint >= 8.1` data file: before 8.1
the string-keyed spread in it renumbers, which the rule now reports.
ondrejmirtes
force-pushed
the
create-pull-request/patch-hbtwp05
branch
from
September 17, 2026 16:09
c713dd9 to
e1d8843
Compare
ondrejmirtes
approved these changes
Sep 17, 2026
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
In an array literal, a by-reference item that follows an unpacked item was linked to the wrong offset, because
AssignHandler::processArrayByRefItems()counted...$listas a single keyless item.[...$list, &$x]therefore linked&$xto$a[1], so assigning to$xchanged an element that belongs to$list— and when that element held a constant, the propagated write collapsed the whole array to*NEVER*.The same off-by-N lives in
DuplicateKeysInLiteralArraysRule, which also derives auto-generated indices from item positions, so it is fixed here too.Changes
src/Type/ArrayUnpackingHelper.php(autowired service) with the unpacking key arithmetic in one place:getImplicitIndexCount()— how many implicit indices an unpacked value takes up, ornullwhen it can't be determined (non-constant arrays, unsealed arrays, optional keys, unions of constant arrays with different integer-key counts,Traversable).getKeyTypes()— the exact keys an unpacked value contributes, given the next implicit index.PhpVersion::supportsArrayUnpackingWithStringKeys(): string keys only keep their name from PHP 8.1 on, and an array whose keys are all strings takes up no implicit index at all.src/Analyser/ExprHandler/AssignHandler.php:processArrayByRefItems()is now a thin wrapper overprocessArrayByRefItemsWithImplicitIndex(), which threads the implicit index through the items and returns it.nullwhen unknown — subsequent by-reference items then link to a generalintdim instead of a wrong constant one.[...[&$x], &$y]links$xto$a[0]and$yto$a[1](PHP does preserve references through unpacking).src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php:max(false, 5)evaluated to5, which resurrected index tracking that had been invalidated by an unknown key.var_export()so the message reads('a', 'a')instead of('a', a).Root cause
Both call sites walked
Expr\Array_::$itemsand treated every item withkey === nullas taking up exactly one implicit index. An unpacked item also haskey === null, but it contributes one implicit index per integer key of the unpacked value — zero for an all-string-keyed array, an unknown number for a non-constant one. Every position computed after the unpack was therefore shifted.Probed and found already correct, so left alone:
InitializerExprTypeResolver::getArrayType()— already merges unpacked constant arrays by integer-key position and by string key name; the literal's own type (array{10, 20, int}) was right all along.Constant\OversizedArrayBuilder— expands unpacked items into synthetic items before computing the next auto index.[$a, $b] = ...,list(),foreachdestructuring inForeachHandler,VarTagTypeRuleHelper) — destructuring patterns can't contain unpacked items.LiteralArrayKeyCastRule,InvalidKeyInArrayItemRule,ArrayUnpackingRule— they only look at explicit keys.Still open and out of scope, tracked as phpstan/phpstan#15246: writing through a by-reference item whose slot holds an incompatible constant still collapses to
*NEVER*($a = [&$x, 10, 20]; $x = 'str';). That happens without any unpacking involved. This change removes every occurrence of it that was caused by the wrong offset.Test
tests/PHPStan/Analyser/nsrt/bug-15247.php— the issue's two reproducers verbatim, plus: writes propagating through a constant spread, an unknown-length spread, an empty spread,[...[&$x], &$y], a spread after an explicit key, an optional-key spread, unions of constant arrays with equal and with different sizes, aGeneratorspread, a by-reference item before a spread, and a spread inside a nested array literal.tests/PHPStan/Analyser/nsrt/bug-15247-php81.php— string-keyed and mixed-key spreads, plus a spread ofarray<string, int>.tests/PHPStan/Rules/Arrays/data/bug-15247.php+testBug15247()— the duplicate-key false positive on[...$list, 0 => 'x'], the true positives on[...[1, 2], 0 => 'x']and[1, ...[2, 3], 1 => 'x']that must stay, and the[$k => 'z', 5 => 'a', 'b', 6 => 'c']false positive.tests/PHPStan/Rules/Arrays/data/bug-15247-php81.php+testBug15247Php81()— string-key spreads, including the newly reported['a' => 1, ...['a' => 2]].Every assertion was checked against real PHP output, and all new tests were confirmed to fail before the fix.
Fixes phpstan/phpstan#15247