Skip conditional expression guard when guard type is a subtype of the other branch's value#5848
Merged
ondrejmirtes merged 2 commits intoJun 11, 2026
Conversation
… other branch's value - In MutatingScope::createConditionalExpressions(), a guard `expr=G implies target=T` was created whenever the merged type differed from the truthy value, and only skipped when the guard type was a supertype of the other (falsey) branch's value for that expression. - This missed the symmetric, equally non-discriminating case: when the guard type is a *subtype* of the other branch's value (the falsey branch still contains the guard value), the guard cannot uniquely identify the truthy branch, so the conditional is unsound. - Add the mirror check `$theirExprIsSuperTypeOfGuard->yes()` alongside the existing `$guardIsSuperTypeOfTheirExpr->yes()` so such guards are skipped. - Fixes a false `booleanAnd.alwaysFalse` / `identical.alwaysFalse` when a 2-case enum narrowed with `!==` is combined via `&&` with an object property comparison and the same enum condition is reused in a later `if`: the property narrowing from the first `if` no longer leaks into the second.
ondrejmirtes
requested changes
Jun 11, 2026
| @@ -0,0 +1,33 @@ | |||
| <?php declare(strict_types = 1); | |||
Member
There was a problem hiding this comment.
This needs to havee <?php // lint >= 8.1 on the first line and declare on the 3rd line.
Collaborator
Author
There was a problem hiding this comment.
Done. I addressed the reviewer's comment.
Summary
The reviewer (ondrejmirtes) requested that the test data file start with <?php // lint >= 8.1 on the first line with declare() moved to the third line — required because the test uses enum syntax (PHP 8.1+).
Changes made:
tests/PHPStan/Analyser/nsrt/bug-14807.php— the file the reviewer flagged: changed the header to<?php // lint >= 8.1withdeclare(strict_types = 1);on line 3.tests/PHPStan/Rules/Comparison/data/bug-14807.php— applied the same fix, since this sibling test data file also usesenumand had the identical issue (all other enum-using data files in that directory already follow this convention).
Verification:
testBug14807(rule test) and the nsrtbug-14807type-inference test both pass.- Full
BooleanAndConstantConditionRuleTestsuite passes (23/23). make phpstanreports no errors.
Committed as a single logical change and pushed to create-pull-request/patch-pmkcr7d.
The enum syntax requires PHP 8.1, so the test data files need the lint directive on the first line with declare() moved to the third line. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ondrejmirtes
approved these changes
Jun 11, 2026
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
When two
ifstatements reuse the same left-hand condition$enum !== Case::X(a 2-case enum narrowed with!==), and the firstifadditionally narrows an object property via&& $obj->prop === true, PHPStan incorrectly carried that property narrowing into the secondif. It then reported$obj->prop === falsein the secondifasResult of && is always false./Strict comparison ... will always evaluate to false.. This is a false positive — the firstifcan be skipped purely because the enum condition was false, leaving the property at its realboolvalue.This change makes
MutatingScope::createConditionalExpressions()skip the unsound conditional that caused the leak.Changes
src/Analyser/MutatingScope.php: increateConditionalExpressions(), compute$theirExprIsSuperTypeOfGuard(the other branch's value for the guard expression vs. the guard type) and skip creating the conditional when$theirExprIsSuperTypeOfGuard->yes(), mirroring the existing$guardIsSuperTypeOfTheirExpr->yes()check.tests/PHPStan/Analyser/nsrt/bug-14807.php: new type-inference regression test asserting$item->readyisbool(nottrue) inside the second reused-conditionif.tests/PHPStan/Rules/Comparison/data/bug-14807.php+BooleanAndConstantConditionRuleTest::testBug14807(): new rule-level regression test asserting nobooleanAnd.alwaysFalseis reported for the reproducer.Root cause
The conditional-expression mechanism records "when
exprequals the value it took in the truthy branch (the guard),targetis the value it took in the truthy branch". This is only sound when the guard value uniquely identifies the truthy branch — i.e. the guard type is disjoint from the valueexprtakes in the other (falsey) branch.The previous code only skipped the guard when the guard type was a supertype of the other branch's value (
$guardIsSuperTypeOfTheirExpr->yes()). It missed the symmetric case where the guard type is a subtype of the other branch's value. With a 2-case enum:$color === Color::Blue,$item->ready === true(guard$color = Color::Blue),$color !== Red && $item->ready === true:$color = Color::Blue|Color::Red(it still containsBlue, because the&&can be false via$item->ready !== true).Here
Color::Blueis a subtype ofColor::Blue|Color::Red, so the guard does not discriminate the branches, yet a conditionalif $color = Color::Blue then $item->ready = truewas created and later fired when the enum was re-narrowed with!==. Adding the mirror check skips it. The canonical case (e.g.if ($x === 1) {...} else {...}) is unaffected because PHPStan subtracts the literal/enum-case in the falsey branch, so the guard remains disjoint from the other branch's value.Test
bug-14807.php(nsrt): without the fix,$item->readywas inferred astruein the secondif(andfalse-branch became*NEVER*); with the fix it isbool/falseas expected.bug-14807.php(Comparison rule): without the fix,Result of && is always false.was reported on the secondif; with the fix no error is reported.||disjunction, 3-case enums, nullable?->-style property narrowing): none reproduced the bug even before the fix — only!==against a 2-case enum narrows to a single remaining case that produces the offending guard — so no additional code paths needed changing. The fix lives in the single, type-agnostic merge site, so all these constructs are covered by it.Fixes phpstan/phpstan#14807