Skip to content

Skip boolean conditional holders whose target was dropped as a no-op antecedent narrowing#5958

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-x01hq09
Open

Skip boolean conditional holders whose target was dropped as a no-op antecedent narrowing#5958
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-x01hq09

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When a guard such as if ($a === $b && $a !== "" && $b !== "") { return; } was
followed by an (even empty) sibling if ($a !== "") {} block, PHPStan later
narrowed $a to '' and reported a bogus "Strict comparison using !== between
'' and '' will always evaluate to false". The narrowing was unsound: $a and
$b can each be any distinct non-empty string in the else branch.

The fix makes the boolean conditional-expression holder mechanism refuse to build
a holder whose consequent targets an expression that only entered the antecedent
through a relation it cannot represent.

Changes

  • src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php:
    • processBooleanConditionalTypes() now records every condition-side expression
      whose narrowing is a no-op and therefore gets dropped from the antecedent
      ($droppedNoOpConditions).
    • When projecting consequents, it skips any holder target that was dropped as a
      no-op condition, since the antecedent no longer constrains that target.
  • tests/PHPStan/Analyser/nsrt/bug-14891.php: regression test covering the
    reported === case plus the analogous cases fixed by the same change.

Root cause

The holder mechanism turns !(Left && Right) into implications like
"if Left is true then Right is false". The antecedent is built from type
narrowings of the operands. A relation like $a === $b between two variables of
the same broad type (string) narrows nothing, so it is a no-op and gets dropped
(this drop was already done by #5951). Dropping it leaves an antecedent that is
strictly weaker than "Left is true" — here just "$a is non-empty-string" —
while the consequent still claims something about $b ($b === ''). Because the
only thing that tied $b into the antecedent was the dropped $a === $b
relation, the resulting holder "if $a is non-empty-string then $b is ''"
fires whenever $a is non-empty, regardless of $b.

The pattern is: a consequent must not be projected onto an expression whose sole
link to the antecedent was a relational condition the type-guard mechanism can't
represent.
Detecting that the target itself was dropped as a no-op condition is
exactly this signal.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14891.php reproduces the reported case with
    assertType() ($a stays string, and non-empty-string after if ($a !== "")).
    It fails before the fix ($a was '', then *NEVER*) and passes after.
  • It also covers three analogous cases found on the same axis, each independently
    verified to fail before the fix and pass after:
    • a loose == relational antecedent,
    • a relation between two property fetches ($this->a === $this->b),
    • a relation between two integers with integer-range arms
      ($a === $b && $a > 0 && $b > 0, where $a was wrongly narrowed to int<min, 0>).
  • The BooleanOr path shares the same helper, so it is covered by the same change.
  • The degenerate-antecedent regressions from Skip degenerate no-op antecedents when building boolean conditional expression holders #5951 (bug-14878,
    boolean-and-conditional-holders-mixed-context, bug-6202, bug-14874,
    bug-10644, negated-boolean-and-conditional-holders) still pass.

Fixes phpstan/phpstan#14891

…antecedent narrowing

- In ConditionalExpressionHolderHelper::processBooleanConditionalTypes, track
  which condition-side expressions produced a no-op narrowing and were dropped
  from the antecedent (e.g. `$a === $b` between two variables of the same broad
  type, which couples the two without changing either type).
- When building a holder, skip any target expression that was dropped as a no-op
  condition. Its only link into the antecedent was the un-representable relation,
  so a holder projected onto it would fire even when that relation does not hold.
- This fixes unsound holders like "if $a is non-empty-string then $b is ''"
  generated from `!($a === $b && $a !== "" && $b !== "")`, which wrongly narrowed
  $a to '' in a later branch.
- Same defect (and same fix) covers the loose `==` relational antecedent, relations
  between property fetches, and integer-range narrowings; the shared helper is used
  by both BooleanAndHandler and BooleanOrHandler.
@staabm staabm requested review from VincentLanglet and Copilot and removed request for Copilot July 1, 2026 05:07
Comment on lines +212 to +217
// The condition side narrowed this same expression to a no-op — e.g.
// the relation `$a === $b` between two variables of the same broad type,
// which couples the target into the antecedent without changing its
// type. Dropping that no-op leaves an antecedent that no longer
// constrains this target, so a holder projecting a consequent onto it
// would fire even when the dropped relation does not hold. Skip it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shorter comment

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 VincentLanglet's review comment requesting a shorter comment.

Change: Condensed the six-line explanatory comment on the droppedNoOpConditions skip down to three lines that keep the essential reasoning:

// The target's only link to the antecedent was a no-op relation (e.g.
// `$a === $b`) that got dropped, so the antecedent no longer constrains
// it. Projecting a consequent onto it would fire unsoundly. Skip it.

Verification:

  • make phpstan (on the changed file) → No errors
  • NodeScopeResolverTest (which runs all nsrt/ data files, including bug-14891.php) → OK (1668 tests, 1668 assertions)
  • AnalyserIntegrationTest bug suite → OK (148 tests)

Committed as 948ed69dc and pushed to create-pull-request/patch-x01hq09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@VincentLanglet VincentLanglet requested a review from staabm July 1, 2026 07:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants