Skip to content

Don't treat a breakless default case as a switch terminator - #187

Open
junoatwork wants to merge 1 commit into
mainfrom
jsuarez_result_ok_overnarrowing
Open

Don't treat a breakless default case as a switch terminator#187
junoatwork wants to merge 1 commit into
mainfrom
jsuarez_result_ok_overnarrowing

Conversation

@junoatwork

@junoatwork junoatwork commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

hakana over-narrows a variable on the path after a guard block when that block ends in a switch whose default case falls off the end without break/return. It concludes the guard always exits, so it applies the guard's negated type narrowing to the continuation — even though control actually reaches the continuation with the variable still its original (wider) type. hh_client types the variable as the union here; hakana should too.

function foo(Base $x): void {
    if (!$x is Sub) {
        switch (something()) {
            case A: return;
            default: // no break, no return
        }
        // no return here either
    }
    // $x is wrongly narrowed to Sub here, but control can reach this
    // with $x still Base
}

Solution

In control_analyzer's switch handling, treat a default case whose control actions contain None (i.e. it falls off its end) the same as an explicit break/continue/LeaveSwitch: the switch is not a control-flow terminator, so the enclosing block does not always exit. The default case is always last, so falling off its end leaves the switch normally.

A reviewer only needs the one added condition in get_control_actions; the chain from there to the observed over-narrowing is in the fold.

Regression test SwitchType/defaultFallthroughDoesNotNarrowGuard reproduces the exact shape (guard-if ending in a breakless-default switch, no trailing return) and asserts the guarded variable stays a union afterward.

Verification

  • New regression test fails without the fix (the NonExistentMethod that proves the variable is still a union is not emitted) and passes with it.
  • Full tests/inference (incl. all 40 SwitchType cases), tests/unused, tests/nopanic, and tests/security suites green. The one pre-existing tests/diff/preserveOverlappingDuplicateDefinition failure reproduces identically on main without this change (unrelated to switch control flow).
Mechanism, and how the fix is contained

Why the over-narrowing happened. For a breakless default that falls off the end, get_control_actions computes case_actions = {None}. The pre-fix code:

  1. The break/continue/LeaveSwitch check at the top of the default-case block does not match {None}, so it doesn't continue 'outer.
  2. case_does_end is false (None is neither End nor Return), and has_ended stays false.
  3. But has_default_terminator = true is then set unconditionally, and the switch returns its control actions as {Return} — dropping None.

So the switch is reported as always returning. Upstream, ifelse_analyzer.rs (~line 263) treats a then/guard block whose statements never yield None as always-exiting and applies the else-branch (negated) narrowing to the continuation — narrowing $x to Sub after the guard, even though control reaches the continuation with $x still the original union.

The fix adds || case_actions.contains(&ControlAction::None) to the existing early-continue 'outer condition, so a fall-off-end default is handled exactly like a break: the switch falls through, None is inserted for the block, and the block is no longer seen as always-exiting.

Containment. The change only affects the default-case branch, and only when that case falls off its end ({None}). Defaults that break/continue/return/exit already hit the existing conditions and are unchanged; this only adds the previously-missing fall-off-end case to the same early-exit path. Verified by the full SwitchType suite (40 tests) plus the inference/unused/nopanic/security suites staying green.

A `default` case that falls off its end without break/return leaves the
switch normally, exactly like an explicit break. control_analyzer was
missing this: such a default yielded case_actions = {None}, which skipped
the break/continue/leave-switch check and unconditionally set
has_default_terminator = true, so the switch's control actions were
reported as always-returning.

Downstream, ifelse_analyzer treats a guard block whose statements never
fall through as always-exiting and applies the else-branch (negated) type
narrowing to the continuation. So a guard like

    if (!$x is Foo) {
        switch (...) { case A: return ...; default: /* no break */ }
        /* no return */
    }
    // $x used here

wrongly narrowed $x to Foo after the block, even though control can reach
that use with $x still the original union.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant