Scope JavaScript trailing inside matches

ober

5abcf3d7cd9af8c467d02441629f73995a82b6d7

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index df4fbf2..76007e6 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,24 +1,24 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-30 05:52 MDT
+Date: 2026-05-30 06:21 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 handoff-only checkpoint:
-`355e728 Respect taint metavariable unification`
+Base HEAD before this checkpoint:
+`8dd414c Refresh Opus parity handoff`
 
 The user wants the pure Jerboa Semgrep port carried forward until it reaches
-Semgrep parity. Do not treat this handoff as completion. This file is a
-detailed continuation memo for Opus 4.8 and intentionally preserves the next
-known failing case, the current baseline, the recent design decisions, and the
-exact validation commands.
+Semgrep parity. Do not treat this handoff as completion. This checkpoint closes
+the upstream JavaScript taint `sanitized_by_side_effect` mismatch by bounding
+the JavaScript trailing-ellipsis `pattern-inside` fallback to the lexical block
+that contains the base match.
 
 ## Immediate State
 
-The worktree was clean at `355e728` before this handoff edit. The latest code
-checkpoint already committed the implementation work for
-`taint_unify_mvars`; this handoff commit should modify only this file.
+The worktree was clean at `8dd414c` before this checkpoint. The implementation
+change is in `src/semgrep/scan.ss`; `lib/semgrep/scan.sls` and
+`src/.jerbuild-hashes` were regenerated by `make test`.
 
 Current headline:
 
@@ -27,10 +27,10 @@ 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: 302 tests / 302 passed.
+- Smoke suite: 303 tests / 303 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: 6 passed / 5 mismatched /
+- Upstream `tests/tainting_rules/js` sweep: 7 passed / 4 mismatched /
   0 Jerboa errors / 0 current errors.
 
 Semgrep parity is not reached. The cleanest active frontier is JavaScript taint
@@ -95,9 +95,90 @@ Operational defaults:
 - Added smoke coverage:
   `scan JavaScript taint unifies propagated source metavariables`.
 
+`8dd414c Refresh Opus parity handoff`
+
+- Handoff-only checkpoint after the `taint_unify_mvars` fix.
+- Preserved the investigation notes for `sanitized_by_side_effect`.
+
+This checkpoint:
+
+- Adds `javascript-matching-close-brace` and `javascript-enclosing-block-end`
+  in `src/semgrep/scan.ss`.
+- Makes `inside-after-clause-apply` require after-clause candidates to end
+  inside the base match's enclosing JavaScript block.
+- Adds smoke coverage:
+  `scan JavaScript taint sanitizer after verify stays in function scope`.
+- Closes upstream JS tainting-rule case `sanitized_by_side_effect`.
+- JS tainting-rule sweep is now 7 passed / 4 mismatched.
+
+This checkpoint modifies:
+
+```text
+HANDOFF_OPUS_4_8.md
+src/semgrep/scan.ss
+lib/semgrep/scan.sls
+src/.jerbuild-hashes
+tests/smoke.ss
+```
+
 ## Latest Code Change Details
 
-The latest code checkpoint fixed `metavar_eq_simple`. The upstream shape was:
+### Scoped Trailing-Ellipsis `pattern-inside`
+
+Semgrep treats a JavaScript `pattern-inside` such as:
+
+```yaml
+pattern-inside: |
+  $JWT.verify($TOKEN, ...)
+  ...
+```
+
+as a region after the verifier inside the relevant lexical block. Jerboa's
+fallback for this shape previously checked only that the base match appeared
+before the candidate. That let a verifier in one function act as context for a
+candidate in a later function.
+
+The upstream `sanitized_by_side_effect` rule exposes this:
+
+```javascript
+const jwt = require('jsonwebtoken');
+
+function ok(token, key) {
+  jwt.verify(token, key);
+  jwt.decode(token, true); // sanitized
+}
+
+const ok2 = (token, key) => {
+  jwt.verify(token, key);
+  jwt.decode(token, true); // sanitized
+};
+
+function bad_different_token(token, key) {
+  token2 = getToken();
+  jwt.verify(token2, key);
+  jwt.decode(token, true); // must report
+}
+```
+
+Jerboa was incorrectly allowing the verifier from `ok2` to satisfy the
+sanitizer `pattern-inside` for the later `bad_different_token` sink.
+
+The scanner now:
+
+- Tracks the nearest unmatched `{` before the base finding, ignoring braces in
+  JavaScript strings and line/block comments.
+- Finds the corresponding close brace with `javascript-matching-close-brace`.
+- Falls back to EOF when the base finding is top-level.
+- Requires the candidate's end offset to be within that scope before merging
+  metavariable bindings in `inside-after-clause-apply`.
+
+This keeps top-level trailing-ellipsis context such as
+`$JWT = require('jsonwebtoken'); ...` file-wide, while preventing a verifier in
+one function body from sanitizing a token in a later function.
+
+### Previous Checkpoint: Propagated Taint Unification
+
+The previous code checkpoint fixed `metavar_eq_simple`. The upstream shape was:
 
 ```javascript
 var source1 = get(A)
@@ -148,10 +229,10 @@ Smoke:
 make test
 ```
 
-Result from the last code checkpoint:
+Result:
 
 ```text
-302 tests, 302 passed, 0 failed
+303 tests, 303 passed, 0 failed
 ```
 
 Focused upstream JS taint `metavar_eq_simple`:
@@ -214,7 +295,7 @@ The 3 current-side errors are packaged-Semgrep schema failures for
 `anywhere_global`, `anywhere_include`, and `anywhere_metavar`; they are not
 Jerboa mismatches.
 
-Fresh handoff confirmation for `sanitized_by_side_effect`:
+Focused upstream JS taint `sanitized_by_side_effect`:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
@@ -226,13 +307,10 @@ tests/oracle/upstream-sweep.sh
 Result:
 
 ```text
-upstream-sweep: 0 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
 ```
 
-The missing Jerboa finding is Semgrep's line 31 finding in
-`sanitized_by_side_effect.js`.
-
-Fresh handoff confirmation for full JS tainting rules:
+Full JS tainting-rule subdirectory:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
@@ -243,12 +321,12 @@ LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh
 Result:
 
 ```text
-upstream-sweep: 6 passed, 5 mismatched, 0 jerboa errors, 0 current errors, 11 compared
+upstream-sweep: 7 passed, 4 mismatched, 0 jerboa errors, 0 current errors, 11 compared
 ```
 
 ## Remaining JS Taint Mismatches
 
-The 5 remaining mismatches under `/Users/user/mine/semgrep/tests/tainting_rules/js`
+The 4 remaining mismatches under `/Users/user/mine/semgrep/tests/tainting_rules/js`
 are:
 
 1. `await`
@@ -272,23 +350,16 @@ reports both `A` and `B` messages at both sinks. Jerboa keeps only the locally
 matching binding at each sink. Likely gap: conditional-expression sources need
 multiple alternative taint states/metavariable environments.
 
-4. `sanitized_by_side_effect`
-
-Semgrep reports `jwt.decode(token, true)` at line 31 because only `token2` was
-verified in that function. Jerboa misses that finding by over-sanitizing
-`token`. This is the best next target because the root cause is now fairly
-well isolated.
-
-5. `simpl_nodejs_eval`
+4. `simpl_nodejs_eval`
 
 Jerboa misses the direct concat sink
 `s.run('lol(' + req.query.userInput + ')', cb)` and has a one-column overlong
 range on the template-literal sink. Likely gaps: direct string concatenation
 taint into a focused sink expression, plus template literal range trimming.
 
-## Best Next Target: `sanitized_by_side_effect`
+## Completed Target: `sanitized_by_side_effect`
 
-Start with this command:
+Verification command:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
@@ -297,7 +368,7 @@ CASE_REGEX='^(sanitized_by_side_effect)$' LIST_MISMATCHES=1 MAX_DIFFS=180 \
 tests/oracle/upstream-sweep.sh
 ```
 
-Current diff:
+Old diff before this checkpoint:
 
 ```diff
 - (finding "jwt-decode-without-verify" ".../sanitized_by_side_effect.js" 31 20 728 31 25 733 ...)
@@ -352,13 +423,19 @@ function bad_different_token(token, key) {
 }
 ```
 
-Observed behavior:
+Observed behavior before this checkpoint:
 
 - Semgrep reports lines 6 and 31.
 - Jerboa reports only line 6.
 - Therefore Jerboa is incorrectly treating some prior `jwt.verify(...)` as a
   sanitizer for the later `jwt.decode(token, ...)` at line 31.
 
+Current behavior after this checkpoint:
+
+- Focused `sanitized_by_side_effect` passes.
+- Full JS tainting rules improved from 6 passed / 5 mismatched to
+  7 passed / 4 mismatched.
+
 ### Important Investigation Already Done
 
 Do not start from the assumption that by-side-effect sanitizer identity alone is
@@ -419,7 +496,7 @@ that candidate when the sink token is `token`. The incorrect line 31 sanitizer
 is likely coming from the earlier line 19 verify in `ok2`, because the
 after-clause fallback checks ordering only.
 
-### Probable Patch Point
+### Patch Applied
 
 Relevant code in [src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgrep/scan.ss):
 
@@ -430,7 +507,7 @@ Relevant code in [src/semgrep/scan.ss](/Users/user/mine/jerboa-semgrep/src/semgr
 - `patterns-clause-apply*` uses the trailing-ellipsis fallback around lines
   29256 and 29273.
 
-Current shape of `inside-after-clause-apply`:
+Previous shape of `inside-after-clause-apply`:
 
 ```scheme
 (def (inside-after-clause-apply rule candidate findings source)
@@ -448,13 +525,13 @@ Current shape of `inside-after-clause-apply`:
       ...)))
 ```
 
-That helper needs a lexical/scope bound, not just `before?`.
+That helper needed a lexical/scope bound, not just `before?`.
 
-Suggested implementation direction:
+Implementation applied:
 
-- Add a helper that returns the end of the smallest enclosing JavaScript block
+- Added a helper that returns the end of the smallest enclosing JavaScript block
   for the base finding, or EOF if there is no enclosing block.
-- Use it in `inside-after-clause-apply` so a candidate must be after the base
+- Used it in `inside-after-clause-apply` so a candidate must be after the base
   finding and before that lexical end.
 - For line 19 in `ok2`, the lexical end should be the `};` around line 24, so
   it must not sanitize line 31.
@@ -467,7 +544,7 @@ track `{` positions outside comments and strings, then call
 `find-matching-close-brace` on the top stack entry. Existing code already has
 small brace scanners; do not add a full parser unless necessary.
 
-One possible shape:
+Implemented shape:
 
 ```scheme
 (def (javascript-enclosing-block-end source offset)
@@ -499,10 +576,9 @@ Risk to watch: a naive nearest-brace helper may pick an object literal instead
 of a block. That is probably still safer than file-wide leakage for this
 fallback, but the broad `tests/rules` sweep should be rerun.
 
-### Smoke Test to Add With That Fix
+### Smoke Test Added
 
-Add a focused smoke test near the other JavaScript taint sanitizer tests. A
-minimal shape:
+Added a focused smoke test near the other JavaScript taint sanitizer tests:
 
 ```scheme
 (test-case "scan JavaScript taint sanitizer after verify stays in function scope"
@@ -518,13 +594,9 @@ minimal shape:
     (check (finding-start-line (car findings)) => 8)))
 ```
 
-If that minimal source pattern is too broad in practice, include the
-`require('jsonwebtoken')` inside-clause from the upstream rule and assert only
-the line 8 decode token survives.
-
-### Required Validation After This Patch
+### Validation Run For This Patch
 
-Run these before committing a fix:
+These were run before committing the fix:
 
 ```sh
 make test
@@ -542,7 +614,7 @@ LIST_MISMATCHES=1 MAX_DIFFS=40 tests/oracle/upstream-sweep.sh
 git diff --check
 ```
 
-Expected improvement if the fix is right:
+Actual improvement:
 
 - Focused `sanitized_by_side_effect`: 1 passed / 0 mismatched.
 - Full JS tainting rules: 7 passed / 4 mismatched.
@@ -550,10 +622,9 @@ Expected improvement if the fix is right:
 - Broad `tests/rules`: still 0 mismatches, with the same 3 current-side
   packaged-Semgrep errors unless upstream/current behavior changes.
 
-## Alternative Next Targets
+## Recommended Next Target
 
-If `sanitized_by_side_effect` unexpectedly fans out, the next most tractable
-target is probably `await`:
+The next most tractable target is probably `await`:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep \
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index ea852b3..0ec6578 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -21163,6 +21163,108 @@
                               (string-trim
                                 (substring pattern 0 ellipsis-line)))])
               (and base (not (string=? base "")) (cons 'pattern base)))))
+  (def (javascript-matching-close-brace source open-index)
+       (let ([len (string-length source)])
+         (and (>= open-index 0)
+              (< open-index len)
+              (char=? (string-ref source open-index) #\{)
+              (let loop ([i open-index]
+                         [depth 0]
+                         [state 'normal]
+                         [escaped? #f])
+                (cond
+                  [(>= i len) #f]
+                  [(eq? state 'normal)
+                   (let ([ch (string-ref source i)])
+                     (cond
+                       [(char=? ch #\") (loop (+ i 1) depth 'double #f)]
+                       [(char=? ch #\') (loop (+ i 1) depth 'single #f)]
+                       [(char=? ch #\`) (loop (+ i 1) depth 'backtick #f)]
+                       [(and (< (+ i 1) len)
+                             (char=? ch #\/)
+                             (char=? (string-ref source (+ i 1)) #\/))
+                        (loop (line-end-after source i) depth state #f)]
+                       [(and (< (+ i 1) len)
+                             (char=? ch #\/)
+                             (char=? (string-ref source (+ i 1)) #\*))
+                        (loop (+ i 2) depth 'block-comment #f)]
+                       [(char=? ch #\{)
+                        (loop (+ i 1) (+ depth 1) state #f)]
+                       [(char=? ch #\})
+                        (if (= depth 1)
+                            (+ i 1)
+                            (loop (+ i 1) (max 0 (- depth 1)) state #f))]
+                       [else (loop (+ i 1) depth state #f)]))]
+                  [(eq? state 'block-comment)
+                   (if (and (< (+ i 1) len)
+                            (char=? (string-ref source i) #\*)
+                            (char=? (string-ref source (+ i 1)) #\/))
+                       (loop (+ i 2) depth 'normal #f)
+                       (loop (+ i 1) depth state #f))]
+                  [escaped? (loop (+ i 1) depth state #f)]
+                  [(char=? (string-ref source i) #\\)
+                   (loop (+ i 1) depth state #t)]
+                  [(and (eq? state 'double)
+                        (char=? (string-ref source i) #\"))
+                   (loop (+ i 1) depth 'normal #f)]
+                  [(and (eq? state 'single)
+                        (char=? (string-ref source i) #\'))
+                   (loop (+ i 1) depth 'normal #f)]
+                  [(and (eq? state 'backtick)
+                        (char=? (string-ref source i) #\`))
+                   (loop (+ i 1) depth 'normal #f)]
+                  [else (loop (+ i 1) depth state #f)])))))
+  (def (javascript-enclosing-block-end source offset)
+       (let* ([len (string-length source)]
+              [limit (min offset len)])
+         (let loop ([i 0] [stack '()] [state 'normal] [escaped? #f])
+           (cond
+             [(>= i limit)
+              (let ([open (and (not (null? stack)) (car stack))])
+                (or (and open
+                         (javascript-matching-close-brace source open))
+                    len))]
+             [(eq? state 'normal)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [(char=? ch #\") (loop (+ i 1) stack 'double #f)]
+                  [(char=? ch #\') (loop (+ i 1) stack 'single #f)]
+                  [(char=? ch #\`) (loop (+ i 1) stack 'backtick #f)]
+                  [(and (< (+ i 1) limit)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\/))
+                   (loop (line-end-after source i) stack state #f)]
+                  [(and (< (+ i 1) limit)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\*))
+                   (loop (+ i 2) stack 'block-comment #f)]
+                  [(char=? ch #\{) (loop (+ i 1) (cons i stack) state #f)]
+                  [(char=? ch #\})
+                   (loop
+                     (+ i 1)
+                     (if (null? stack) stack (cdr stack))
+                     state
+                     #f)]
+                  [else (loop (+ i 1) stack state #f)]))]
+             [(eq? state 'block-comment)
+              (if (and (< (+ i 1) limit)
+                       (char=? (string-ref source i) #\*)
+                       (char=? (string-ref source (+ i 1)) #\/))
+                  (loop (+ i 2) stack 'normal #f)
+                  (loop (+ i 1) stack state #f))]
+             [escaped? (loop (+ i 1) stack state #f)]
+             [(char=? (string-ref source i) #\\)
+              (loop (+ i 1) stack state #t)]
+             [(and (eq? state 'double)
+                   (char=? (string-ref source i) #\"))
+              (loop (+ i 1) stack 'normal #f)]
+             [(and (eq? state 'single)
+                   (char=? (string-ref source i) #\'))
+              (loop (+ i 1) stack 'normal #f)]
+             [(and (eq? state 'backtick)
+                   (char=? (string-ref source i) #\`))
+              (loop (+ i 1) stack 'normal #f)]
+             [else (loop (+ i 1) stack state #f)]))))
   (def (inside-after-clause-apply
          rule
          candidate
@@ -21184,7 +21286,14 @@
             (let* ([finding (car xs)]
                    [before? (< (finding-end-offset finding)
                                (finding-start-offset candidate))]
-                   [merged (and before?
+                   [scope-end (and before?
+                                   (javascript-enclosing-block-end
+                                     source
+                                     (finding-start-offset finding)))]
+                   [within? (and scope-end
+                                 (<= (finding-end-offset candidate)
+                                     scope-end))]
+                   [merged (and within?
                                 (merge-binding-list
                                   (finding-metavars candidate)
                                   (finding-metavars finding)))]
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 7324617..d26eec3 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" . "C428896282E9AB09")
-  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
-  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/scan.ss" . "26462685AB67B945")
   ("src/semgrep/rule.ss" . "E12C108153C181FA")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
+  ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
+  ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
   ("src/semgrep/match/structural.ss" . "F7B63A9A6FA028B")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
   ("src/semgrep/cli.ss" . "EBDC4B1DAD3F13CC"))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 4b93411..7287b6e 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -21227,6 +21227,106 @@
               (not (string=? base ""))
               (cons 'pattern base)))))
 
+(def (javascript-matching-close-brace source open-index)
+  (let ([len (string-length source)])
+    (and (>= open-index 0)
+         (< open-index len)
+         (char=? (string-ref source open-index) #\{)
+         (let loop ([i open-index]
+                    [depth 0]
+                    [state 'normal]
+                    [escaped? #f])
+           (cond
+             [(>= i len) #f]
+             [(eq? state 'normal)
+              (let ([ch (string-ref source i)])
+                (cond
+                  [(char=? ch #\") (loop (+ i 1) depth 'double #f)]
+                  [(char=? ch #\') (loop (+ i 1) depth 'single #f)]
+                  [(char=? ch #\`) (loop (+ i 1) depth 'backtick #f)]
+                  [(and (< (+ i 1) len)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\/))
+                   (loop (line-end-after source i) depth state #f)]
+                  [(and (< (+ i 1) len)
+                        (char=? ch #\/)
+                        (char=? (string-ref source (+ i 1)) #\*))
+                   (loop (+ i 2) depth 'block-comment #f)]
+                  [(char=? ch #\{)
+                   (loop (+ i 1) (+ depth 1) state #f)]
+                  [(char=? ch #\})
+                   (if (= depth 1)
+                       (+ i 1)
+                       (loop (+ i 1) (max 0 (- depth 1)) state #f))]
+                  [else (loop (+ i 1) depth state #f)]))]
+             [(eq? state 'block-comment)
+              (if (and (< (+ i 1) len)
+                       (char=? (string-ref source i) #\*)
+                       (char=? (string-ref source (+ i 1)) #\/))
+                  (loop (+ i 2) depth 'normal #f)
+                  (loop (+ i 1) depth state #f))]
+             [escaped? (loop (+ i 1) depth state #f)]
+             [(char=? (string-ref source i) #\\)
+              (loop (+ i 1) depth state #t)]
+             [(and (eq? state 'double) (char=? (string-ref source i) #\"))
+              (loop (+ i 1) depth 'normal #f)]
+             [(and (eq? state 'single) (char=? (string-ref source i) #\'))
+              (loop (+ i 1) depth 'normal #f)]
+             [(and (eq? state 'backtick) (char=? (string-ref source i) #\`))
+              (loop (+ i 1) depth 'normal #f)]
+             [else (loop (+ i 1) depth state #f)])))))
+
+(def (javascript-enclosing-block-end source offset)
+  (let* ([len (string-length source)]
+         [limit (min offset len)])
+    (let loop ([i 0]
+               [stack '()]
+               [state 'normal]
+               [escaped? #f])
+      (cond
+        [(>= i limit)
+         (let ([open (and (not (null? stack)) (car stack))])
+           (or (and open (javascript-matching-close-brace source open))
+               len))]
+        [(eq? state 'normal)
+         (let ([ch (string-ref source i)])
+           (cond
+             [(char=? ch #\") (loop (+ i 1) stack 'double #f)]
+             [(char=? ch #\') (loop (+ i 1) stack 'single #f)]
+             [(char=? ch #\`) (loop (+ i 1) stack 'backtick #f)]
+             [(and (< (+ i 1) limit)
+                   (char=? ch #\/)
+                   (char=? (string-ref source (+ i 1)) #\/))
+              (loop (line-end-after source i) stack state #f)]
+             [(and (< (+ i 1) limit)
+                   (char=? ch #\/)
+                   (char=? (string-ref source (+ i 1)) #\*))
+              (loop (+ i 2) stack 'block-comment #f)]
+             [(char=? ch #\{)
+              (loop (+ i 1) (cons i stack) state #f)]
+             [(char=? ch #\})
+              (loop (+ i 1)
+                    (if (null? stack) stack (cdr stack))
+                    state
+                    #f)]
+             [else (loop (+ i 1) stack state #f)]))]
+        [(eq? state 'block-comment)
+         (if (and (< (+ i 1) limit)
+                  (char=? (string-ref source i) #\*)
+                  (char=? (string-ref source (+ i 1)) #\/))
+             (loop (+ i 2) stack 'normal #f)
+             (loop (+ i 1) stack state #f))]
+        [escaped? (loop (+ i 1) stack state #f)]
+        [(char=? (string-ref source i) #\\)
+         (loop (+ i 1) stack state #t)]
+        [(and (eq? state 'double) (char=? (string-ref source i) #\"))
+         (loop (+ i 1) stack 'normal #f)]
+        [(and (eq? state 'single) (char=? (string-ref source i) #\'))
+         (loop (+ i 1) stack 'normal #f)]
+        [(and (eq? state 'backtick) (char=? (string-ref source i) #\`))
+         (loop (+ i 1) stack 'normal #f)]
+        [else (loop (+ i 1) stack state #f)]))))
+
 (def (inside-after-clause-apply rule candidate findings source)
   (let loop ([xs findings] [best #f] [best-start #f] [best-bindings #f])
     (cond
@@ -21237,8 +21337,15 @@
        (let* ([finding (car xs)]
               [before? (< (finding-end-offset finding)
                           (finding-start-offset candidate))]
-              [merged
+              [scope-end
                (and before?
+                    (javascript-enclosing-block-end
+                      source
+                      (finding-start-offset finding)))]
+              [within? (and scope-end
+                            (<= (finding-end-offset candidate) scope-end))]
+              [merged
+               (and within?
                     (merge-binding-list
                       (finding-metavars candidate)
                       (finding-metavars finding)))]
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 3d9a89e..aa85bbe 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -4139,6 +4139,18 @@
     (check (length findings) => 1)
     (check (finding-start-line (car findings)) => 2)))
 
+(test-case "scan JavaScript taint sanitizer after verify stays in function scope"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.jwt.verify\n    mode: taint\n    languages: [javascript]\n    message: jwt token\n    severity: WARNING\n    pattern-sources:\n      - pattern: $TOKEN\n    pattern-sanitizers:\n      - patterns:\n          - pattern-inside: |\n              $JWT.verify($TOKEN, ...)\n              ...\n          - pattern: $TOKEN\n    pattern-sinks:\n      - patterns:\n          - pattern: $JWT.decode($TOKEN, ...)\n          - pattern: $TOKEN\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "javascript"
+            "demo.js"
+            "function ok(token, key) {\n  jwt.verify(token, key);\n  jwt.decode(token, true);\n}\nfunction bad(token, key) {\n  token2 = getToken();\n  jwt.verify(token2, key);\n  jwt.decode(token, true);\n}\n")])
+    (check (length findings) => 1)
+    (check (finding-start-line (car findings)) => 8)))
+
 (test-case "scan JavaScript taint object destructuring assignment"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.js.destructure\n    mode: taint\n    languages: [javascript]\n    message: destructured taint\n    severity: WARNING\n    pattern-sources:\n      - pattern: user.dataValues\n    pattern-sinks:\n      - pattern: sink(...)\n"]