Skip to content

Skip boolean conditional expression holders when the antecedent side is an unsplittable compound boolean#5983

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-93js1j2
Open

Skip boolean conditional expression holders when the antecedent side is an unsplittable compound boolean#5983
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-93js1j2

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #14807 / #5848. When an intermediate if
sits between two ifs that re-assert the same enum condition, the narrowings
from the first if leaked into the later block, producing two false positives:

  • identical.alwaysFalse on $flags->flagA === false, and
  • function.alreadyNarrowedType on in_array($kind, [...], true) (a regression
    introduced in 2.2.3 by the #14807 fix).

The fix stops the boolean-decomposition machinery from creating an unsound
conditional expression holder whose guard under-approximates a compound
condition.

Changes

  • src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php
    • processBooleanConditionalTypes() now bails out early and builds no holders
      when the condition (antecedent) side is an unsplittable compound boolean.
    • Renamed isUnsplittableCompoundHolderSide() to isUnsplittableCompoundSide()
      and reused it for the antecedent side with the opposite polarity, since the
      antecedent's asserted truth value is the negation of the consequent's.
    • Added ?Expr $conditionSideExpr parameter to carry the antecedent expression.
  • src/Analyser/ExprHandler/BooleanAndHandler.php and
    src/Analyser/ExprHandler/BooleanOrHandler.php
    • Pass the sibling (condition-side) expression to each
      processBooleanConditionalTypes() call.

Analogous cases probed

  • BooleanOr true context: the same helper handles it, so the fix is applied
    symmetrically. I could not construct a standalone reproducer that leaks via the
    BooleanOr path (with no fix at all it already inferred correct types), so no
    BooleanOr-specific regression test was kept, but the shared check is
    polarity-correct for both operators.
  • LogicalAnd / LogicalOr (and / or keywords): already covered because
    isUnsplittableCompoundSide() and the handlers match those node types too.

Root cause

processBooleanConditionalTypes() builds holders of the form
"IF <antecedent narrowing> THEN <consequent narrowing>". The antecedent
narrowing is the per-expression narrowing that a boolean operand produces when it
is asserted true/false. For an operand whose asserted truth value is a
disjunction ($a || $b asserted true, or $a && $b asserted false), that
per-expression narrowing is only a necessary consequence of the operand being
true, not a sufficient one.

In the repro, the intermediate condition
$forced === false && ((grade===One && extra===false) || (cond && grade!==Three))
produced the holder {$grade ∈ {One,Two}} ⇒ {$forced = true} from the
BooleanAnd false context (antecedent = the (... || ...) disjunction, whose
truth narrows $grade to One|Two). Because $grade ∈ {One,Two} does not
imply the disjunction is true, re-asserting $grade !== Grade::Three in the later
if wrongly set $forced = true, which then chained through the sound
{$forced = true} ⇒ {$flags->flagA = true, $kind = K1|K2} holders and leaked
those narrowings.

The existing code already refused to split an unsplittable compound on the
consequent side; the same reasoning applies to the antecedent side, and
that is exactly the symmetric check this PR adds.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14908.php — asserts that inside the final
    if ($grade !== Grade::Three) the leaked expressions stay wide
    ($flags->flagA is bool, $kind is Kind, $forced is bool), and that
    the genuinely-sound narrowing ($forced === true really implying the first
    branch) still fires.
  • tests/PHPStan/Rules/Comparison/data/bug-14908.php — the verbatim playground
    reproducer, wired into both StrictComparisonOfDifferentTypesRuleTest
    (identical.alwaysFalse) and ImpossibleCheckTypeFunctionCallRuleTest
    (function.alreadyNarrowedType), each expecting no errors.

All three tests fail before the fix and pass after it.

Fixes phpstan/phpstan#14908

…is an unsplittable compound boolean

- `ConditionalExpressionHolderHelper::processBooleanConditionalTypes()` now
  returns no holders when the condition (antecedent) side is a compound boolean
  whose asserted truth value is a disjunction (`||` asserted true, or `&&`
  asserted false). Such a side's per-expression narrowing is only a necessary
  consequence of its truth value, not a sufficient one, so using it as a guard
  fires the holder unsoundly.
- Generalized the existing `isUnsplittableCompoundHolderSide()` into
  `isUnsplittableCompoundSide()` and applied it to the antecedent side with the
  opposite polarity (`!$holderSideIsNegated`), mirroring the consequent-side
  check that already existed.
- Threaded the condition-side expression into `processBooleanConditionalTypes()`
  from both `BooleanAndHandler` and `BooleanOrHandler` call sites.
- The `BooleanOr` true-context path receives the same fix for symmetry; a
  standalone reproducer for that path could not be constructed, but the check
  lives in the shared helper and is polarity-correct for both operators.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant