🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Narrow switch case branch from the case condition's plain scope instead of its truthy scope - #5852

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-yg4v2lz
Jun 12, 2026
Merged

Narrow switch case branch from the case condition's plain scope instead of its truthy scope#5852
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-yg4v2lz

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan reported $i === 0 as "always false" inside a switch case even
though it is reachable at runtime:

/** @param int<0, 5> $range */
public function doFoo(int $i, int $range): void {
    switch ($i) {
        case $range:
            if ($i === 0) { // wrongly reported "always false"
                echo 'zero';
                return;
            }
        case 0:
            break;
    }
}

In the case $range branch PHPStan narrowed $i to int<1, 5> instead of
int<0, 5>, dropping the value 0.

Changes

  • src/Analyser/NodeScopeResolver.php: when narrowing a switch case
    branch, start from $caseResult->getScope() instead of
    $caseResult->getTruthyScope() before applying
    filterByTruthyValue($cond == $case).
  • tests/PHPStan/Analyser/nsrt/bug-14814.php: new regression test covering
    the reported int<0, 5> range case, the case-condition value itself, and
    the analogous string (''|'a'|'b') case.
  • tests/PHPStan/Analyser/nsrt/switch-instanceof-not.php: updated to assert
    the now-correct narrowing (switch (false) { case $foo instanceof Foo: }
    narrows $foo to the non-Foo member of the union instead of producing
    the bogus *NEVER*).
  • tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php: updated the
    bug-6407 expectation (see Root cause).

Root cause

A switch case case X: is entered when $cond == X — this is completely
independent of whether X is truthy. The branch scope was being taken from
$caseResult->getTruthyScope(), i.e. the scope in which the case-condition
expression is assumed truthy. For a non-boolean case condition this is wrong:

  • For a variable case condition (case $range, $range of int<0, 5>), the
    truthy scope narrows $range to int<1, 5>, and the subsequent
    filterByTruthyValue($i == $range) then propagates that to $i, so $i
    loses 0.
  • For a falsy literal case condition (case 0, case '', …) the truthy
    scope is contradictory, with the result that the switch subject was not
    narrowed to the case value at all.

The getTruthyScope() call was originally introduced to pick up the
type-narrowing side effects of boolean case conditions in switch (true)
(bug-5326, e.g. preg_match(..., $match) defining $match). Those side
effects are still produced when narrowing from the plain scope, because
filterByTruthyValue($cond == $caseExpr) re-applies the truthy narrowing of
the boolean case condition. bug-5326 continues to pass.

This brings switch in line with match, whose MatchHandler already
narrows arms from $armCondResult->getScope() (not a truthy scope), so the
analogous match (true) { ... } construct never had this bug.

Note on bug-6407

bug-6407's match test previously expected no errors. That expectation
only held because of this same bug: a case 0 (and the other falsy/literal
cases) failed to narrow $packet->type, so the post-switch type stayed
int and the later match looked non-exhaustive. With falsy case values now
narrowing correctly, the subject is 0|1|2|3|4, the match is genuinely
exhaustive, and PHPStan correctly flags the last redundant arm as always true
(the standard behaviour for an exhaustive match carrying a redundant
default). The test was updated to expect that error.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14814.php reproduces the reported issue
    and fails before the fix (int<1, 5> instead of int<0, 5>), as does the
    analogous string case ('a'|'b' instead of ''|'a'|'b'), confirming the
    bug is not specific to integer ranges.
  • Full suite (make tests), self-analysis (make phpstan) and make cs-fix
    are green. The make name-collision failure on
    tests/PHPStan/Build/data/final-class-rule-pipe.php is pre-existing and
    unrelated to this change.

Fixes phpstan/phpstan#14814

…ad of its truthy scope

- In `NodeScopeResolver`, a `switch` case branch is now narrowed from
  `$caseResult->getScope()` rather than `$caseResult->getTruthyScope()`
  before applying `filterByTruthyValue($cond == $case)`.
- A `case` matches when `$cond == $caseCond`, which has nothing to do with
  whether `$caseCond` is itself truthy. Using the truthy scope wrongly
  assumed the case-condition expression was truthy, which removed every
  falsy value from it (e.g. `0`, `''`) and then propagated that removal to
  the switch subject through the `==` comparison.
- This caused `switch ($i) { case $range: ... }` with `$range` of type
  `int<0, 5>` to narrow `$i`/`$range` to `int<1, 5>`, producing a false
  "always false" report for `$i === 0`. It also meant a `case 0:` (or any
  falsy literal) failed to narrow the switch subject at all.
- This matches how `match` already works (`MatchHandler` narrows from
  `$armCondResult->getScope()`), so `switch` and `match` are now consistent.
- The `switch(true)`-with-side-effects scenario (bug-5326) still works
  because `filterByTruthyValue` re-derives the truthy narrowing of boolean
  case conditions.
- Updated `bug-6407` match-expression test expectation: with falsy case
  values now narrowing correctly, the `match` is genuinely exhaustive and
  PHPStan correctly reports the redundant arm as always true (the old
  "no errors" expectation relied on the falsy-case narrowing bug).
@ondrejmirtes
ondrejmirtes merged commit 16de08a into phpstan:2.2.x Jun 12, 2026
665 of 670 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-yg4v2lz branch June 12, 2026 08:23
@staabm

staabm commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

with this PR we got a "is always true" error for PMMP:
https://github.com/phpstan/phpstan-src/actions/runs/27403310547/job/80986601049?pr=5852

is it expected?

@ondrejmirtes

Copy link
Copy Markdown
Member

Yes, it's the same code that's in a modified test here. Removing default still applies here, the error attaches a tip:

Remove remaining cases below this one and this error will disappear too.

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.

Switch bug

3 participants