Narrow switch case branch from the case condition's plain scope instead of its truthy scope - #5852
Merged
ondrejmirtes merged 1 commit intoJun 12, 2026
Conversation
…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).
Contributor
|
with this PR we got a "is always true" error for PMMP: is it expected? |
Member
|
Yes, it's the same code that's in a modified test here. Removing
|
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
PHPStan reported
$i === 0as "always false" inside aswitchcase eventhough it is reachable at runtime:
In the
case $rangebranch PHPStan narrowed$itoint<1, 5>instead ofint<0, 5>, dropping the value0.Changes
src/Analyser/NodeScopeResolver.php: when narrowing aswitchcasebranch, start from
$caseResult->getScope()instead of$caseResult->getTruthyScope()before applyingfilterByTruthyValue($cond == $case).tests/PHPStan/Analyser/nsrt/bug-14814.php: new regression test coveringthe reported
int<0, 5>range case, the case-condition value itself, andthe analogous string (
''|'a'|'b') case.tests/PHPStan/Analyser/nsrt/switch-instanceof-not.php: updated to assertthe now-correct narrowing (
switch (false) { case $foo instanceof Foo: }narrows
$footo the non-Foomember of the union instead of producingthe bogus
*NEVER*).tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php: updated thebug-6407expectation (see Root cause).Root cause
A
switchcasecase X:is entered when$cond == X— this is completelyindependent of whether
Xis truthy. The branch scope was being taken from$caseResult->getTruthyScope(), i.e. the scope in which the case-conditionexpression is assumed truthy. For a non-boolean case condition this is wrong:
case $range,$rangeofint<0, 5>), thetruthy scope narrows
$rangetoint<1, 5>, and the subsequentfilterByTruthyValue($i == $range)then propagates that to$i, so$iloses
0.case 0,case '', …) the truthyscope 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 thetype-narrowing side effects of boolean case conditions in
switch (true)(bug-5326, e.g.
preg_match(..., $match)defining$match). Those sideeffects are still produced when narrowing from the plain scope, because
filterByTruthyValue($cond == $caseExpr)re-applies the truthy narrowing ofthe boolean case condition. bug-5326 continues to pass.
This brings
switchin line withmatch, whoseMatchHandleralreadynarrows arms from
$armCondResult->getScope()(not a truthy scope), so theanalogous
match (true) { ... }construct never had this bug.Note on bug-6407
bug-6407'smatchtest previously expected no errors. That expectationonly held because of this same bug: a
case 0(and the other falsy/literalcases) failed to narrow
$packet->type, so the post-switchtype stayedintand the latermatchlooked non-exhaustive. With falsy case values nownarrowing correctly, the subject is
0|1|2|3|4, thematchis genuinelyexhaustive, and PHPStan correctly flags the last redundant arm as always true
(the standard behaviour for an exhaustive
matchcarrying a redundantdefault). The test was updated to expect that error.Test
tests/PHPStan/Analyser/nsrt/bug-14814.phpreproduces the reported issueand fails before the fix (
int<1, 5>instead ofint<0, 5>), as does theanalogous string case (
'a'|'b'instead of''|'a'|'b'), confirming thebug is not specific to integer ranges.
make tests), self-analysis (make phpstan) andmake cs-fixare green. The
make name-collisionfailure ontests/PHPStan/Build/data/final-class-rule-pipe.phpis pre-existing andunrelated to this change.
Fixes phpstan/phpstan#14814