Skip to content

Require the union with the other branch's type to equal the consequent before rescuing a one-sided conditional holder - #6835

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

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

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.3.0, reading an array key that was assigned conditionally, after some unrelated if blocks, could be reported as Offset '...' on left side of ?? does not exist. The cause was a stale conditional expression holder for $post that survived an if-merge it should not have survived. When the holder was applied later, it narrowed $post back to a shape without the newly assigned key.

Changes

  • src/Analyser/MutatingScope.php (preserveVacuousConditionalExpressions()): before rescuing a one-sided holder because "the other branch's state already satisfies the consequent", also require TypeCombinator::union($consequent, $otherState)->equals($consequent).
  • turbo-ext/src/MutatingScope.cpp: the same check in the native mirror.
  • Probed the other isSuperTypeOf() uses in the conditional-holder machinery: the vacuous-guard check (->no()), ScopeOps guard matching, and the StatementsHandler loop holders. They compare guards rather than check that a consequent still holds, so none of them has this problem.

Root cause

Without bleeding edge, array shapes are unsealed, so array{}|array{field0?: T, field1?: T} counts as a supertype of array{field0?: T, field1?: T, other0?: T, company_address: T}. The subsumption rescue therefore kept if otherEnabled()=false then $post is array{}|array{field0?, field1?} across the isEnabled() block that assigned company_address. A later if (isVisible() && !otherEnabled()) applied that holder, and the merge recorded a wrong if isEnabled()=true then $post is … holder that dropped company_address. Checking that the union is unchanged expresses what the rescue actually needs: the merged state under the guard is still exactly the consequent. With bleeding edge, shapes are sealed and isSuperTypeOf() already answered correctly, which is why the normal test suite (always bleeding edge) did not catch this.

Test

  • tests/PHPStan/Rules/Variables/NullCoalesceRuleNoBleedingEdgeTest.php + data/bug-15534.php: the issue's reproducer, run without bleeding edge, expects no errors.
  • tests/PHPStan/Analyser/Bug15534Test.php + data/bug-15534.php: a type-inference test run without bleeding edge that asserts $post keeps company_address? after the later !otherEnabled() / isEnabled() checks.
    Both fail without the fix and pass with it.

Fixes phpstan/phpstan#15534

🤖 Generated with Claude Code

…t before rescuing a one-sided conditional holder

- MutatingScope::preserveVacuousConditionalExpressions() kept a conditional
  holder present in only one branch when the other branch's state was a
  subtype of the holder's consequent. Without bleeding edge, array shapes are
  unsealed in isSuperTypeOf(), so array{}|array{a?: T} counted as a supertype
  of array{a?: T, b: T}. Applying that holder later dropped the 'b' key,
  which caused false positives such as "Offset ... on left side of ?? does
  not exist".
- Now also require TypeCombinator::union(consequent, other) to equal the
  consequent, so the other branch's state adds nothing to it.
- Ported the same check to turbo-ext/src/MutatingScope.cpp.
- Probed the other isSuperTypeOf() uses in the conditional-holder machinery
  (vacuous-guard check, ScopeOps guard matching, StatementsHandler loop
  holders). They compare guards rather than check that a consequent still
  holds, so they don't have this problem.

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.

False positive nullCoalesce.offset after conditional array assignments since 2.3.0

1 participant