Respect Ruby case branch taint paths

ober

633e0f1af904badd7f58117fa1119f9c405aff46

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index b7aaa35..05d0cfd 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,27 +1,26 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-30 11:21 MDT
+Date: 2026-05-30 11:50 MDT
 Workspace: `/Users/user/mine/jerboa-semgrep`
 Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep`
 Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep`
 Branch: `main`
 Base HEAD before this checkpoint:
-`422c66f Filter unreachable Python taint tails`
+`482f971 Honor Django safe URL taint guards`
 
 The user wants the pure Jerboa Semgrep port carried forward until it reaches
 Semgrep parity. Do not treat this handoff as completion. The latest checkpoint
-closes the final current upstream Python tainting-rule mismatch,
-`simpl_django_redirect`, by suppressing Django redirect taint sources in
-functions guarded by imported `is_safe_url(...)` checks.
+closes the current upstream Ruby tainting-rule mismatch, `switch`, by making
+sanitizer blocking and assignment kills respect sibling Ruby `case` branches.
 
 ## Immediate State
 
-The worktree was clean at `422c66f` before this checkpoint. The latest
+The worktree was clean at `482f971` before this checkpoint. The latest
 implementation change is in
 [src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgrep/scan.ss);
 [lib/semgrep/scan.sls](/Users/user/mine/jerboa-semgrep/lib/semgrep/scan.sls)
 and [src/.jerbuild-hashes](/Users/user/mine/jerboa-semgrep/src/.jerbuild-hashes)
-were regenerated by `make test`. Focused Django taint source guard smoke
+were regenerated by `make test`. Focused Ruby `case` branch taint smoke
 coverage was added in [tests/smoke.ss](/Users/user/mine/jerboa-semgrep/tests/smoke.ss).
 
 Current headline:
@@ -31,18 +30,20 @@ Current headline:
 - Promoted JavaScript pattern oracle: 91 passed / 0 mismatched.
 - Promoted Python pattern oracle: 116 passed / 0 mismatched.
 - Local oracle: 42 passed / 0 failed.
-- Smoke suite: 311 tests / 311 passed.
+- Smoke suite: 312 tests / 312 passed.
 - Broad same-basename upstream `tests/rules` sweep: 437 passed /
   0 mismatched / 0 Jerboa errors, with 3 packaged-Semgrep current errors.
 - Upstream `tests/tainting_rules/js` sweep: 11 passed / 0 mismatched /
   0 Jerboa errors / 0 current errors.
 - Upstream `tests/tainting_rules/python` sweep: 12 passed / 0 mismatched /
   0 Jerboa errors / 0 current errors.
+- Upstream `tests/tainting_rules/ruby` sweep: 1 passed / 0 mismatched /
+  0 Jerboa errors / 0 current errors.
 
-Semgrep parity is not reached. The JavaScript tainting-rule subdirectory is
-clean; the active frontier is now semantic taint-control-flow parity in other
-upstream `tests/tainting_rules` subdirectories, especially Python, Go, PHP,
-Dart, and Ruby.
+Semgrep parity is not reached. The JavaScript, Python, and Ruby
+`tests/tainting_rules` subdirectories are clean against the packaged oracle.
+The active frontier is now semantic taint-control-flow parity in the remaining
+upstream `tests/tainting_rules` subdirectories, especially Dart, Go, and PHP.
 
 ## Repository Context
 
@@ -181,6 +182,22 @@ This checkpoint:
 
 Latest checkpoint:
 
+- Adds Ruby `case`/`when`/`else` sibling-branch detection for taint
+  reachability.
+- Prevents a sanitizer or clean assignment in one Ruby `when` branch from
+  killing a tainted value that reaches a sink in a sibling `when` or `else`
+  branch.
+- Extends sanitizer blocking, assignment kills, and sanitizer-blocked
+  propagators to check the sanitizer/assignment against the sink or propagator
+  site, not only source against assignment.
+- Closes upstream Ruby tainting-rule case `switch`.
+- Moves the Ruby tainting-rule sweep from `0 passed / 1 mismatched /
+  0 Jerboa errors` to `1 passed / 0 mismatched / 0 Jerboa errors`.
+- Adds smoke coverage:
+  `scan Ruby taint keeps sibling case branches separate`.
+
+Previous checkpoint:
+
 - Adds a Python/Django taint source filter for rules whose source formula
   contains `is_safe_url` and whose sinks are Django redirects.
 - Removes source matches inside functions containing an imported
@@ -227,6 +244,108 @@ tests/smoke.ss
 
 ## Latest Code Change Details
 
+### Ruby Case Branch Taint Reachability
+
+The upstream Ruby taint case `switch` is:
+
+```ruby
+def f()
+  i = source()
+  sink(i)         # report
+  case x
+  when 0
+    i = sanitize(i)
+    sink(i)       # no report
+  when 1
+    sink(i)       # report
+  else
+    sink(i)       # report
+  end
+end
+```
+
+The rule is the normal taint shape:
+
+```yaml
+pattern-sources:
+  - pattern: source(...)
+pattern-sanitizers:
+  - pattern: sanitize(...)
+pattern-sinks:
+  - pattern: sink(...)
+```
+
+Jerboa previously reported only the first sink. The `i = sanitize(i)` finding
+inside `when 0` was path-insensitively treated as both:
+
+- a sanitizer between the original propagated `i` source and later sinks, and
+- a clean assignment kill for the implicit assignment source state carried by
+  `i`.
+
+That was correct for the sink in the same branch, but wrong for sibling
+branches. Semgrep treats Ruby `when`/`else` clauses in the same `case` as
+mutually exclusive, so the sanitizer in `when 0` cannot clean the paths through
+`when 1` or `else`.
+
+The patch adds these line-oriented helpers in
+[src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgrep/scan.ss):
+
+- `ruby-significant-trimmed-line`
+- `ruby-keyword-line?`
+- `ruby-case-line?`
+- `ruby-case-clause-kind`
+- `ruby-parent-case-clause`
+- `ruby-case-chain-start`
+- `ruby-case-branch-info`
+- `ruby-findings-in-mutually-exclusive-case-branches?`
+
+`findings-in-mutually-exclusive-if-branches?` now also delegates to the Ruby
+case helper. The name is now a little narrow, but it preserves the existing call
+sites and keeps this checkpoint small.
+
+Three taint reachability sites now avoid cross-branch suppression:
+
+- `sanitizer-blocks?` checks whether the sanitizer and sink are in sibling
+  mutually exclusive branches.
+- `taint-assignment-kills-source?` checks whether the assignment kill and sink
+  are in sibling mutually exclusive branches. This is necessary when the source
+  is outside the `case` but the clean assignment is inside one branch.
+- `propagator-blocked-by-sanitizer?` checks whether the sanitizer and
+  propagator source site are in sibling mutually exclusive branches.
+
+The Ruby helper is intentionally scoped and text based. It walks upward from a
+finding to the nearest less-indented `when` or `else` clause, then walks further
+up to the matching same-indentation `case`. Two findings are mutually exclusive
+only when they have the same case start, the same clause indentation, and
+different clause starts. This handles the upstream fixture and avoids changing
+the broader taint model into a full control-flow graph.
+
+Smoke coverage added:
+
+```scheme
+(test-case "scan Ruby taint keeps sibling case branches separate"
+  ...)
+```
+
+Focused upstream verification:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
+Full Ruby taint verification:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
+Shared taint regressions checked:
+
+```text
+tests/tainting_rules/python: 12 passed, 0 mismatched, 0 Jerboa errors
+tests/tainting_rules/js:     11 passed, 0 mismatched, 0 Jerboa errors
+```
+
 ### Django Safe URL Taint Source Guard
 
 The upstream Python taint case `simpl_django_redirect` has sources shaped like:
@@ -810,7 +929,22 @@ make test
 Result:
 
 ```text
-311 tests, 311 passed, 0 failed
+312 tests, 312 passed, 0 failed
+```
+
+Focused upstream Ruby taint `switch`:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
+UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \
+CASE_REGEX='^(switch)$' LIST_MISMATCHES=1 MAX_DIFFS=300 \
+tests/oracle/upstream-sweep.sh
+```
+
+Result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
 ```
 
 Focused upstream Python taint `simpl_django_redirect`:
@@ -1036,23 +1170,42 @@ Result:
 upstream-sweep: 12 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 12 compared
 ```
 
+Full Ruby tainting-rule subdirectory:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
+UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \
+LIST_MISMATCHES=1 MAX_DIFFS=300 tests/oracle/upstream-sweep.sh
+```
+
+Result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
 ## Cross-Language Tainting-Rule Frontier
 
-The upstream `tests/tainting_rules/js` and `tests/tainting_rules/python`
-directories are clean as of this checkpoint.
+The upstream `tests/tainting_rules/js`, `tests/tainting_rules/python`, and
+`tests/tainting_rules/ruby` directories are clean as of this checkpoint.
 Additional subdirectory sweeps found:
 
 - `java`: 2 passed / 0 mismatched / 0 Jerboa errors.
 - `scala`: 1 passed / 0 mismatched / 0 Jerboa errors.
 - `ts`: 1 passed / 0 mismatched / 0 Jerboa errors.
-- `dart`: 0 passed / 2 mismatched.
-- `go`: 4 passed / 5 mismatched.
-- `php`: 2 passed / 4 mismatched.
+- `dart`: 0 passed / 2 mismatched: `arrays_if`, `try_return`.
+- `go`: 4 passed / 5 mismatched: `command-injection`, `continue`,
+  `goto_dead_code`, `make`, `zip-traversal`.
+- `php`: 2 passed / 4 mismatched: `break`, `echo`, `lval_var_sink`,
+  `no_duplicate_submatches`.
 - `python`: 12 passed / 0 mismatched / 0 Jerboa errors.
-- `ruby`: 0 passed / 1 mismatched.
+- `ruby`: 1 passed / 0 mismatched / 0 Jerboa errors.
 
-The next compact target is probably `ruby/switch`, because it is the only
-current Ruby tainting-rule mismatch.
+The next compact targets are probably Dart `try_return` or `arrays_if`. Dart
+has only two current mismatches, so it is the smallest dirty subdirectory after
+Ruby. If those turn out to require broad Dart syntax fallback work, move to the
+PHP `break`/`echo` control-flow pair or the Go `continue`/`goto_dead_code`
+cluster.
 
 ## Completed Target: `sanitized_by_side_effect`
 
@@ -1522,20 +1675,30 @@ Actual improvement:
 
 ## Recommended Next Target
 
-The next compact target is outside JavaScript and Python taint. A practical
-choice is Ruby `switch`, the only current Ruby tainting-rule mismatch:
+The next compact target is outside JavaScript, Python, and Ruby taint. A
+practical choice is Dart `try_return`, one of only two current Dart
+tainting-rule mismatches:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
-UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/ruby \
-CASE_REGEX='^(switch)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \
+UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/dart \
+CASE_REGEX='^(try_return)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \
+tests/oracle/upstream-sweep.sh
+```
+
+The paired Dart mismatch is `arrays_if`:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
+UPSTREAM_RULE_DIR=/Users/user/mine/semgrep/tests/tainting_rules/dart \
+CASE_REGEX='^(arrays_if)$' LIST_MISMATCHES=1 MAX_DIFFS=260 \
 tests/oracle/upstream-sweep.sh
 ```
 
-Earlier frontier data showed `ruby/switch` missing two of three expected
-findings. If that target turns out to be a generic text fallback gap, the next
-best alternatives are Dart `arrays_if`/`try_return`, then the Go/PHP
-taint-control-flow clusters.
+If Dart requires more syntax fallback work than is worth doing immediately,
+the next best alternatives are the PHP `break`/`echo` pair or the Go
+`continue`/`goto_dead_code` pair, because those are likely control-flow
+reachability gaps similar to the Python and Ruby fixes already landed.
 
 ## Commit Hygiene
 
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 35a36c4..ff9761a 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -30376,6 +30376,109 @@
                  (alist-ref/default b-branch 'chain-start -2))
               (not (= (alist-ref/default a-branch 'start -1)
                       (alist-ref/default b-branch 'start -1))))))
+  (def (ruby-significant-trimmed-line source line-start)
+       (let* ([line-end (line-end-after source line-start)]
+              [trimmed (string-trim
+                         (substring source line-start line-end))])
+         (and (> (string-length trimmed) 0)
+              (not (sg-string-prefix? "#" trimmed))
+              trimmed)))
+  (def (ruby-keyword-line? trimmed keyword)
+       (let ([keyword-len (string-length keyword)]
+             [len (string-length trimmed)])
+         (and (>= len keyword-len)
+              (string=? (substring trimmed 0 keyword-len) keyword)
+              (or (= len keyword-len)
+                  (char-whitespace? (string-ref trimmed keyword-len))))))
+  (def (ruby-case-line? trimmed)
+       (ruby-keyword-line? trimmed "case"))
+  (def (ruby-case-clause-kind trimmed)
+       (cond
+         [(ruby-keyword-line? trimmed "when") 'when]
+         [(ruby-keyword-line? trimmed "else") 'else]
+         [else #f]))
+  (def (ruby-parent-case-clause source offset)
+       (let* ([line-start (line-start-before source offset)]
+              [target-indent (line-indent-at-offset source offset)])
+         (let loop ([current-start line-start]
+                    [current-indent target-indent])
+           (let ([prev-start (python-previous-line-start
+                               source
+                               current-start)])
+             (if (not prev-start)
+                 #f
+                 (let ([trimmed (ruby-significant-trimmed-line
+                                  source
+                                  prev-start)])
+                   (if (not trimmed)
+                       (loop prev-start current-indent)
+                       (let ([indent (line-indent-at-offset
+                                       source
+                                       prev-start)])
+                         (if (>= indent current-indent)
+                             (loop prev-start current-indent)
+                             (let ([kind (ruby-case-clause-kind trimmed)])
+                               (cond
+                                 [kind
+                                  (list
+                                    (cons 'kind kind)
+                                    (cons 'indent indent)
+                                    (cons 'start prev-start))]
+                                 [(ruby-case-line? trimmed) #f]
+                                 [else (loop prev-start indent)])))))))))))
+  (def (ruby-case-chain-start source clause)
+       (let ([kind (alist-ref/default clause 'kind #f)]
+             [clause-start (alist-ref/default clause 'start #f)]
+             [clause-indent (alist-ref/default clause 'indent 0)])
+         (cond
+           [(or (eq? kind 'when) (eq? kind 'else))
+            (let loop ([current-start clause-start])
+              (let ([prev-start (python-previous-line-start
+                                  source
+                                  current-start)])
+                (if (not prev-start)
+                    #f
+                    (let ([trimmed (ruby-significant-trimmed-line
+                                     source
+                                     prev-start)])
+                      (if (not trimmed)
+                          (loop prev-start)
+                          (let ([indent (line-indent-at-offset
+                                          source
+                                          prev-start)])
+                            (cond
+                              [(< indent clause-indent) #f]
+                              [(> indent clause-indent) (loop prev-start)]
+                              [(ruby-case-line? trimmed) prev-start]
+                              [(ruby-case-clause-kind trimmed)
+                               (loop prev-start)]
+                              [else (loop prev-start)])))))))]
+           [else #f])))
+  (def (ruby-case-branch-info source finding)
+       (let ([clause (ruby-parent-case-clause
+                       source
+                       (finding-start-offset finding))])
+         (and clause
+              (let ([chain-start (ruby-case-chain-start source clause)])
+                (and chain-start
+                     (list
+                       (cons 'start (alist-ref/default clause 'start #f))
+                       (cons 'indent (alist-ref/default clause 'indent 0))
+                       (cons 'chain-start chain-start)))))))
+  (def (ruby-findings-in-mutually-exclusive-case-branches?
+         source
+         a
+         b)
+       (let ([a-branch (ruby-case-branch-info source a)]
+             [b-branch (ruby-case-branch-info source b)])
+         (and a-branch
+              b-branch
+              (= (alist-ref/default a-branch 'indent -1)
+                 (alist-ref/default b-branch 'indent -2))
+              (= (alist-ref/default a-branch 'chain-start -1)
+                 (alist-ref/default b-branch 'chain-start -2))
+              (not (= (alist-ref/default a-branch 'start -1)
+                      (alist-ref/default b-branch 'start -1))))))
   (def (findings-in-mutually-exclusive-if-branches?
          source
          a
@@ -30387,6 +30490,10 @@
            (javascript-findings-in-mutually-exclusive-if-branches?
              source
              a
+             b)
+           (ruby-findings-in-mutually-exclusive-case-branches?
+             source
+             a
              b)))
   (def (python-pass-line? trimmed)
        (or (string=? trimmed "pass")
@@ -31601,6 +31708,10 @@
          (and sanitizer
               (let ([source (taint-state-finding source-state)])
                 (and source
+                     (not (findings-in-mutually-exclusive-if-branches?
+                            source-text
+                            sanitizer
+                            sink))
                      (or (finding-between? source sanitizer sink)
                          (and (taint-state-token? source-state)
                               (finding-range-contains? sanitizer source)
@@ -31839,6 +31950,10 @@
                      source-text
                      source
                      assignment-finding))
+              (not (findings-in-mutually-exclusive-if-branches?
+                     source-text
+                     assignment-finding
+                     sink))
               (> (finding-start-offset assignment-finding)
                  (finding-start-offset source))
               (< (finding-start-offset assignment-finding)
@@ -32187,6 +32302,10 @@
                                source
                                sanitizer
                                from-finding)
+                             (not (findings-in-mutually-exclusive-if-branches?
+                                    source
+                                    sanitizer
+                                    from-finding))
                              (finding-between?
                                source-finding
                                sanitizer
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 08e3abe..5487d1d 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -3,11 +3,11 @@
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
   ("src/semgrep/lang.ss" . "6982E07679D20836")
   ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA")
-  ("src/semgrep/scan.ss" . "3D90FF42092F8FA1")
-  ("src/semgrep/rule.ss" . "E12C108153C181FA")
+  ("src/semgrep/scan.ss" . "523EDB0189EB42F5")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/rule.ss" . "E12C108153C181FA")
   ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
+  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
   ("src/semgrep/match/structural.ss" . "6FE77014EE9FDCE4")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
   ("src/semgrep/cli.ss" . "EBDC4B1DAD3F13CC"))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 979d2be..b1d9d76 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -30381,9 +30381,104 @@
          (not (= (alist-ref/default a-branch 'start -1)
                  (alist-ref/default b-branch 'start -1))))))
 
+(def (ruby-significant-trimmed-line source line-start)
+  (let* ([line-end (line-end-after source line-start)]
+         [trimmed (string-trim (substring source line-start line-end))])
+    (and (> (string-length trimmed) 0)
+         (not (sg-string-prefix? "#" trimmed))
+         trimmed)))
+
+(def (ruby-keyword-line? trimmed keyword)
+  (let ([keyword-len (string-length keyword)]
+        [len (string-length trimmed)])
+    (and (>= len keyword-len)
+         (string=? (substring trimmed 0 keyword-len) keyword)
+         (or (= len keyword-len)
+             (char-whitespace? (string-ref trimmed keyword-len))))))
+
+(def (ruby-case-line? trimmed)
+  (ruby-keyword-line? trimmed "case"))
+
+(def (ruby-case-clause-kind trimmed)
+  (cond
+    [(ruby-keyword-line? trimmed "when") 'when]
+    [(ruby-keyword-line? trimmed "else") 'else]
+    [else #f]))
+
+(def (ruby-parent-case-clause source offset)
+  (let* ([line-start (line-start-before source offset)]
+         [target-indent (line-indent-at-offset source offset)])
+    (let loop ([current-start line-start] [current-indent target-indent])
+      (let ([prev-start (python-previous-line-start source current-start)])
+        (if (not prev-start)
+            #f
+            (let ([trimmed (ruby-significant-trimmed-line source prev-start)])
+              (if (not trimmed)
+                  (loop prev-start current-indent)
+                  (let ([indent (line-indent-at-offset source prev-start)])
+                    (if (>= indent current-indent)
+                        (loop prev-start current-indent)
+                        (let ([kind (ruby-case-clause-kind trimmed)])
+                          (cond
+                            [kind
+                             (list (cons 'kind kind)
+                                   (cons 'indent indent)
+                                   (cons 'start prev-start))]
+                            [(ruby-case-line? trimmed) #f]
+                            [else (loop prev-start indent)])))))))))))
+
+(def (ruby-case-chain-start source clause)
+  (let ([kind (alist-ref/default clause 'kind #f)]
+        [clause-start (alist-ref/default clause 'start #f)]
+        [clause-indent (alist-ref/default clause 'indent 0)])
+    (cond
+      [(or (eq? kind 'when) (eq? kind 'else))
+       (let loop ([current-start clause-start])
+         (let ([prev-start (python-previous-line-start source current-start)])
+           (if (not prev-start)
+               #f
+               (let ([trimmed
+                      (ruby-significant-trimmed-line source prev-start)])
+                 (if (not trimmed)
+                     (loop prev-start)
+                     (let ([indent (line-indent-at-offset
+                                     source
+                                     prev-start)])
+                       (cond
+                         [(< indent clause-indent) #f]
+                         [(> indent clause-indent) (loop prev-start)]
+                         [(ruby-case-line? trimmed) prev-start]
+                         [(ruby-case-clause-kind trimmed) (loop prev-start)]
+                         [else (loop prev-start)])))))))]
+      [else #f])))
+
+(def (ruby-case-branch-info source finding)
+  (let ([clause (ruby-parent-case-clause
+                  source
+                  (finding-start-offset finding))])
+    (and clause
+         (let ([chain-start (ruby-case-chain-start source clause)])
+           (and chain-start
+                (list (cons 'start (alist-ref/default clause 'start #f))
+                      (cons 'indent (alist-ref/default clause 'indent 0))
+                      (cons 'chain-start chain-start)))))))
+
+(def (ruby-findings-in-mutually-exclusive-case-branches? source a b)
+  (let ([a-branch (ruby-case-branch-info source a)]
+        [b-branch (ruby-case-branch-info source b)])
+    (and a-branch
+         b-branch
+         (= (alist-ref/default a-branch 'indent -1)
+            (alist-ref/default b-branch 'indent -2))
+         (= (alist-ref/default a-branch 'chain-start -1)
+            (alist-ref/default b-branch 'chain-start -2))
+         (not (= (alist-ref/default a-branch 'start -1)
+                 (alist-ref/default b-branch 'start -1))))))
+
 (def (findings-in-mutually-exclusive-if-branches? source a b)
   (or (python-findings-in-mutually-exclusive-if-branches? source a b)
-      (javascript-findings-in-mutually-exclusive-if-branches? source a b)))
+      (javascript-findings-in-mutually-exclusive-if-branches? source a b)
+      (ruby-findings-in-mutually-exclusive-case-branches? source a b)))
 
 (def (python-pass-line? trimmed)
   (or (string=? trimmed "pass")
@@ -31551,6 +31646,10 @@
     (and sanitizer
          (let ([source (taint-state-finding source-state)])
            (and source
+                (not (findings-in-mutually-exclusive-if-branches?
+                       source-text
+                       sanitizer
+                       sink))
                 (or (finding-between? source sanitizer sink)
                     (and (taint-state-token? source-state)
                          (finding-range-contains? sanitizer source)
@@ -31771,6 +31870,10 @@
                 source-text
                 source
                 assignment-finding))
+         (not (findings-in-mutually-exclusive-if-branches?
+                source-text
+                assignment-finding
+                sink))
          (> (finding-start-offset assignment-finding)
             (finding-start-offset source))
          (< (finding-start-offset assignment-finding)
@@ -32141,6 +32244,10 @@
                               source
                               sanitizer
                               from-finding)
+                            (not (findings-in-mutually-exclusive-if-branches?
+                                   source
+                                   sanitizer
+                                   from-finding))
                             (finding-between?
                               source-finding
                               sanitizer
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 4543e63..965a1a8 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -2650,6 +2650,17 @@
           (scan-config-string ruby-config "ruby" "demo.rb" source)])
     (check (length findings) => 0)))
 
+(test-case "scan Ruby taint keeps sibling case branches separate"
+  (let* ([ruby-config
+          "rules:\n- id: test-switch\n  languages: [ruby]\n  message: Match Found!\n  mode: taint\n  pattern-sinks:\n    - pattern: sink(...)\n  pattern-sources:\n    - pattern: source(...)\n  pattern-sanitizers:\n    - pattern: sanitize(...)\n  severity: WARNING\n"]
+         [findings
+          (scan-config-string
+            ruby-config
+            "ruby"
+            "demo.rb"
+            "def f()\n  i = source()\n  #ruleid: test-switch\n  sink(i)\n  case x\n  when 0\n    i = sanitize(i)\n    #OK:\n    sink(i)\n  when 1\n    unrelated_call()\n    #ruleid: test-switch\n    sink(i)\n  else\n    unrelated_call()\n    #ruleid: test-switch\n    sink(i)\n  end\nend\n")])
+    (check (map finding-start-line findings) => '(4 13 17))))
+
 (test-case "scan Rust response body and macro pattern fallbacks"
   (let* ([response-config
           "rules:\n- id: no-direct-response-write\n  patterns:\n  - pattern: $BUILDER.body(...)\n  - pattern-not: $BUILDER.body(\"...\".to_string())\n  - metavariable-type:\n      metavariable: $BUILDER\n      type: HttpResponseBuilder\n  message: direct response\n  severity: WARNING\n  languages: [rust]\n"]