Skip to content

Skip non-discriminating guards in createConditionalExpressions even when target is not tracked in the other scope#5676

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

Skip non-discriminating guards in createConditionalExpressions even when target is not tracked in the other scope#5676
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-o0xvlb3

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When an array shape's keys are accessed in conditions ($options['multiple'], $options['total']) and an unrelated mutable variable ($instructions) is modified in if-branches, PHPStan could incorrectly narrow the array key's type through a chain of conditional expression holders. After three successive if-blocks, $options['multiple'] was narrowed from 0|1|2 to 1 despite no condition warranting that narrowing.

Changes

  • Modified the guard filter in MutatingScope::createConditionalExpressions (src/Analyser/MutatingScope.php, line ~3750) to also skip guards when the guard variable's type is definitively a supertype (.yes()) of its type in the other scope, even when the target expression is not tracked in the other scope.
  • The original filter (checking both target and guard presence with !.no() match) is preserved as the first branch of an OR condition, ensuring no regressions for cases like Event/OrderInterface where isSuperTypeOf returns maybe.

Analogous cases probed

  • Strict comparison (!==): Also broken, fixed by the same change.
  • String concatenation as mutable variable: Not broken (different type representation avoids width-subtyping match).
  • Integer counter as mutable variable: Not broken (scalar types don't have width-subtyping).
  • && condition instead of ||: Not broken (different guard structure).

Root cause

createConditionalExpressions creates conditional expression holders linking variables that differ between merged branches. When $instructions (an array that gets elements appended) and $options['multiple'] both differed between branches, a conditional was created: "when $instructions is array{'foo'} then $options['multiple'] is 1".

This conditional was not filtered out because the filter at line 3750 required the target expression ($options['multiple']) to exist in the other scope's expression types. Since $options['multiple'] was not explicitly narrowed in the truthy branch (only the full $options array was tracked), the filter didn't fire.

Later, a chain reaction in filterBySpecifiedTypes added $instructions to specifiedExpressions via another conditional, and Pass 2's isSuperTypeOf check matched the guard array{'foo'} against array{0: 'foo', 1: 'bar'|'baz', 2?: 'baz'} due to ConstantArrayType's width subtyping (fewer keys = more general = supertype). This incorrectly triggered the conditional, narrowing $options['multiple'] to 1.

The fix adds a target-independent guard check: if the guard type is definitively a supertype of the other scope's type for that variable, the guard cannot distinguish between branches and should be skipped.

Test

  • Added tests/PHPStan/Analyser/nsrt/bug-14595.php with two test functions:
    • arrayAppendGuard: reproduces the original bug with != comparison
    • strictComparisonGuard: reproduces the same bug with !== comparison
  • Both assert that $options['multiple'] remains 0|1|2 after all three if-blocks.

Fixes phpstan/phpstan#14595

… when target is not tracked in the other scope

- In `MutatingScope::createConditionalExpressions`, the filter that skips
  non-discriminating guards previously required the target expression to
  exist in the other scope's expression types. When the target was absent
  (e.g. `$options['multiple']` not explicitly narrowed in the truthy branch),
  the filter did not fire, allowing guards like `$instructions` (an unrelated
  mutable variable) to create spurious conditional expression holders.
- Add an additional check: if the guard variable's type in our scope is
  definitively a supertype of its type in the other scope (`.yes()`), the
  guard is non-discriminating regardless of the target's presence, so skip it.
- The original check (requiring both target and guard in the other scope,
  using `!.no()`) is preserved for cases where the target IS tracked, to
  avoid regressions with `maybe` supertype results (e.g. `Event` vs
  `OrderInterface`).
- Also fixes the same bug with strict comparison (`!==` instead of `!=`).
@VincentLanglet VincentLanglet self-assigned this May 16, 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