Skip to content

Keep an array shape that a union reduces back to without growing - #6919

Draft
puggan wants to merge 2 commits into
phpstan:2.3.xfrom
puggan:merged-single-shape-not-oversized
Draft

puggan wants to merge 2 commits into
phpstan:2.3.xfrom
puggan:merged-single-shape-not-oversized

Conversation

@puggan

@puggan puggan commented Nov 2, 2026

Copy link
Copy Markdown

Closes phpstan/phpstan#15649

Disclosure: I worked this out with an AI assistant (Claude). The diagnosis, the fix and the test runs below were all done and checked for real.

When two branches narrow one key of an array shape and merge again, TypeCombinator::processArrayTypes() gets the two variants, and reduceArrays() correctly merges them back into one ConstantArrayType, the declared shape. optimizeConstantArrays() then counts that single shape with countConstantArrayValueTypes(), which also counts nested shapes. Once nested row shapes bring it over ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT, the shape is generalized to non-empty-array<key, union of every value>, although no single shape in it comes near the limit. From then on every required key "might not exist".

processArrayTypes() now skips optimizeConstantArrays() when the union reduced to a single constant array that holds no more values than the largest array that went into it: that shape grew nothing, so generalizing it only loses precision. An array that grows on every merge, like one built up in a loop, is larger than each of its inputs and is still generalized; unions that keep several shapes are unchanged. The same check is mirrored in turbo-ext/src/TypeCombinator.cpp.

Testing

  • New tests/PHPStan/Analyser/nsrt/bug-15649.php: two nested row shapes of 127 keys each (258 values counted). It fails without the fix ($data['name'] after the merge is int|list<array{a1: int, …}>|… instead of string) and passes with it. $data['version'] itself can't show the bug, because the narrowed expression keeps its own remembered type.
  • Full suite (paratest), PHP 8.5: 22,988 tests OK without the extension, and 22,990 OK with the extension built from this branch loaded through PHP_INI_SCAN_DIR. The 2 deprecations show up both ways.
  • Turbo, built in php:8.5-cli with make WARN_FLAGS="$(make -s print-warn-flags)": compiles with -Werror, tests/smoke.php ALL OK. With the PHP fix but the C++ file reverted, bug-15649 fails with the extension loaded, so the turbo path is the one being exercised.
  • phpcs (build-cs) and self-analysis of TypeCombinator.php are clean.

I didn't run make lint-turbo (clang-tidy), make sanitize-turbo, bin/side-by-side.php or tests/signature-parity.php locally.

🤖 Generated with Claude Code

puggan and others added 2 commits November 2, 2026 22:49
When two branches narrow one key of a large array shape and merge again,
reduceArrays() merges the two variants back into the declared shape, but
optimizeConstantArrays() then counts that single shape recursively and,
once its nested shapes bring it over ARRAY_COUNT_LIMIT, generalizes it to
non-empty-array<key, union of every value>. The shape is no larger than
the ones that went into the union, so generalizing it only loses
precision. An array that grows on every merge is larger than each of its
inputs and is still generalized. Mirrored in the turbo extension.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
turbo-ext/bin/generate-declarations.php, for the new
isReducedToNoLargerShape() method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Array shape with nested row shapes is generalized after an if/else on one of its keys once it counts more than 256 values in total

1 participant