Skip to content

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
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-e2auxuj
Open

phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-e2auxuj

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

A return/argument/assignment rejected by a sealed array shape sometimes came with the
Sealed array shape can only accept a constant array. Extra keys are not allowed. tip and
sometimes with no tip at all. The difference was whether the per-offset check inside
ConstantArrayType::accepts() had already produced a definite no: 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.php
    • accepts(): the "how does this shape treat keys it does not declare" check now runs
      before if ($result->no()) return $result;, so the sealed tip survives an already-failing
      offset check. Guarded with !$type->isArray()->no() so passing e.g. a string to an array
      shape is not annotated with an array-specific tip.
    • accepts(): the unsealed branch for non-constant arrays (array{a: string, ...<int>} vs
      array<string, bool>) was subject to the same bail-out and, when reached, and()-ed in
      bare 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(): the count($this->keyTypes) === 0 shortcut returned
      AcceptsResult::createFromBoolean(...), i.e. a no with no reasons. array{} now lists
      the extra keys just like the non-empty sealed branch does
      (Sealed array shape does not accept array with extra key 'a'.).
    • Extracted decorateUnsealedReasons(), replacing four copies of the same
      decorate-inner-reasons-or-synthesize-one block.
  • src/Type/UnionType.php
    • accepts(): when every member of the union fails for the same reason, that reason is
      reported once instead of once per member with a Type #N from the union prefix.
  • Tests: 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. Because
checkOurKeys() 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 functions offset 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:

  • sealed shape vs non-constant array (the reported case),
  • unsealed shape vs non-constant array (same code path, unsealed branch),
  • sealed array{} vs a shape with extra keys (separate early createFromBoolean() shortcut).

IntersectionType::accepts() was probed and is already correct — it accumulates with and()
and never short-circuits. UnionType::isSuperTypeOf() has no per-member reason decoration, so
it has no equivalent of the duplication fixed in UnionType::accepts().

Test

  • ReturnTypeRuleTest::testBug15234 analyses the reporter's playground snippet verbatim and
    asserts all three tips, including the previously missing one on queryDeviceFunctions() and
    the now-deduplicated union tip on queryDeviceSignalStrength(). It fails on 2.2.x with a
    missing tip on one line and a doubled tip on another.
  • ConstantArrayTypeTest::testAccepts gained cases for the analogous constructs, each of which
    fails without the fix:
    • sealed shape vs a general array whose value type already mismatches,
    • sealed shape vs non-empty-array<…>&hasOffsetValue(…) (the reduced form of the report),
    • unsealed shape vs a general array with an incompatible key type,
    • and the two existing cases that asserted the reason-less behaviour
      (array{} vs array{a: string}, unsealed shape vs incompatible general array) were
      updated to the corrected output.
  • TypesAssignedToPropertiesRuleTest::testBug13438d/e now assert the
    Sealed array shape does not accept array with extra key 0. tip, which shows the same fix
    reaching property assignment; parameter passing was verified manually to gain the tip too
    (both go through the same accepts()).

Fixes phpstan/phpstan#15234

…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 VincentLanglet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done — pushed as 6cd040194.

What changed:

  • src/Type/UnionType.php reverted to its pre-PR state; accepts() again prefixes a reason shared by all union members with Type #N from the union once per member. The PR now touches only ConstantArrayType::accepts().
  • ReturnTypeRuleTest::testBug15234 updated accordingly: the tip on line 30 (queryDeviceSignalStrength()) is back to the two-bullet repeated form. The other two expectations — including the previously missing tip on line 62, which is what the issue is about — are unaffected.

Verification: make tests passes (21382 tests, 92 skipped, 0 failures) and make phpstan reports no errors.

@VincentLanglet VincentLanglet self-assigned this Sep 19, 2026
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