Emit SwitchConditionNode and report always-false switch case comparisons#5854
Emit SwitchConditionNode and report always-false switch case comparisons#5854phpstan-bot wants to merge 17 commits into
Conversation
VincentLanglet
left a comment
There was a problem hiding this comment.
To be consistent with MatchExpressionRule I think the SwitchConditionRule should also report always-true
|
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
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
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. Behavior, verified against matchFor the idiomatic enum- Tests
|
|
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 Switch statements do not have to be exhaustive. We should not steer people away from having Should the new rule for switch statement follow https://phpstan.org/config-reference#reportalwaystrueinlastcondition? And consider even the last non-default case before |
I'm fine with this strategy ; we could still change later. |
VincentLanglet
left a comment
There was a problem hiding this comment.
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)
|
I've addressed VincentLanglet's review. What changedThe review asked: "Do not report always-true error if the only remaining case is the This matches the strategy ondrejmirtes and VincentLanglet agreed on in the discussion — follow
Behavior impactThis directly resolves the concern ondrejmirtes raised about the idiomatic enum-switch + Tests
|
VincentLanglet
left a comment
There was a problem hiding this comment.
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
--------------------------------------------------------------------------------
|
Done. I addressed the coding-standard finding ( foreach ($stmt->cases as $caseKey => $caseNode) {
if ($caseNode->cond === null) {
continue;
}
$lastNonDefaultCaseKey = $caseKey;
}Verification:
Committed and pushed to the PR branch. |
|
This should be ready @ondrejmirtes @staabm |
36c25f8 to
d262b05
Compare
|
reported the larastan errors upstream in larastan/larastan#2504 |
staabm
left a comment
There was a problem hiding this comment.
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 Maybe it's better to just let 712 open for another PR which has to work for both match and switch, like
|
de29e7c to
a77edf9
Compare
|
@staabm @VincentLanglet This would be nice to have but the bot is stuck here for some reaosn, can you please take over and finish? |
a77edf9 to
18016b0
Compare
There was a problem hiding this comment.
Pull request overview
Adds switch-equivalent diagnostics to existing match arm always-false/always-true comparison reporting by introducing a virtual node that captures per-case scopes and a new comparison rule gated behind a feature toggle (enabled in bleeding edge).
Changes:
- Emit
PHPStan\Node\SwitchConditionNode(with per-caseSwitchConditionArmscopes) fromNodeScopeResolverfor each non-defaultcase. - Add
PHPStan\Rules\Comparison\SwitchConditionRuleto reportswitch.alwaysFalse,switch.alwaysTrue, andswitch.duplicateCase. - Add a
switchConditionAlwaysFalsefeature toggle (default off; on in bleeding edge) and add rule tests + data fixtures.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Analyser/NodeScopeResolver.php | Emits SwitchConditionNode for switch statements and captures per-case scopes. |
| src/Node/SwitchConditionNode.php | New virtual node representing a switch subject paired with per-case arms. |
| src/Node/SwitchConditionArm.php | New value object carrying a case condition, its captured scope, and metadata. |
| src/Rules/Comparison/SwitchConditionRule.php | New rule evaluating subject == caseCondition and reporting constant true/false comparisons and duplicates. |
| conf/config.neon | Adds default-off switchConditionAlwaysFalse feature toggle. |
| conf/parametersSchema.neon | Registers the new feature toggle in the parameter schema. |
| conf/config.level4.neon | Conditionally registers SwitchConditionRule at level 4 behind the feature toggle. |
| conf/bleedingEdge.neon | Enables the new feature toggle under bleeding edge. |
| tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.php | Adds rule tests for duplicates, always-true/false, native-mode behavior, and trait handling. |
| tests/PHPStan/Rules/Comparison/data/switch-condition-*.php | Adds test fixtures covering duplicates, enums, impossible cases, trait usage, and native-vs-PHPDoc certainty. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Virtual node emitted once per `switch` statement. It pairs the switch subject | ||
| * with each non-default `case` condition so rules can inspect the loose `==` | ||
| * comparison the `switch` performs, using the scope captured at each case | ||
| * (which already excludes the values matched by earlier cases). |
| unusedLabel: false | ||
| newOnNonObject: false | ||
| finiteTypesInHaystack: false | ||
| switchConditionAlwaysFalse: false |
| [ | ||
| 'Switch condition comparison between int<5, max> and 1 is always false.', | ||
| 50, | ||
| ], | ||
| ]); |
There was a problem hiding this comment.
make phpstan is clean. My change is test/documentation-only, and the targeted test suite passes. Here's a summary of how I addressed the review.
What the review asked
staabm (echoing the copilot bot) asked on testImpossibleCases: "do we miss an error here?" — referring to the case $other on line 66 of switch-condition-always-false-impossible.php, where the switch subject has been exhausted to never. The (now-stale) PR description claimed this fixture "exercises the resolveEqualType fix", but the test asserts no error for it, so a reader can't tell whether the absence is intentional.
Finding
The absence of an error is intentional and correct, and must stay that way:
- At
case $other, the subject narrows to*NEVER*(verified with\PHPStan\dumpType). never == stringresolves to an undecided boolean viaNeverType::looseCompare(), so noswitch.alwaysFalseis emitted.- This is exactly what staabm's own Resolve array offset access on
nevertoneverand treatneveroperands of===/!==as undecided #5906 (cae15455c, on main but not yet in this branch) established:neveroperands of===/==are deliberately undecided "to stop … piling always-true/false errors onto already-unreachable code." Aneversubject means the case sits on unreachable code — flagging it always-false would be that very cascade. Resolve array offset access onnevertoneverand treatneveroperands of===/!==as undecided #5906 explicitly notes==/!=already do this vialooseCompare.
So the original resolveEqualType never→false short-circuit (which staabm questioned earlier and phpstan-bot already reverted) was correctly dropped. Adding the error the bot suggested would directly contradict #5906. We do not miss an error — we intentionally don't report one.
Change made
Since the behavior is right but the fixture was misleading, I made it self-documenting instead of changing any source:
switch-condition-always-false-impossible.php: added a docblock onnonConstantCaseOnExhaustedSubjectexplaining the subject exhausts tonever, that aneveroperand leaves the comparison undecided, and that the trailing case is intentionally not reported.SwitchConditionRuleTest.php: added an explicit no-error note intestImpossibleCasespointing at line 66 and referencing Resolve array offset access onnevertoneverand treatneveroperands of===/!==as undecided #5906.
This turns a confusing fixture into a proper "no false positive on unreachable code" regression guard. Committed as 84733972b and pushed. SwitchConditionRuleTest (6/6) and make phpstan both pass.
One note for the maintainers: this branch still predates #5906, so its resolveIdenticalType currently returns constant false for never while resolveEqualType returns undecided — the rebase ondrejmirtes requested will bring both into the consistent undecided behavior. The switch rule already relies only on the ==/looseCompare path, so its behavior is stable across that rebase.
| $conditionExpr = new Equal($subject, $caseCondition); | ||
|
|
||
| $conditionType = $armScope->getType($conditionExpr); | ||
| if (!$this->isConstantBoolean($conditionType)) { | ||
| $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $conditionExpr); | ||
| continue; |
| } | ||
|
|
||
| /** | ||
| * Once the earlier cases have exhausted the subject, it narrows to `never`. |
There was a problem hiding this comment.
@VincentLanglet do we agree with that comment? maybe I miss something, but I think it should error?
There was a problem hiding this comment.
I think it comes from the initial request from ondrej
We need to think about what to do about those "always true" last cases (or last before default).
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 [phpstan.org/config-reference#reportalwaystrueinlastcondition](https://phpstan.org/config-reference#reportalwaystrueinlastcondition)? And consider even the last non-default case before default as last?
So,
- We don't report
defaultas "always false". - We don't report the last non default as "always true"
I think here it does not report the last non default as "always false". And we should report it...
There was a problem hiding this comment.
Done. The commit is pushed. Here's my summary of how I addressed staabm's review.
The question
staabm challenged the docblock comment on nonConstantCaseOnExhaustedSubject, which claimed a case on a subject already exhausted to never is intentionally not reported:
@VincentLanglet do we agree with that comment? maybe I miss something, but I think it should error?
staabm is right — it should error. The previous "no error" behavior (and the comment defending it) was based on a false premise.
Root cause of the confusion
The earlier revert (ef96c6ed2) kept never == X undecided, justifying it with phpstan-src #5906 ("treat never operands of ===/== as undecided"). But I verified that #5906 is not merged into the target branch 2.2.x — it only lives on a different, unmerged bot patch branch. In the actual 2.2.x world this PR targets:
resolveIdenticalType()returns constantfalsefor aneveroperand →match(which compares via===) does report an exhausted-neverarm as always-false. The live*NEVER* === 'ccc'expectations inStrictComparisonOfDifferentTypesRuleTestprove this.switchcompares via==→resolveEqualType()→NeverType::looseCompare()→ undecidedbool, so it stayed silent.
That divergence is the inconsistency — the exact "same bug in adjacent code" the PR set out to fix, and precisely what VincentLanglet's original review asked for ("be consistent with MatchExpressionRule").
The fix
Restored the resolveEqualType() short-circuit so a never operand resolves to constant false, mirroring resolveIdenticalType(). Now switch ($s) { case 'a': case 'b': ...; case $other: } — where $s narrows to *NEVER* after the exhausting cases — reports switch.alwaysFalse, matching match and if/elseif chains on the same exhausted subject.
src/Reflection/InitializerExprTypeResolver.php— restored theNeverTypeshort-circuit.tests/.../SwitchConditionRuleTest.php— replaced the "no error" note with the expected*NEVER* and string is always falseat line 72.tests/.../data/switch-condition-always-false-impossible.php— rewrote the docblock to explain it is reported, consistently withmatch/if-elseif.tests/PHPStan/Analyser/nsrt/bcmath-number.php—never == X/never != Xback tofalse/true.
Verification
SwitchConditionRuleTest— 6/6 pass; wholeRules/Comparisonsuite — 431/431.NodeScopeResolverTest+ fullAnalysersuite pass (bcmath assertions locked in).- Full
make tests— 17608 pass (97 version-skips). make phpstanclean,phpcsclean.
One forward-looking note for the maintainers: if #5906 later lands on 2.2.x, resolveIdenticalType and resolveEqualType should be flipped to undecided together (with the match/switch/strict-comparison never-expectations updated in the same change) so the two stay consistent — which is the whole point of this fix.
There was a problem hiding this comment.
The review is addressed and the branch is already in the correct state. Here's a summary.
What the review asked
VincentLanglet (echoing staabm and the Copilot bot) pointed out that the rule follows the reportAlwaysTrueInLastCondition strategy — the last non-default case is exempted from always-true reporting — but that exemption was wrongly bleeding into always-false: a trailing case $other sitting on a subject that earlier cases had exhausted to never went unreported, and it should be reported.
- We don't report
defaultas "always false".- We don't report the last non default as "always true".
I think here it does not report the last non default as "always false". And we should report it...
Resolution
While I was implementing a rule-local fix, a parallel run of the pipeline pushed commit 8e741ef81 to the branch, which addresses this exact review with a cleaner, more consistent approach:
src/Reflection/InitializerExprTypeResolver.php—resolveEqualType()now short-circuits aneveroperand to constantfalse, mirroringresolveIdenticalType(). Sonever == Xresolves tofalsejust likenever === X, and the switch rule's existing always-false path reports it — consistently with how the equivalentmatcharm andif/elseifchain already behave on an exhausted subject. This matches the base branch's (2.2.x) convention, where===withneveris already constantfalse.tests/.../SwitchConditionRuleTest.php+data/switch-condition-always-false-impossible.php— the previously-suppressedcase $otheron line 72 now assertsSwitch condition comparison between *NEVER* and string is always false., and the fixture docblock documents the new behavior.tests/.../nsrt/bcmath-number.php—never == X/never != Xassertions restored tofalse/true.
I reset my local rule-local variant (which would have been redundant on top of this) to match the remote HEAD and verified the pushed fix holds up broadly:
make phpstan— cleanSwitchConditionRuleTest,MatchExpressionRuleTest,StrictComparisonOfDifferentTypesRuleTest— 141/141 pass- Full
NodeScopeResolverTest(allnsrt, incl.bcmath-number.php) — 1678/1678 pass AnalyserIntegrationTest— 184/184 pass
The branch (8e741ef81) is in sync with origin, working tree clean — no further commit was needed since the parallel push already landed the correct fix and I confirmed it doesn't regress the codebase-wide == change.
| switch ($m) { | ||
| case 1: | ||
| break; | ||
| case '1': | ||
| break; | ||
| case 1.0: | ||
| break; | ||
| case true: | ||
| break; | ||
| case 0: | ||
| break; | ||
| case false: | ||
| break; | ||
| } |
There was a problem hiding this comment.
I think we miss a error here.
after 1 matched, '1' can no longer match, because switch does loose type comparisons
There was a problem hiding this comment.
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
1matched,'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 1 — case 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 tests— 17608 pass (97 version-skips).make phpstanclean,make csclean.
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.
d3adb95 to
712cf29
Compare
- 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>
712cf29 to
088d747
Compare
Summary
PHPStan already reports always-false
matcharm comparisons (match.alwaysFalse) by tracking types inScope, but had no equivalent forswitch. This adds that diagnostic forswitchstatements: acasewhose loose==comparison against the switch subject can never be true is now reported asswitch.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 duplicateint/enum/boolcases. As withmatch, generalstringsubjects 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 aSwitchConditionNodefor every non-defaultcase, 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 looseEqual(subject, caseCondition)comparison and reportsswitch.alwaysFalsewhen it is constantlyfalse. MirrorsMatchExpressionRule's native-type /treatPhpDocTypesAsCertain, boolean-subject and in-trait handling.conf/config.neon,conf/parametersSchema.neon,conf/bleedingEdge.neon,conf/config.level4.neon: newswitchConditionAlwaysFalsefeature toggle (off by default, on under bleeding edge), registering the rule at level 4.src/Reflection/InitializerExprTypeResolver.php:resolveEqualType()now returnsfalsewhen either operand isNeverType, mirroringresolveIdenticalType().Root cause
switchhad no type-based always-false detection at all — only the never-merged syntacticDuplicateCasesInSwitchRule(#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-circuitedNeverTypeoperands tofalse, but its siblingresolveEqualType()did not and fell through tolooseCompare(), yieldingbool. As a result a switch subject narrowed tonever(all cases exhausted) producednever == X === bool, hiding the always-false case. FixingresolveEqualType()makes loose and strict comparison consistent fornever.Test
tests/PHPStan/Rules/Comparison/SwitchConditionRuleTest.phpwith data files:switch-condition-always-false.php— the samples from phpstan-src#5849, asserting the different errors the scope-based approach produces (duplicateintcase, duplicatetrue/nullonmixed).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 theresolveEqualTypefix.switch-condition-always-false-native.php—treatPhpDocTypesAsCertain = falsemode.tests/PHPStan/Analyser/nsrt/bcmath-number.phpupdated:never == X/never != Xnow inferfalse/true, locking in theresolveEqualTypefix.Fixes phpstan/phpstan#14815