Use genuine falsey narrowing (not the inverted truthy narrowing) for the consequent of BooleanAnd false-context conditional holders#5878
Merged
ondrejmirtes merged 1 commit intoJun 15, 2026
Conversation
…the consequent of `BooleanAnd` false-context conditional holders - In `BooleanAndHandler::specifyTypes()` false context, split the per-arm narrowings used to build conditional expression holders into a condition (antecedent) set and a holder (consequent) set. - The truthy-narrowing-swap fallback (added for `isset()` on array dim fetches) is now only applied to the antecedent set. `processBooleanConditionalTypes` inverts that set back to the truthy narrowing, so the swap is sound there. - The consequent set keeps the genuine falsey narrowing of each arm (including the mixed truthy-and-false re-derivation). When an arm has no sound falsey narrowing it contributes no consequent, instead of fabricating one by inverting its truthy narrowing. - This fixes over-narrowing where `$a !== null && $b === $a` falling through and then re-testing `$a !== null` wrongly narrowed `$b` to `null`: inverting the `===` truthy narrowing (`$b` to `$a`'s broad `string`) removed `string` from `$b`. The same fault affected every comparison value type (int/string) and both variables and property fetches; all are fixed by the single change. - The `BooleanOr` truthy path does not use the swap and was already correct.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
After
if ($pendingValue !== null && $submitted === $pendingValue) { return; },a subsequent
if ($pendingValue !== null && $submitted === null)wrongly reportedStrict comparison using === between null and null will always evaluate to true(
identical.alwaysTrue). PHPStan incorrectly narrowed$submittedtonullonce$pendingValuewas re-narrowed to non-null. This is a regression introduced inv2.2.2 by the cross-kind conditional holder work for
isset()(#5760 / commit 4910296). The fix keeps that
isset()improvement while nolonger fabricating an unsound consequent narrowing for comparisons.
Changes
src/Analyser/ExprHandler/BooleanAndHandler.php: in thefalsecontext, theper-arm narrowings that feed
processBooleanConditionalTypesare now split intotwo sets:
used for
isset()on array dim fetches ($leftCondTypes/$rightCondTypes);arm, including the existing mixed truthy-and-false re-derivation
(
$leftHolderTypes/$rightHolderTypes).The four
processBooleanConditionalTypescalls now pass the condition set as theantecedent and the holder set as the consequent.
tests/PHPStan/Analyser/nsrt/bug-14828.php: regression test withassertTypefor the reported variable case plus the analogous integer-comparison and
property-fetch cases.
Root cause
The
false-context conditional holders model the disjunction!(A && B) = !A || !Bas implications such as "ifAis true, then!B". Theconsequent must be
B's genuine falsey narrowing. #5760 added a fallback that,when an arm's falsey narrowing was empty, derived narrowings by swapping the
arm's truthy sure/sureNot types — needed so
isset($arr['x'])(whose falseynarrowing doesn't track the dim fetch) still produces a usable holder.
That swap is only sound for the antecedent:
processBooleanConditionalTypesinverts the antecedent set back into the truthy narrowing, so swap-then-invert is
a no-op. But the same swapped set was also used as the consequent, where it is
taken as-is. For
$submitted === $pendingValue, the genuine falsey narrowing iscorrectly empty (a
!==against a non-constantstringdoes not narrow), so theswap kicked in and produced
$submittednot-string; removingstringfromstring|nullleftnull. The general fault: inverting a comparison's truthynarrowing (which narrows to the broad type of the other operand) is not a valid
falsey narrowing unless the predicate's truthy condition is also sufficient (true
for
isset/empty/constant===, false for===against a non-constant).By using the genuine falsey narrowing for the consequent, an arm with no sound
falsey narrowing simply contributes no consequent.
Test
tests/PHPStan/Analyser/nsrt/bug-14828.phpasserts (all three fail before the fixwith
Actual: null):brokenWithGuard()— the reported?stringcase:$submittedstaysstring|null.intCase()— analogous?intcase:$bstaysint|null.propertyCase()— analogous nullable-property case:$c->qstaysstring|null.The
||dual was probed ($a === null || $b !== $atruthy) and found alreadycorrect — the
BooleanOrtruthy path never used the truthy-swap fallback.Existing tests for the
isset()/empty()improvement (bug-14455, bug-6202,bug-10644, bug-11918, bug-3385, and the negated/mixed boolean-and-conditional-holder
fixtures) continue to pass.
Fixes phpstan/phpstan#14828