Skip to content

Do not generalize a union of constant arrays that is no bigger than its largest member in TypeCombinator::optimizeConstantArrays() - #6829

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9ng9y8x
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-9ng9y8x

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.3.0, reading an optional key of a large declared array shape with ?? could return (array{...}&oversized-array)|string|null, because the shape had been degraded to an oversized general array. That happened when the shape held more than 256 constant array values, nested ones included. This PR keeps the shape precise when a union of constant arrays is no bigger than its largest member.

Changes

  • src/Type/TypeCombinator.php
    • optimizeConstantArrays() now also receives the types being unioned. When the total value count goes over ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT, it raises the threshold to the largest count held by a single member, using the new getConstantArrayValueTypesLimit(). It generalizes only when the result is still over that threshold.
    • Both call sites in processArrayTypes() (the reduced constant-array path and the general-array value-type path) pass their input types.
  • turbo-ext/src/TypeCombinator.cpp: the same change in the native mirror.

Probed analogous cases:

  • isset, array_key_exists, !empty, (... ?? null) !== null and isset(...) ? ... : null all went through the same scope merge and were broken. They are fixed by this change and covered by the test.
  • A list shape with under 256 keys but more than 256 nested values was broken and is fixed (test added).
  • A declared union of a large shape with another shape is already generalized when the PHPDoc type is resolved. This is long-standing behavior and is not changed here.
  • A shape with more than 256 keys is degraded upfront by ConstantArrayTypeBuilder::createFromConstantArray() whenever an offset is set on it, including the HasOffsetValueType intersection from ??. This is older behavior (2023), not this regression, and is left unchanged.

Root cause

$r['o0'] ?? null narrows $r in one branch, and the scopes are then merged by TypeCombinator::union(). reduceArrays() correctly merges the two variants back into the single original shape. optimizeConstantArrays() then counted that shape's constant array values (197 + 20 × 3 = 257), compared the count against the fixed limit of 256, and generalized it. That limit exists to stop unions of many constant arrays from growing. A union that is no bigger than one of its members does not grow anything, so generalizing it only lost precision. A single large shape that never goes through a union was already left alone, so the type depended on whether a scope merge had happened.

Test

tests/PHPStan/Analyser/nsrt/bug-15459.php is generated from the issue's script and covers:

  • the reported case (197 string keys + 20 optional nested keys, read twice with ??)
  • the nested-only case (86 optional nested keys)
  • isset, array_key_exists, !empty, !== null and ternary-isset narrowing
  • a list shape (100 string + 60 optional nested entries)

The test fails without the fix, passes with it, and also passes with the PHP fix stashed and the native extension loaded.

Fixes phpstan/phpstan#15459

…ts largest member in `TypeCombinator::optimizeConstantArrays()`

- `optimizeConstantArrays()` generalized any union whose constant array value
  count exceeded `ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT`, even when the
  union reduced to a single shape no bigger than one of its own members. A
  declared shape with more than 256 values (nested ones included) is kept
  as-is on its own, but after `$r['opt'] ?? null` (or `isset`,
  `array_key_exists`, `empty`, `!== null`) the scope merge unions it with its
  narrowed variant, and the merged result was degraded to
  `non-empty-array<..., ...>&oversized-array`.
- The threshold is now raised to the largest per-member count of the unioned
  types (new `getConstantArrayValueTypesLimit()`), computed only once the
  total is already over the plain limit. Both callers in
  `processArrayTypes()` pass the types being unioned.
- Ported the same change to the native mirror in
  `turbo-ext/src/TypeCombinator.cpp`.
- Probed: big list shapes (fixed by the same change, test added); declared
  unions of a large shape with another shape (already generalized when they
  are declared, which is long-standing behavior and left unchanged); shapes
  with more than 256 *keys* (degraded by `ConstantArrayTypeBuilder::createFromConstantArray()`,
  an older design decision, left unchanged).
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.

Reading an optional key of a large array shape with ?? returns oversized-array with other value types (regression in 2.3.0)

1 participant