Skip to content

Skip conditional expression guard when guard type is a subtype of the other branch's value#5848

Merged
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-pmkcr7d
Jun 11, 2026
Merged

Skip conditional expression guard when guard type is a subtype of the other branch's value#5848
ondrejmirtes merged 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-pmkcr7d

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When two if statements reuse the same left-hand condition $enum !== Case::X (a 2-case enum narrowed with !==), and the first if additionally narrows an object property via && $obj->prop === true, PHPStan incorrectly carried that property narrowing into the second if. It then reported $obj->prop === false in the second if as Result of && is always false. / Strict comparison ... will always evaluate to false.. This is a false positive — the first if can be skipped purely because the enum condition was false, leaving the property at its real bool value.

This change makes MutatingScope::createConditionalExpressions() skip the unsound conditional that caused the leak.

Changes

  • src/Analyser/MutatingScope.php: in createConditionalExpressions(), 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->ready is bool (not true) inside the second reused-condition if.
  • tests/PHPStan/Rules/Comparison/data/bug-14807.php + BooleanAndConstantConditionRuleTest::testBug14807(): new rule-level regression test asserting no booleanAnd.alwaysFalse is reported for the reproducer.

Root cause

The conditional-expression mechanism records "when expr equals the value it took in the truthy branch (the guard), target is 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 value expr takes 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:

  • truthy branch: $color === Color::Blue, $item->ready === true (guard $color = Color::Blue),
  • falsey branch of $color !== Red && $item->ready === true: $color = Color::Blue|Color::Red (it still contains Blue, because the && can be false via $item->ready !== true).

Here Color::Blue is a subtype of Color::Blue|Color::Red, so the guard does not discriminate the branches, yet a conditional if $color = Color::Blue then $item->ready = true was 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->ready was inferred as true in the second if (and false-branch became *NEVER*); with the fix it is bool / false as expected.
  • bug-14807.php (Comparison rule): without the fix, Result of && is always false. was reported on the second if; with the fix no error is reported.
  • Probed analogous constructs (swapped operand order, || 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

… 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.
@@ -0,0 +1,33 @@
<?php declare(strict_types = 1);

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.

This needs to havee <?php // lint >= 8.1 on the first line and declare on the 3rd line.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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.1 with declare(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 uses enum and had the identical issue (all other enum-using data files in that directory already follow this convention).

Verification:

  • testBug14807 (rule test) and the nsrt bug-14807 type-inference test both pass.
  • Full BooleanAndConstantConditionRuleTest suite passes (23/23).
  • make phpstan reports 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 ondrejmirtes enabled auto-merge (squash) June 11, 2026 09:42
@ondrejmirtes ondrejmirtes merged commit ad34ca7 into phpstan:2.2.x Jun 11, 2026
131 of 134 checks passed
@ondrejmirtes ondrejmirtes deleted the create-pull-request/patch-pmkcr7d branch June 11, 2026 09:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Object property narrowing leaks out of a previous if when a 2-case enum is narrowed with !==

2 participants