Skip to content

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

Open
phpstan-bot wants to merge 2 commits 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 2 commits 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.

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

…on side's truthiness

A boolean operand that is a relational predicate such as
`in_array($needle, $haystack)` narrows one of its arguments only
accessorily: in the truthy branch `$haystack` merely becomes non-empty.
That narrowing is a necessary but not sufficient consequence of the
predicate, so using it as a conditional-expression-holder guard lets the
holder fire when the narrowing is re-established by an unrelated check
(e.g. `$haystack !== []`) even though the predicate is false. In the
reproducer this leaked `$needle = null` into the `elseif` branch and
produced a false `notIdentical.alwaysFalse`.

Only build a holder when the guard actually identifies the condition
side's asserted truth value: some condition narrows beyond plain
truthiness/falsiness, the consequent is an offset of a guarded container
(the isset()/array_key_exists() shape, whose truth cannot always be
re-derived from a value type), or re-applying the guard forces the
condition side to that truth value.

Closes phpstan/phpstan#14966

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

The review is addressed. Here's a summary of what I did.

Addressed: "Fix also phpstan/phpstan#14966"

The bug

Issue #14966 reported a false notIdentical.alwaysFalse on the pattern $x !== null && in_array($x, $arr): in the elseif branch, PHPStan wrongly narrowed $x to null. I fetched the exact playground reproducer and confirmed it.

Root cause

processBooleanConditionalTypes() built an unsound conditional-expression holder: IF $haystack = non-empty-list THEN $needle = null. The guard came from in_array()'s truthy narrowing of its haystack argument — which is only "$haystack is truthy" (non-empty), a necessary-but-not-sufficient consequence of the predicate. Re-asserting it with an unrelated $haystack !== [] in the elseif fired the holder, leaking $needle = null.

This is distinct from the compound-boolean antecedent case the PR already handled — here the condition side is a relational function call.

The fix

In ConditionalExpressionHolderHelper, a holder is now built only when its guard (the conditions remaining after the self-condition is dropped) actually identifies the condition side's asserted truth value — via any of:

  1. a condition narrows beyond plain truthiness/falsiness (real value pinned down),
  2. the consequent is an offset of a guarded container (the isset()/array_key_exists() shape, whose truth PHPStan can't re-derive from a value type for dynamic keys), or
  3. re-applying the guard forces the condition side to that truth value.

A pure "this expression is truthy" guard (like in_array()'s haystack) satisfies none and is skipped. Getting this discriminator right was the crux — it had to drop the in_array guard while preserving the isset/array_key_exists (both constant- and dynamic-key) and simple-predicate guards.

Verification

  • Fails before, passes after — confirmed by stashing the source.
  • NodeScopeResolverTest: 1671/1671 pass (incl. the new nsrt/bug-14966.php).
  • Comparison rule tests: 230/230 pass (incl. new testBug14966).
  • make phpstan: no errors.

Committed & pushed

b9e7f750c on create-pull-request/patch-93js1j2 — source fix plus three test additions (nsrt type-inference test, verbatim rule reproducer, and its StrictComparisonOfDifferentTypesRuleTest wiring), with Closes …/issues/14966 in the body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants