Skip to content

Advance the implicit index by an unpacked item's integer keys in array literals - #6462

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-hbtwp05
Sep 17, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-hbtwp05

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

In an array literal, a by-reference item that follows an unpacked item was linked to the wrong offset, because AssignHandler::processArrayByRefItems() counted ...$list as a single keyless item. [...$list, &$x] therefore linked &$x to $a[1], so assigning to $x changed 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

  • New src/Type/ArrayUnpackingHelper.php (autowired service) with the unpacking key arithmetic in one place:
    • getImplicitIndexCount() — how many implicit indices an unpacked value takes up, or null when 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.
    • Both honour 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 over processArrayByRefItemsWithImplicitIndex(), which threads the implicit index through the items and returns it.
    • An unpacked item advances the implicit index by its integer-key count, or sets it to null when unknown — subsequent by-reference items then link to a general int dim instead of a wrong constant one.
    • An unpacked array literal whose items are all keyless is flattened into the surrounding literal, so [...[&$x], &$y] links $x to $a[0] and $y to $a[1] (PHP does preserve references through unpacking).
  • src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php:
    • An unpacked item now contributes the keys it really has instead of exactly one auto index; when they can't be determined the auto-generated index becomes unknown and no key is registered.
    • Once the auto-generated index is unknown, an explicit integer key no longer brings it back. max(false, 5) evaluated to 5, which resurrected index tracking that had been invalidated by an unknown key.
    • String keys contributed by an unpacked item are printed with var_export() so the message reads ('a', 'a') instead of ('a', a).

Root cause

Both call sites walked Expr\Array_::$items and treated every item with key === null as taking up exactly one implicit index. An unpacked item also has key === 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.
  • Array destructuring ([$a, $b] = ..., list(), foreach destructuring in ForeachHandler, 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, a Generator spread, 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 of array<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

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Conflicts after merging #6461

@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-hbtwp05 branch from 69491f7 to c713dd9 Compare September 17, 2026 15:37
…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
ondrejmirtes force-pushed the create-pull-request/patch-hbtwp05 branch from c713dd9 to e1d8843 Compare September 17, 2026 16:09
@ondrejmirtes
ondrejmirtes merged commit a269f2e into phpstan:2.2.x Sep 17, 2026
859 of 888 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-hbtwp05 branch September 17, 2026 16:14
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.

2 participants