Skip boolean conditional expression holders when the antecedent side is an unsplittable compound boolean#5983
Conversation
…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
left a comment
There was a problem hiding this comment.
Fix also phpstan/phpstan#14966
…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>
|
The review is addressed. Here's a summary of what I did. Addressed: "Fix also phpstan/phpstan#14966"The bugIssue #14966 reported a false Root cause
This is distinct from the compound-boolean antecedent case the PR already handled — here the condition side is a relational function call. The fixIn
A pure "this expression is truthy" guard (like Verification
Committed & pushed
|
Summary
Follow-up to #14807 / #5848. When an intermediate
ifsits between two
ifs that re-assert the same enum condition, the narrowingsfrom the first
ifleaked into the later block, producing two false positives:identical.alwaysFalseon$flags->flagA === false, andfunction.alreadyNarrowedTypeonin_array($kind, [...], true)(a regressionintroduced 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.phpprocessBooleanConditionalTypes()now bails out early and builds no holderswhen the condition (antecedent) side is an unsplittable compound boolean.
isUnsplittableCompoundHolderSide()toisUnsplittableCompoundSide()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.
?Expr $conditionSideExprparameter to carry the antecedent expression.src/Analyser/ExprHandler/BooleanAndHandler.phpandsrc/Analyser/ExprHandler/BooleanOrHandler.phpprocessBooleanConditionalTypes()call.Analogous cases probed
BooleanOrtrue context: the same helper handles it, so the fix is appliedsymmetrically. I could not construct a standalone reproducer that leaks via the
BooleanOrpath (with no fix at all it already inferred correct types), so noBooleanOr-specific regression test was kept, but the shared check ispolarity-correct for both operators.
LogicalAnd/LogicalOr(and/orkeywords): already covered becauseisUnsplittableCompoundSide()and the handlers match those node types too.Root cause
processBooleanConditionalTypes()builds holders of the form"IF
<antecedent narrowing>THEN<consequent narrowing>". The antecedentnarrowing 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 || $basserted true, or$a && $basserted false), thatper-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 theBooleanAndfalse context (antecedent = the(... || ...)disjunction, whosetruth narrows
$gradetoOne|Two). Because$grade ∈ {One,Two}does notimply the disjunction is true, re-asserting
$grade !== Grade::Threein the laterifwrongly set$forced = true, which then chained through the sound{$forced = true} ⇒ {$flags->flagA = true, $kind = K1|K2}holders and leakedthose 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 finalif ($grade !== Grade::Three)the leaked expressions stay wide(
$flags->flagAisbool,$kindisKind,$forcedisbool), and thatthe genuinely-sound narrowing (
$forced === truereally implying the firstbranch) still fires.
tests/PHPStan/Rules/Comparison/data/bug-14908.php— the verbatim playgroundreproducer, wired into both
StrictComparisonOfDifferentTypesRuleTest(
identical.alwaysFalse) andImpossibleCheckTypeFunctionCallRuleTest(
function.alreadyNarrowedType), each expecting no errors.All three tests fail before the fix and pass after it.
Fixes phpstan/phpstan#14908