Report how a sealed/unsealed array shape treats undeclared keys even when an offset check already failed - #6446
Open
phpstan-bot wants to merge 2 commits into
Open
phpstan-bot wants to merge 2 commits into
phpstan-bot wants to merge 2 commits into
Conversation
…when an offset check already failed
* `ConstantArrayType::accepts()` ran the "sealed array shape can only accept a constant array"
check after `if ($result->no()) return $result;`, so whenever `checkOurKeys()` already
rejected the type the tip was dropped - and `checkOurKeys()` produces no reason at all when
the accepted type is not a constant array, leaving the error with no tip whatsoever.
The undeclared-keys check now runs before that bail-out, guarded by `!$type->isArray()->no()`
so non-arrays are not given a misleading array tip.
* Same gap in the unsealed sibling branch (`array{a: string, ...<int>}` vs a general array):
its key/value checks were also skipped by the bail-out and, when reached, produced bare
reason-less results. They now report `Unsealed array key/value type ... does not accept
key/value type ...`.
* `array{}` (sealed, no declared keys) took an early `createFromBoolean()` shortcut that
returned a reason-less `no`; it now lists the extra keys, matching the non-empty sealed branch.
* Extracted `decorateUnsealedReasons()` from the four copies of the
decorate-inner-reasons-or-fall-back-to-a-plain-one block in the unsealed branch.
* `UnionType::accepts()` repeated a reason shared by every member of the union once per member
with a `Type #N from the union` prefix; a reason common to all members is now reported once,
undecorated.
* Probed and found already correct: `IntersectionType::accepts()` (accumulates with `and()`,
no short-circuit) and `UnionType::isSuperTypeOf()` (no per-member reason decoration).
VincentLanglet
requested changes
Sep 19, 2026
VincentLanglet
left a comment
Contributor
There was a problem hiding this comment.
Revert the change to the UnionType, this is un unrelated issue ; focus only on the missing tip about array sealed.
The `Type #N from the union` duplication is a separate concern from the missing sealed array shape tip, so `UnionType::accepts()` keeps its existing behaviour and the bug-15234 expectation records the repeated tip as-is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collaborator
Author
|
Done — pushed as What changed:
Verification: |
VincentLanglet
approved these changes
Sep 19, 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
A
return/argument/assignment rejected by a sealed array shape sometimes came with theSealed array shape can only accept a constant array. Extra keys are not allowed.tip andsometimes with no tip at all. The difference was whether the per-offset check inside
ConstantArrayType::accepts()had already produced a definiteno: if it had,accepts()returned early and never reached the sealedness check — and since that per-offset check adds
no reason when the accepted type is not a constant array, the error ended up with no
explanation at all.
The fix moves the undeclared-keys check ahead of the early bail-out, gives the unsealed
counterpart the same treatment, and reports a reason shared by all members of a union only
once instead of repeating it per member.
Changes
src/Type/Constant/ConstantArrayType.phpaccepts(): the "how does this shape treat keys it does not declare" check now runsbefore
if ($result->no()) return $result;, so the sealed tip survives an already-failingoffset check. Guarded with
!$type->isArray()->no()so passing e.g. astringto an arrayshape is not annotated with an array-specific tip.
accepts(): the unsealed branch for non-constant arrays (array{a: string, ...<int>}vsarray<string, bool>) was subject to the same bail-out and, when reached,and()-ed inbare reason-less results. It now reports
Unsealed array key type <K> does not accept key type <K2>./Unsealed array value type <V> does not accept value type <V2>.accepts(): thecount($this->keyTypes) === 0shortcut returnedAcceptsResult::createFromBoolean(...), i.e. anowith no reasons.array{}now liststhe extra keys just like the non-empty sealed branch does
(
Sealed array shape does not accept array with extra key 'a'.).decorateUnsealedReasons(), replacing four copies of the samedecorate-inner-reasons-or-synthesize-one block.
src/Type/UnionType.phpaccepts(): when every member of the union fails for the same reason, that reason isreported once instead of once per member with a
Type #N from the unionprefix.tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php+tests/PHPStan/Rules/Methods/data/bug-15234.php,tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php,tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php.Root cause
The pattern is an explanatory reason being short-circuited by a sub-check that fails first.
ConstantArrayType::accepts()is layered:checkOurKeys()validates the declared offsets,then the sealed/unsealed part validates everything the shape does not declare. The second
layer was only reached when the first one had not already returned
no. BecausecheckOurKeys()deliberately omits reasons for non-constant arrays ($type->isConstantArray()->yes()gates both of its reason strings), that combination produced a rejection with zero reasons —
exactly the reported case, where
non-empty-array<string, mixed>&hasOffsetValue('functions', …)failed on the
functionsoffset and thus never got the sealed tip.The two layers are independent: sealedness rejects the type regardless of whether the declared
offsets happen to match, so it belongs before the bail-out, not after. Every location that had
this shape was fixed:
array{}vs a shape with extra keys (separate earlycreateFromBoolean()shortcut).IntersectionType::accepts()was probed and is already correct — it accumulates withand()and never short-circuits.
UnionType::isSuperTypeOf()has no per-member reason decoration, soit has no equivalent of the duplication fixed in
UnionType::accepts().Test
ReturnTypeRuleTest::testBug15234analyses the reporter's playground snippet verbatim andasserts all three tips, including the previously missing one on
queryDeviceFunctions()andthe now-deduplicated union tip on
queryDeviceSignalStrength(). It fails on2.2.xwith amissing tip on one line and a doubled tip on another.
ConstantArrayTypeTest::testAcceptsgained cases for the analogous constructs, each of whichfails without the fix:
non-empty-array<…>&hasOffsetValue(…)(the reduced form of the report),(
array{}vsarray{a: string}, unsealed shape vs incompatible general array) wereupdated to the corrected output.
TypesAssignedToPropertiesRuleTest::testBug13438d/enow assert theSealed array shape does not accept array with extra key 0.tip, which shows the same fixreaching property assignment; parameter passing was verified manually to gain the tip too
(both go through the same
accepts()).Fixes phpstan/phpstan#15234