Skip to content

Emit SwitchConditionNode and report always-false switch case comparisons - #5854

Merged
staabm merged 18 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-pndxujx
Jul 30, 2026
Merged

Emit SwitchConditionNode and report always-false switch case comparisons#5854
staabm merged 18 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-pndxujx

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

PHPStan already reports always-false match arm comparisons (match.alwaysFalse) by tracking types in Scope, but had no equivalent for switch. This adds that diagnostic for switch statements: a case whose loose == comparison against the switch subject can never be true is now reported as switch.alwaysFalse.

This catches type-incompatible cases (e.g. switch ($int) { case 'foo': }phpstan/phpstan#712), cases on already-exhausted subjects (unions/enums narrowed away by earlier cases), and duplicate int/enum/bool cases. As with match, general string subjects that PHPStan cannot narrow are intentionally not flagged.

Changes

  • src/Node/SwitchConditionNode.php (new): virtual node pairing the switch subject with a case condition.
  • src/Analyser/NodeScopeResolver.php: emit a SwitchConditionNode for every non-default case, using the scope captured right after the case condition is processed (which already excludes values matched by earlier terminating cases).
  • src/Rules/Comparison/SwitchConditionRule.php (new): builds the loose Equal(subject, caseCondition) comparison and reports switch.alwaysFalse when it is constantly false. Mirrors MatchExpressionRule's native-type / treatPhpDocTypesAsCertain, boolean-subject and in-trait handling.
  • conf/config.neon, conf/parametersSchema.neon, conf/bleedingEdge.neon, conf/config.level4.neon: new switchConditionAlwaysFalse feature toggle (off by default, on under bleeding edge), registering the rule at level 4.
  • src/Reflection/InitializerExprTypeResolver.php: resolveEqualType() now returns false when either operand is NeverType, mirroring resolveIdenticalType().

Root cause

switch had no type-based always-false detection at all — only the never-merged syntactic DuplicateCasesInSwitchRule (#5849) approached the problem, and only for syntactic duplicates. The scope-tracking approach reuses the narrowing PHPStan already performs between cases.

The accompanying type-system bug is an instance of "the same bug in adjacent code": resolveIdenticalType() short-circuited NeverType operands to false, but its sibling resolveEqualType() did not and fell through to looseCompare(), yielding bool. As a result a switch subject narrowed to never (all cases exhausted) produced never == X === bool, hiding the always-false case. Fixing resolveEqualType() makes loose and strict comparison consistent for never.

Test

  • tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.php with data files:
    • switch-condition-always-false.php — the samples from phpstan-src#5849, asserting the different errors the scope-based approach produces (duplicate int case, duplicate true/null on mixed).
    • switch-condition-always-false-enum.php — duplicate enum case.
    • switch-condition-always-false-impossible.php — type mismatch (More precise dirname signature #712), exhausted literal-int / string unions, integer ranges, and a non-constant case on an exhausted (never) subject that specifically exercises the resolveEqualType fix.
    • switch-condition-always-false-native.phptreatPhpDocTypesAsCertain = false mode.
  • tests/PHPStan/Analyser/nsrt/bcmath-number.php updated: never == X / never != X now infer false / true, locking in the resolveEqualType fix.

Fixes phpstan/phpstan#14815

Comment thread src/Rules/Comparison/SwitchConditionRule.php Outdated

@VincentLanglet VincentLanglet left a comment

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.

To be consistent with MatchExpressionRule I think the SwitchConditionRule should also report always-true

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. Here's a summary of how I addressed VincentLanglet's review ("To be consistent with MatchExpressionRule I think the SwitchConditionRule should also report always-true").

What changed

SwitchConditionRule now reports switch.alwaysTrue in addition to switch.alwaysFalse, mirroring MatchExpressionRule exactly — including the same message shape, the "Remove remaining cases below this one…" tip, the last-case exemption, and suppression of subsequently-dead cases.

To get true consistency I had to address a structural difference. Reporting always-true correctly requires cross-case state (when a case is always-true, the cases below it become dead and must be suppressed rather than reported as always-false — exactly what MatchExpressionRule does via $nextArmIsDeadForType). The original per-case SwitchConditionNode couldn't share state across cases, so I redesigned it to match MatchExpressionNode:

  • SwitchConditionNode is now emitted once per switch, carrying every non-default case.
  • SwitchConditionArm (new) pairs each case condition with the scope captured at that case and whether it's the last case — analogous to MatchExpressionArmCondition.
  • SwitchConditionRule iterates the arms with dead-case tracking, just like the match rule.

A welcome side effect: reading each case's scope at the settled point (like match does) produces more precise subject types in messages (e.g. int<min, 0>|int<3, max> and Status::Pending instead of int/Status).

Behavior, verified against match

For the idiomatic enum-switch + defensive default: throw pattern, the second-to-last case is flagged always-true — I confirmed match already emits the identical error on the exact same code, so this is consistent, not a new false positive. This surfaced in AnalyserIntegrationTest::testBug7927 (runs with bleeding edge), whose expectation I updated to include the consistent error.

Tests

  • Updated SwitchConditionRuleTest expectations; added testAlwaysTrue with a dedicated data file covering: non-last always-true (reported), last-case always-true (exempted), always-true-before-default, and suppression of dead cases below.
  • Added an in-trait always-true case to exercise the previously-untested emitError(…, true, …) path.
  • Restructured nonConstantCaseOnExhaustedSubject to a fall-through group so the resolveEqualType (never == X) path is still exercised as an always-false report rather than being suppressed.

make tests (12674 passing) and make phpstan (clean) both pass. Committed and pushed to the PR branch.

@ondrejmirtes

Copy link
Copy Markdown
Member

We need to think about what to do about those "always true" last cases (or last before default).

Match expression is supposed to be exhaustive. We're steering people away from having default because without default PHPStan can afford (and should report) when there's an unhandled value (missing arm for a newly-added enum case for example). With default in place we cannot report anything because the default consumes everything. But it's better to have a separate arm for each case.

Switch statements do not have to be exhaustive. We should not steer people away from having default.

Should the new rule for switch statement follow https://phpstan.org/config-reference#reportalwaystrueinlastcondition? And consider even the last non-default case before default as last?

@VincentLanglet

Copy link
Copy Markdown
Contributor

Should the new rule for switch statement follow phpstan.org/config-reference#reportalwaystrueinlastcondition? And consider even the last non-default case before default as last?

I'm fine with this strategy ; we could still change later.

@VincentLanglet VincentLanglet left a comment

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.

Do not report always-true error if the only remaining case is the default one.

(Basically a case which is only followed by default could be considered as last too)

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed VincentLanglet's review.

What changed

The review asked: "Do not report always-true error if the only remaining case is the default one. (Basically a case which is only followed by default could be considered as last too)"

This matches the strategy ondrejmirtes and VincentLanglet agreed on in the discussion — follow reportAlwaysTrueInLastCondition semantics, where a trailing default should not count as a case that an always-true comparison would make unreachable.

src/Analyser/NodeScopeResolver.phpSwitchConditionArm.isLast() was computed via array_key_last($stmt->cases), so when a default was the syntactically last case, no non-default case was ever marked "last". I now precompute the key of the last non-default case, so the last real case is treated as last even when a default follows it. Removed the now-unused array_key_last import.

src/Node/SwitchConditionArm.php — updated the isLast() docblock to describe the new semantics (only a default may follow).

Behavior impact

This directly resolves the concern ondrejmirtes raised about the idiomatic enum-switch + default: throw pattern: AnalyserIntegrationTest::testBug7927's case Test::Two: ... default: throw no longer reports always-true, so I reverted that expectation back to 2 errors.

Tests

  • SwitchConditionRuleTest::testAlwaysTrue — dropped the line-58 expectation (case Suit::Diamonds before default is now exempt); the data file's alwaysTrueBeforeDefault now documents the new no-error behavior.
  • switch-condition-in-trait.phpdoFoo3's always-true case was followed only by default, which would have lost coverage of the always-true-in-trait emitError path; added a following case so the always-true case is genuinely non-last and still reported.
  • Verified the test fails for the right reason without the source change (git stash confirmed both cases-before-default get flagged), then passes with it.

make tests (relevant suites) and make phpstan (clean) both pass. Committed and pushed.

@VincentLanglet VincentLanglet left a comment

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.

Fix

FILE: src/Analyser/NodeScopeResolver.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
 2013 | ERROR | [x] Use early exit to reduce code nesting.
      |       |     (SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed)
--------------------------------------------------------------------------------
PHPCBF CAN FIX THE 1 MARKED SNIFF VIOLATIONS AUTOMATICALLY
--------------------------------------------------------------------------------

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed the coding-standard finding (SlevomatCodingStandard.ControlStructures.EarlyExit.EarlyExitNotUsed at NodeScopeResolver.php:2013) by inverting the condition in the last-non-default-case loop to use an early continue instead of nesting:

foreach ($stmt->cases as $caseKey => $caseNode) {
    if ($caseNode->cond === null) {
        continue;
    }

    $lastNonDefaultCaseKey = $caseKey;
}

Verification:

  • make cs — clean (phpcs passes on all files)
  • make phpstan — no errors
  • SwitchConditionRuleTest — 6/6 tests pass

Committed and pushed to the PR branch.

Comment thread tests/PHPStan/Rules/Comparison/data/switch-condition-always-false.php Outdated
Comment thread tests/PHPStan/Rules/Comparison/data/switch-condition-always-false.php Outdated
Comment thread tests/PHPStan/Rules/Comparison/data/switch-condition-in-trait.php Outdated
Comment thread tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.php
Comment thread tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.php
Comment thread tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.php
@VincentLanglet

Copy link
Copy Markdown
Contributor

This should be ready @ondrejmirtes @staabm

@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch from 36c25f8 to d262b05 Compare June 27, 2026 05:47
@staabm

staabm commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

reported the larastan errors upstream in larastan/larastan#2504

@staabm staabm left a comment

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.

this PR does not add new errors for phpstan/phpstan#712 which we expect from it

Comment thread src/Reflection/InitializerExprTypeResolver.php
Comment thread src/Rules/Comparison/SwitchConditionRule.php Outdated
@VincentLanglet

Copy link
Copy Markdown
Contributor

this PR does not add new errors for phpstan/phpstan#712 which we expect from it

@staabm I don't think a special handling should be done here, since it's not handled in
https://phpstan.org/r/721fc05b-1be4-4d50-ac11-c7e4ae5072ac

Maybe it's better to just let 712 open for another PR which has to work for both match and switch, like

  • track the constant scalar values
  • or make the string type Substrable

@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch from de29e7c to a77edf9 Compare June 27, 2026 08:05

@ondrejmirtes ondrejmirtes left a comment

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.

Please rebase

@ondrejmirtes

Copy link
Copy Markdown
Member

@staabm @VincentLanglet This would be nice to have but the bot is stuck here for some reaosn, can you please take over and finish?

@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch from a77edf9 to 18016b0 Compare July 18, 2026 11:20
@staabm
staabm requested review from VincentLanglet and removed request for VincentLanglet July 18, 2026 11:55
Comment on lines +148 to +161
switch ($m) {
case 1:
break;
case '1':
break;
case 1.0:
break;
case true:
break;
case 0:
break;
case false:
break;
}

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.

I think we miss a error here.
after 1 matched, '1' can no longer match, because switch does loose type comparisons

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. Here's a summary of how I addressed staabm's review.

What the review asked

staabm flagged looseEquality() in switch-condition-always-false.php:

I think we miss an error here. after 1 matched, '1' can no longer match, because switch does loose type comparisons

Why the scope-based path missed it

I verified with \PHPStan\dumpType that the subject stays mixed at case '1' — after a terminating case 1, PHPStan can't subtract the loosely-==1 values from mixed, and it also doesn't narrow mixed == '1' to a finite set. So the existing always-false / scope-narrowing detection is structurally unable to catch this, and the duplicate detection used strict equals(), treating '1' and 1 as distinct.

The fix

SwitchConditionRule::isDuplicateCase() now also treats two numerically-equal constant case values (1, '1', 1.0, '0', …) as duplicates, via Type::looseCompare() with the injected PhpVersion.

The key correctness point is the numeric guard: I deliberately do not use bare loose equality, because loose == match-sets aren't a partition. 1 == true and 1 == '1' are both true, but only '1' is a real duplicate of 1case true still matches subjects like 2 that no earlier case caught, so it must stay reachable. Booleans, null and non-numeric strings have broader match-sets than a single value, so they're left to the strict check. Restricting the loose path to int|float|numeric-string constants is provably correct (numeric equality is version-independent and its match-sets coincide) and avoids the PHP 7/8 0 == 'foo' traps.

So looseEquality() now reports case '1' (line 151) and case 1.0 (line 153) as duplicates of case 1 (line 149), while case true, case 0, case false are correctly left alone.

Verification

  • Confirmed the two new expectations fail without the loose-matching logic (temporarily neutralized it → the exact two errors dropped), then pass with it.
  • SwitchConditionRuleTest — 6/6 pass.
  • make tests17608 pass (97 version-skips).
  • make phpstan clean, make cs clean.

Committed as d4cb301e5 and pushed.

One scoping note: this stays within the numeric-constant case staabm pointed at. Fully general loose-duplicate detection for arbitrary string subjects (phpstan/phpstan#712) is the broader item VincentLanglet suggested deferring to a follow-up that handles both match and switch, so I did not expand into that here.

@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch 2 times, most recently from 712cf29 to 088d747 Compare July 19, 2026 06:08
@staabm
staabm dismissed ondrejmirtes’s stale review July 19, 2026 06:09

rebased manually

@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch 2 times, most recently from 5985d5b to dc03471 Compare July 22, 2026 07:29
ondrejmirtes and others added 17 commits July 29, 2026 17:36
- Add `SwitchConditionNode` virtual node, emitted by `NodeScopeResolver` for
  every non-default `case`, pairing the switch subject with the case condition.
- Add `SwitchConditionRule` (level 4, gated behind the bleeding-edge
  `switchConditionAlwaysFalse` feature toggle) which inspects the loose `==`
  comparison the `switch` performs in the scope captured at the case condition.
  That scope already excludes the values matched by earlier (terminating) cases,
  so the rule reports type-incompatible cases (phpstan/phpstan#712), exhausted
  unions/enums, integer-range/literal cases and duplicate int/enum/bool cases as
  `switch.alwaysFalse`. This mirrors how `MatchExpressionRule` reports
  `match.alwaysFalse`.
- Fix `InitializerExprTypeResolver::resolveEqualType()` to return
  `false` when either operand is `NeverType`, mirroring the existing handling in
  `resolveIdenticalType()`. Without this, `never == X` returned `bool`, so
  exhausted switch subjects (narrowed to `never`) were not detected.
- Update `nsrt/bcmath-number.php` expectations: `never == X` / `never != X` are
  now `false` / `true` (previously the inconsistent `bool`), matching `===`/`!==`.
Mirror MatchExpressionRule's trait handling test: combine the rule with
ConstantConditionInTraitRule via CompositeRule and assert that an
always-false switch case inside a trait is reported once when constant in
every using class, while a case that is constant in only some contexts is
not reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror MatchExpressionRule by also reporting switch cases whose loose `==`
comparison against the subject can never be false. A case is flagged
`switch.alwaysTrue` when the subject is narrowed (by earlier terminating
cases) to exactly match it, unless it is the last case of the switch — in
which case the always-true comparison makes nothing unreachable. As with
match, once a case is always-true the subsequent (now dead) cases are
suppressed instead of being reported as always-false.

To track this across cases, SwitchConditionNode is now emitted once per
switch carrying every non-default case together with the scope captured at
that case (like MatchExpressionNode), and SwitchConditionRule iterates them
with cross-case dead-case tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `case` followed only by `default` does not make any subsequent `case`
unreachable, so an always-true comparison there is fine — just like the
genuinely last `case`. This mirrors reportAlwaysTrueInLastCondition and
keeps the idiomatic enum-switch + `default: throw` pattern from being
flagged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The switch-condition-always-false.php test data file used the native
`mixed` type hint, which is only available on PHP 8.0+. Use a
`@param mixed` PHPDoc instead so the file lints on PHP 7.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Enum-based tests (testRuleEnum, testAlwaysTrue, testImpossibleCases)
require PHP 8.1, so guard them with #[RequiresPhp]. The int == 'foo'
loose comparison is only always-false since PHP 8.0 (before that a
non-numeric string loosely equals 0), so testDoNotTreatPhpDocTypesAsCertain
expects no error on PHP < 8.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the resolveEqualType() short-circuit that made `never == X` /
`never != X` evaluate to a constant `false` / `true`. A `never` operand
denotes unreachable code that carries no comparable value, so the result
should stay an undecided `bool` (via NeverType::looseCompare()), matching
the direction taken for strict comparison in phpstan-src#5906.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scope-based always-false detection cannot flag a duplicate `case`
when the switch subject is a general, non-narrowable type such as
`string` (subtracting a single literal from `string` is not
representable), so duplicates like the ones in phpstan/phpstan#712 went
unreported.

Track the constant scalar / enum-case value of each `case` condition and
report a later `case` carrying a value already seen earlier as a
`switch.duplicateCase` error pointing at the original case. This takes
precedence over the always-false/always-true reporting for that arm, so
every exact-duplicate case is reported the same way regardless of whether
the subject type happens to narrow.

Closes phpstan/phpstan#712

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the bespoke scalar/enum case-key construction with the Type API:
a case carries a single definite value when getFiniteTypes() returns exactly
one type, and two such values are duplicates when they equals() each other.
This works uniformly for scalars, enum cases, bool and null without
reconstructing identity by hand, and follows the codebase convention of
comparing types via equals() rather than ad-hoc keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A case whose subject was already exhausted to never sits on unreachable
code. A never operand keeps the loose comparison undecided (see phpstan#5906,
which established never operands of ===/== as undecided precisely to stop
piling always-true/false errors onto unreachable code), so the trailing
case is deliberately not reported. Clarify the misleading fixture and add
an explicit no-error note to the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…er cases

resolveEqualType() now short-circuits a `never` operand to constant `false`,
mirroring resolveIdenticalType(). A switch subject narrowed to `never` because
earlier cases exhausted every possible value now produces an always-false
`switch.alwaysFalse` report, consistently with how the equivalent `match` arm
(via `===`) and `if`/`elseif` chain already behave on an exhausted subject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`switch` compares subjects with loose `==`, so numerically-equal constant
cases like `case 1`, `case '1'` and `case 1.0` all match the exact same
subjects and cannot be told apart. Detect a later such case as a duplicate
of an earlier one, in addition to the strict-equality check. Booleans, null
and non-numeric strings are left to the strict check because their loose
match sets are broader than a single value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
staabm force-pushed the create-pull-request/patch-pndxujx branch from dc03471 to ca306a2 Compare July 29, 2026 15:36
@staabm
staabm merged commit 6e24090 into phpstan:2.2.x Jul 30, 2026
738 of 745 checks passed
@staabm
staabm deleted the create-pull-request/patch-pndxujx branch July 30, 2026 07:50
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.

Detect always false switch case conditions

5 participants