Skip to content

Do not split a compound-boolean holder side whose asserted truth value is a disjunction#5817

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-nk27wmr
Jun 6, 2026
Merged

Do not split a compound-boolean holder side whose asserted truth value is a disjunction#5817
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-nk27wmr

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

After a guard like if ($a && $b && $c) { return; }, PHPStan wrongly narrowed the
other operands when one was later tested. Entering if ($c) narrowed both $a and
$b to false, so if ($c && $b) was reported as Right side of && is always false
— while the logically equivalent if ($b && $c) was not, hence the operand-order
dependence in the report. The only fact known after the guard is the disjunction
!$a || !$b || !$c; it does not make $b false when $c is true (e.g. $a = false, $b = true, $c = true reaches the branch).

Changes

  • src/Analyser/TypeSpecifier.php
    • processBooleanConditionalTypes(): replaced the sureNot-only, count-based
      suppression with a structural check driven by the holder-side expression.
    • Added isUnsplittableCompoundHolderSide(): a holder side is left whole (no
      per-expression holders) when it is a conjunction (&&/and) asserted false in
      the BooleanAnd false context, or a disjunction (||/or) asserted true in the
      BooleanOr true context.
    • Threaded the holder-side expression ($expr->left / $expr->right) into
      processBooleanConditionalTypes() from both the BooleanAnd and BooleanOr
      holder paths.
  • tests/PHPStan/Analyser/nsrt/bug-14787.php: regression test.

Root cause

The BooleanAnd/BooleanOr decomposition creates conditional holders of the form
"if one side is true, the other side is …". When the other (holder) side is itself a
compound boolean, splitting its narrowing into independent per-expression holders is
only sound when the asserted fact is a conjunction:

  • BooleanAnd false context — the holder side is asserted false. A conjunction
    $a && $b asserted false is !$a || !$b, a disjunction, which has no
    per-expression narrowing.
  • BooleanOr true context — the holder side is asserted true. A disjunction
    $a || $b asserted true is itself a disjunction.

The previous guard (added for #14780) only inspected the sureNot list and counted
trackable targets. For === null-style operands the over-narrowed disjunction lands
in the sureNot list, so it was caught; but for plain boolean operands the
conjunction's false narrowing is computed via intersectWith and lands in the sure
list, which the old guard never inspected — so the holders were split and over-narrowed.
Deciding splittability from the holder-side expression's logical structure fixes both
list placements uniformly, and symmetrically covers the BooleanOr true context.

Test

tests/PHPStan/Analyser/nsrt/bug-14787.php covers:

  • The reported case: after if ($a && $b && $c) { return; }, $a/$b stay bool
    under if ($c), and both $c && $b and $b && $c narrow to true (no
    always false). Fails before the fix (the && blocks collapse to *NEVER*).
  • A non-compound side still narrows soundly: !($b && $c) with $c true keeps
    $b as false.
  • Analogous disjunction-operand case: !(($p || $q) && $r) with $r true now
    correctly narrows $p and $q to false (negation of a disjunction is a
    conjunction, safe to split). This was previously a missed narrowing.

The BooleanOr true-context counterpart was also probed: a disjunction operand
asserted true already produces empty narrowings, so it never over-narrowed; the new
structural check covers it for robustness without changing behavior.

Fixes phpstan/phpstan#14787

…e is a disjunction

- `processBooleanConditionalTypes()` now decides whether a holder side may be
  decomposed into per-expression holders from the holder-side expression's
  logical structure instead of counting trackable targets in the sureNot list.
- A new `isUnsplittableCompoundHolderSide()` suppresses holder creation when the
  side is a conjunction (`&&`/`and`) asserted false in the `BooleanAnd` false
  context, or a disjunction (`||`/`or`) asserted true in the `BooleanOr` true
  context. In both cases the asserted fact is itself a disjunction with no sound
  per-expression narrowing.
- The holder-side expression (`$expr->left` / `$expr->right`) is now threaded
  into `processBooleanConditionalTypes()` for both the `BooleanAnd` and
  `BooleanOr` holder paths.
- Fixes the reported false positive: after `if ($a && $b && $c) { return; }`,
  `$c` being true no longer narrowed `$a`/`$b` to `false`, so `$c && $b` was
  wrongly reported as `Right side of && is always false`. The previous
  count-based guard only covered the sureNot list, but a plain-boolean
  conjunction's false narrowing lands (over-narrowed) in the sure list.
- Side effect / improvement: a disjunction operand asserted false (e.g.
  `!(($p || $q) && $r)` with `$r` true) is now correctly narrowed, since the
  negation of a disjunction is a conjunction and is safe to split.
@ondrejmirtes ondrejmirtes merged commit 3cbc108 into phpstan:2.2.x Jun 6, 2026
657 of 670 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-nk27wmr branch June 6, 2026 18:40
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 error on a boolean && condition depending on operand order

2 participants