Skip to content

Stop tracking array auto-indices past PHP_INT_MAX and past unpacked items - #6464

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Assigning an array literal that mixes the explicit key 9223372036854775807 (PHP_INT_MAX) with a later keyless or unpacked item crashed the analysis of the whole file with Int_::__construct(): Argument #1 ($value) must be of type int, float given.

Everything that walks an array literal to work out which key each item gets was computing the next auto index as $index + 1, which silently overflows to a float at PHP_INT_MAX. That float then reached constructors typed int. The same walk also treated an unpacked item (...$rest) as a single keyless item, which shifts every following index by the wrong amount.

This PR makes "there is no next auto index" and "the next auto index is unknown" first-class states in all five places that track array auto indices.

Changes

  • src/Analyser/ExprHandler/AssignHandler.php
    • processArrayByRefItems() gets a nextImplicitIndex() helper that returns null for PHP_INT_MAX; a null implicit index already made following items use a non-constant int dim, so the crash becomes correct imprecision.
    • Unpacked items are now handled before the key/keyless branches. advanceImplicitIndexByUnpackedArray() advances the index by the number of integer keys the unpacked array contributes when that array is a sealed constant array with no optional keys (string keys are preserved by unpacking, so they don't consume an index), and returns null otherwise. This also fixes By-reference item after a spread in an array literal ([...$list, &$x]) is linked to the wrong offset phpstan#15247, where &$x in [...$list, &$x] was linked to $a[1] regardless of the spread's real length.
  • src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php
    • Stops generating auto keys once PHP_INT_MAX has been used — [9223372036854775807 => 1, 2] crashed here with the analogous ConstantIntegerType::__construct() TypeError.
    • Treats unpacked items as making following keys unpredictable, which removes a false positive: [...$arr, 0 => 'x'] was reported as Array has 2 duplicate keys with value 0 (0, 0). Genuine duplicates around a spread (['a' => 1, ...$rest, 'a' => 2]) are still reported.
  • src/Type/Constant/OversizedArrayBuilder.php
    • $nextAutoIndex is now nullable; a keyless item past PHP_INT_MAX gets a plain IntegerType() key and marks the array as not a list. This crashed for array literals with more than ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT items.
  • src/Type/Php/ArrayFillFunctionReturnTypeExtension.php
    • The constant-array fast path is only taken when the last generated key still fits into an int, so array_fill(9223372036854775807, 2, 'a') returns non-empty-array<int, 'a'> instead of crashing. array_fill(9223372036854775806, 2, 'a') keeps its precise array{9223372036854775806: 'a', 9223372036854775807: 'a'} type.
  • src/Type/ArrayType.php
    • setOffsetValueType() with a null offset no longer unions $key + 1 into the key type when that overflows. array<9223372036854775806|9223372036854775807, string> followed by $a[] = 'x' used to infer the impossible key type …|9.223372036854776E+18.

Probed and found already correct, so left untouched:

  • ConstantArrayTypeBuilder — all four auto-index computations already guard with is_float().
  • The oversized-array path in TypeCombinator — its overflowed index is only compared for list-ness and never reaches a ConstantIntegerType constructor.
  • array_merge, array_replace, array_pad, array_reverse, array_flip, array_combine, array_slice, array_chunk, array_unique, array_fill_keys, array_column, range, +, array_push, array_unshift and list destructuring with PHP_INT_MAX keys.

Root cause

PHP generates an array key by incrementing the highest integer key used so far, and PHP_INT_MAX is therefore the last key auto-generation can ever produce — appending to such an array is an Error at runtime. PHPStan modelled the next key as plain int arithmetic, so at PHP_INT_MAX the tracked index became a float and was handed to constructors declared int (PhpParser\Node\Scalar\Int_, PHPStan\Type\Constant\ConstantIntegerType). PHPStan's own type system infers int + int as int, which is why none of these sites were flagged by self-analysis.

The fix names the missing state instead of the overflow: every auto-index tracker now has an explicit "no next index" value and falls back to a non-constant int key.

The second, adjacent pattern is that array unpacking was not represented in these walks at all. An unpacked item has key === null, so it fell into the keyless branch and consumed exactly one index. The two sites that tracked indices for semantics (AssignHandler for by-reference linking, DuplicateKeysInLiteralArraysRule for duplicate detection) now account for the unknown length of a spread.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15248.php — version-independent cases: keyless and by-reference items after a PHP_INT_MAX key, by-reference items after a spread of list<int> and of a constant array, appending to array<9223372036854775806|9223372036854775807, string>, and the array_fill() overflow. Without the fix the whole file crashes in AssignHandler.
  • tests/PHPStan/Analyser/nsrt/bug-15248-string-key-unpacking.php (// lint >= 8.1) — the reporter's playground snippet verbatim plus the string-keyed unpacking variants.
  • tests/PHPStan/Rules/Arrays/data/bug-15248.php + DuplicateKeysInLiteralArraysRuleTest::testBug15248() — the rule's own PHP_INT_MAX crash, the removed [...$arr, 0 => 'x'] false positive, and the still-reported duplicate 'a' across a spread.
  • OversizedArrayBuilderTest::dataBuild() — three new cases ([9223372036854775807 => 1, 2], [9223372036854775806 => 1, 2, 3], [9223372036854775807 => 1, ...[2, 3]]) which all crash without the fix.

Each of the five source changes was confirmed to be individually necessary by stashing it and watching the corresponding test fail.

Fixes phpstan/phpstan#15248

🤖 Generated with Claude Code

@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.

Fix conflicts

…d past `PHP_INT_MAX`

* `AssignHandler::processArrayByRefItems()` no longer gives up on implicit indices at every
  unpacked item. `advanceImplicitIndexByUnpackedArray()` advances the index by the number of
  renumbered integer keys when the unpacked array is a single sealed constant array without
  optional keys (string keys are preserved by unpacking and don't consume an index), and makes
  it unpredictable otherwise. `&$x` in `$a = [...[10, 20], &$x]` is now linked to `$a[2]`, so
  `$x = 7` gives `array{10, 20, 7}` instead of
  `array{7|10, 7|20, int, ...<int<min, -1>|int<3, max>, 7>}`.
* Unpacked items are no longer walked as if they were a keyless item holding a nested array
  literal. `[...[&$x], &$y]` used to link `$x` to `$a[int][0]`, and writing to `$x` or `$y`
  turned the array's values into `*ERROR*`.
* `ArrayType::setOffsetValueType()` no longer unions a float key into the key type of
  `array<9223372036854775807, string>` when appending, because there is no offset past
  `PHP_INT_MAX`.

Closes phpstan/phpstan#15247

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-vgo0lew branch from b89e827 to 2380433 Compare September 17, 2026 15:37
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