Honor JavaScript vardef assignment option

ober

45a5501e705249abb2203b470a4b30f5be4f7e1c

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 2bd7e9a..3b9d90f 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,10 +1,10 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-29 03:15 MDT
+Date: 2026-05-29 03:50 MDT
 Workspace: `/Users/user/mine/jerboa-semgrep`
 Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep`
 Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep`
-Base HEAD before this checkpoint: `113620a Honor taint metavariable unification`
+Base HEAD before this checkpoint: `5ddcbd3 Clear decorated TypeScript and Unicode frontiers`
 
 The user wants this project carried forward until the pure Jerboa port reaches
 Semgrep parity. Continue from the current frontier below. Do not restart broad
@@ -55,7 +55,7 @@ make test
 Result:
 
 ```text
-211 tests, 211 passed, 0 failed
+212 tests, 212 passed, 0 failed
 ```
 
 Local oracle:
@@ -70,16 +70,16 @@ Result:
 oracle: 42 passed, 0 failed
 ```
 
-Focused decorated/Unicode guardrail:
+Focused vardef guardrail:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(ts_decorated_async_property|ts_decorated_static_property|unicode_string)$' LIST_MISMATCHES=1 MAX_DIFFS=180 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^vardef_assign_(false|false1|true|true1|true2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 3 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 3 compared
+upstream-sweep: 5 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 5 compared
 ```
 
 Full upstream sweep:
@@ -91,7 +91,7 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tes
 Result:
 
 ```text
-upstream-sweep: 234 passed, 5 mismatched, 0 jerboa errors, 2 current errors, 241 compared
+upstream-sweep: 239 passed, 0 mismatched, 0 jerboa errors, 2 current errors, 241 compared
 ```
 
 The two current errors are packaged-Semgrep oracle errors, not Jerboa scanner
@@ -100,57 +100,55 @@ invokes upstream Semgrep for each sorted case.
 
 ## What Changed In This Checkpoint
 
-This checkpoint clears three upstream mismatches:
+This checkpoint clears the last five known Jerboa mismatches in the current
+sorted upstream sweep:
 
 ```text
-ts_decorated_async_property
-ts_decorated_static_property
-unicode_string
+vardef_assign_false
+vardef_assign_false1
+vardef_assign_true
+vardef_assign_true1
+vardef_assign_true2
 ```
 
 The full sweep moved from the previous checkpoint:
 
 ```text
-231 passed, 8 mismatched, 0 jerboa errors, 2 current errors
+234 passed, 5 mismatched, 0 jerboa errors, 2 current errors
 ```
 
 to:
 
 ```text
-234 passed, 5 mismatched, 0 jerboa errors, 2 current errors
+239 passed, 0 mismatched, 0 jerboa errors, 2 current errors
 ```
 
 Implementation changes in `src/semgrep/scan.ss`:
 
-- Added a TypeScript decorated class-member scanner for patterns of the form:
-  `@Decorator(...) async name`, `@Decorator(...) async name(...)`,
-  `@Decorator(...) async name(...){ ... }`, and the matching `static` forms.
-- The scanner parses the decorator token, drops pattern decorator call suffixes
-  such as `@Query(...)`, records the required modifier (`async` or `static`),
-  target member name, and shape (`property`, `method`, or `method-body`).
-- Target matching accepts `static async` for both async and static method
-  patterns where Semgrep does. For async patterns, `static` is optional before
-  `async`; for static patterns, `async` is optional after `static`.
-- Property-shaped patterns such as `async foo` and `static foo` deliberately do
-  not match method declarations such as `async foo(){}` or `static foo(){}`.
-- Method-shaped patterns end at the parameter close paren. Method-body-shaped
-  patterns extend through the matching method body close brace.
+- Added `rule-option-explicit-false?` and a narrow JavaScript
+  `vardef_assign` scanner for explicit `options: { vardef_assign: ... }`.
+- The scanner parses assignment-like patterns such as `x = 1`, multiline
+  `...; x = 1;`, and expression ellipsis `<... x = 1 ...>`.
+- With `vardef_assign: false`, it suppresses initializer-derived matches from
+  declarations such as `var x = 1;` while still allowing real assignment
+  statements such as `x = 1;`.
+- With `vardef_assign: true`, it treats `var`/`let`/`const` initializers as
+  assignment-equivalent matches and returns Semgrep-compatible ranges:
+  plain `x = 1` and `<... x = 1 ...>` report the `x = 1` slice, while the
+  multiline statement-sequence shape reports the whole declaration statement.
 - The scanner is wired into the positive pattern paths used by direct pattern
-  rules, formula entries, and binding-aware positive entries.
-- `offset->line-col` now counts UTF-8 byte width for columns, matching Semgrep
-  JSON positions for non-ASCII source. Source offsets already used Semgrep byte
-  offsets for tree-sitter-backed findings; the mismatch was column width.
+  rules, formula entries, and binding-aware positive entries so it can preempt
+  the general structural matcher where the explicit option matters.
 
 New smoke coverage in `tests/smoke.ss`:
 
 ```text
-scan Python unicode string byte columns
-scan TypeScript decorated async methods
-scan TypeScript decorated static methods
+scan JavaScript vardef_assign option
 ```
 
-The Unicode smoke uses ASCII Scheme `\x1f385;` escapes so the test file itself
-does not need literal non-ASCII characters.
+The previous checkpoint also added `scan Python unicode string byte columns`,
+`scan TypeScript decorated async methods`, and `scan TypeScript decorated
+static methods`.
 
 ## Recently Cleared Cases
 
@@ -168,6 +166,11 @@ taint_unify_mvars
 ts_decorated_async_property
 ts_decorated_static_property
 unicode_string
+vardef_assign_false
+vardef_assign_false1
+vardef_assign_true
+vardef_assign_true1
+vardef_assign_true2
 ```
 
 Useful upstream files for the latest cleared cases:
@@ -179,6 +182,16 @@ Useful upstream files for the latest cleared cases:
 /Users/user/mine/semgrep/tests/rules/ts_decorated_static_property.ts
 /Users/user/mine/semgrep/tests/rules/unicode_string.yaml
 /Users/user/mine/semgrep/tests/rules/unicode_string.py
+/Users/user/mine/semgrep/tests/rules/vardef_assign_false.yaml
+/Users/user/mine/semgrep/tests/rules/vardef_assign_false.js
+/Users/user/mine/semgrep/tests/rules/vardef_assign_false1.yaml
+/Users/user/mine/semgrep/tests/rules/vardef_assign_false1.js
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true.yaml
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true.js
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true1.yaml
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true1.js
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true2.yaml
+/Users/user/mine/semgrep/tests/rules/vardef_assign_true2.js
 ```
 
 Important observations:
@@ -193,18 +206,19 @@ Important observations:
   `start offset 69`, `end offset 75`. Only the end column differed: Semgrep
   reported `7`; Jerboa reported `4` because it counted the emoji as one
   character instead of four UTF-8 bytes.
+- The `vardef_assign_*` cases all scan `var x = 1;`. Explicit
+  `vardef_assign: false` should not treat that initializer as an assignment.
+  Explicit `vardef_assign: true` should, with range selection depending on
+  whether the pattern is a plain assignment, statement sequence, or expression
+  ellipsis.
 
 ## Current Full-Sweep Frontier
 
 The current full sorted upstream sweep compared 241 rule/target pairs and has
-these 5 mismatches:
+no known Jerboa mismatches:
 
 ```text
-vardef_assign_false
-vardef_assign_false1
-vardef_assign_true
-vardef_assign_true1
-vardef_assign_true2
+upstream-sweep: 239 passed, 0 mismatched, 0 jerboa errors, 2 current errors, 241 compared
 ```
 
 Refresh the frontier with:
@@ -213,66 +227,23 @@ Refresh the frontier with:
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh
 ```
 
-## Recommended Next Target: vardef_assign_false
-
-The next sorted frontier is `vardef_assign_false`. Start with the whole family
-because these cases almost certainly share one vardef/assignment semantic gap:
-
-```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^vardef_assign_(false|false1|true|true1|true2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
-```
-
-Current focused result:
-
-```text
-MISMATCH vardef_assign_false
-@@ -0,0 +1 @@
-+(finding "test" ".../vardef_assign_false.js" 2 1 10 2 10 19 "WARNING" "Match Found!" "")
-MISMATCH vardef_assign_false1
-@@ -0,0 +1 @@
-+(finding "test" ".../vardef_assign_false1.js" 1 1 0 3 1 21 "WARNING" "Match Found!" "")
-MISMATCH vardef_assign_true
-@@ -1 +1 @@
--(finding "test" ".../vardef_assign_true.js" 2 5 18 2 10 23 "WARNING" "Match Found!" "")
-+(finding "test" ".../vardef_assign_true.js" 2 1 14 2 10 23 "WARNING" "Match Found!" "")
-MISMATCH vardef_assign_true1
-@@ -1 +1 @@
--(finding "test" ".../vardef_assign_true1.js" 2 1 14 2 10 23 "WARNING" "Match Found!" "")
-+(finding "test" ".../vardef_assign_true1.js" 1 1 0 3 1 25 "WARNING" "Match Found!" "")
-MISMATCH vardef_assign_true2
-@@ -1 +0,0 @@
--(finding "test" ".../vardef_assign_true2.js" 2 5 18 2 10 23 "WARNING" "Match Found!" "")
-upstream-sweep: 0 passed, 5 mismatched, 0 jerboa errors, 0 current errors, 5 compared
-```
+## Recommended Next Target
 
-Then inspect the upstream fixtures:
+There is no known Jerboa mismatch in the current 241-case sorted upstream
+sweep. The next useful work is to identify the two packaged-Semgrep
+`current_error` cases and decide whether they should be excluded from this
+oracle, pinned to a different Semgrep binary, or used to expand the Jerboa
+compatibility corpus once upstream current can produce JSON for them.
 
-```sh
-sed -n '1,160p' /Users/user/mine/semgrep/tests/rules/vardef_assign_false.yaml
-sed -n '1,160p' /Users/user/mine/semgrep/tests/rules/vardef_assign_false1.yaml
-sed -n '1,160p' /Users/user/mine/semgrep/tests/rules/vardef_assign_true.yaml
-sed -n '1,160p' /Users/user/mine/semgrep/tests/rules/vardef_assign_true1.yaml
-sed -n '1,160p' /Users/user/mine/semgrep/tests/rules/vardef_assign_true2.yaml
-```
+If continuing parity beyond this sweep, broaden the oracle rather than chasing
+this now-empty frontier. Candidate next expansions:
 
-Likely fix area:
-
-- The rules all scan `var x = 1;` with `pattern: x = 1`, a statement-sequence
-  pattern ending in `x = 1;`, or expression-ellipsis pattern
-  `<... x = 1 ...>`.
-- `options: { vardef_assign: false }` should suppress matches where `x = 1`
-  comes only from the initializer in `var x = 1;`.
-- `options: { vardef_assign: true }` should allow the initializer to match,
-  but the expected range differs by pattern shape: plain `x = 1` and
-  `<... x = 1 ...>` report the `x = 1` slice starting at column 5, while the
-  multiline `...; x = 1;` pattern reports the whole `var x = 1;` statement.
-- `rule-option-enabled?` and `rule-options` already exist in
-  `src/semgrep/scan.ss`; the missing piece is likely matcher behavior around
-  JavaScript `variable_declaration`/`variable_declarator` nodes, range
-  selection, and expression-ellipsis support for assignment-equivalent
-  vardefs.
-- Add a focused smoke test that covers the smallest upstream shape before
-  changing shared structural matching.
+- Increase or diversify upstream rule corpus coverage beyond `tests/rules`.
+- Add parser-specific cases for TypeScript/TSX beyond the current JavaScript
+  parser fallback.
+- Add CLI/target-selection snapshot parity from the Semgrep checkout.
+- Identify the two `current_error` cases by temporarily instrumenting
+  `tests/oracle/upstream-sweep.sh` to print current Semgrep failures.
 
 ## Useful Commands
 
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 9248797..41305b3 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -60,6 +60,9 @@
   (def (rule-option-enabled? rule key)
        (let ([found (rule-option-entry rule key)])
          (and found (cdr found))))
+  (def (rule-option-explicit-false? rule key)
+       (let ([found (rule-option-entry rule key)])
+         (and found (not (cdr found)))))
   (def (sg-filter pred xs)
        (let loop ([remaining xs] [acc '()])
          (cond
@@ -1278,6 +1281,172 @@
                           (loop
                             next
                             (if finding (cons finding acc) acc))))))))))
+  (def (javascript-vardef-assign-pattern-kind pattern)
+       (let ([trimmed (string-trim pattern)])
+         (cond
+           [(sg-string-prefix? "<..." trimmed) 'expression-ellipsis]
+           [(string-find-substring pattern "\n") 'statement-sequence]
+           [else 'plain])))
+  (def (javascript-vardef-assign-pattern-spec pattern)
+       (let* ([match (re-search
+                       (re "([A-Za-z_$][A-Za-z0-9_$]*)[ \\t]*=[ \\t]*([^;>\\n]+)")
+                       pattern
+                       0)]
+              [name (and match (re-match-group match 1))]
+              [value0 (and match (string-trim (re-match-group match 2)))]
+              [ellipsis (and value0 (string-find-substring value0 "..."))]
+              [value (and value0
+                          (if ellipsis
+                              (string-trim (substring value0 0 ellipsis))
+                              value0))])
+         (and name
+              value
+              (not (string-find-substring name "$"))
+              (not (string-find-substring value "$"))
+              (list
+                (cons 'name name)
+                (cons 'value value)
+                (cons
+                  'kind
+                  (javascript-vardef-assign-pattern-kind pattern))))))
+  (def (identifier-boundary-before? source index)
+       (or (= index 0)
+           (not (identifier-char? (string-ref source (- index 1))))))
+  (def (identifier-boundary-after? source index)
+       (or (>= index (string-length source))
+           (not (identifier-char? (string-ref source index)))))
+  (def (previous-word-before source index)
+       (let ([before (skip-horizontal-backward
+                       source
+                       (- index 1))])
+         (and (>= before 0)
+              (identifier-char? (string-ref source before))
+              (let loop ([start before])
+                (if (and (> start 0)
+                         (identifier-char?
+                           (string-ref source (- start 1))))
+                    (loop (- start 1))
+                    (substring source start (+ before 1)))))))
+  (def (javascript-declaration-keyword? word)
+       (or (string=? word "var")
+           (string=? word "let")
+           (string=? word "const")))
+  (def (javascript-vardef-assignment-preceded-by-declaration?
+         source
+         start)
+       (let ([word (previous-word-before source start)])
+         (and word (javascript-declaration-keyword? word))))
+  (def (javascript-vardef-assignment-finding rule path source
+         start end)
+       (finding-for-range-with-bindings rule path source start end
+         '()))
+  (def (scan-javascript-vardef-actual-assignments
+         rule
+         path
+         source
+         spec)
+       (let* ([name (alist-ref/default spec 'name "")]
+              [value (alist-ref/default spec 'value "")]
+              [rx (re (string-append
+                        (regex-escape-string name)
+                        "[ \\t]*=[ \\t]*"
+                        (regex-escape-string value)))]
+              [len (string-length source)])
+         (let loop ([start 0] [acc '()])
+           (let ([match (re-search rx source start)])
+             (if (not match)
+                 (reverse acc)
+                 (let* ([match-start (re-match-start match)]
+                        [match-end (re-match-end match)]
+                        [finding (and (identifier-boundary-before?
+                                        source
+                                        match-start)
+                                      (identifier-boundary-after?
+                                        source
+                                        (+ match-start
+                                           (string-length name)))
+                                      (identifier-boundary-after?
+                                        source
+                                        match-end)
+                                      (not (javascript-vardef-assignment-preceded-by-declaration?
+                                             source
+                                             match-start))
+                                      (javascript-vardef-assignment-finding rule path source match-start
+                                        match-end))]
+                        [next (max (+ match-start 1) match-end)])
+                   (loop next (if finding (cons finding acc) acc))))))))
+  (def (javascript-vardef-declaration-range source spec match)
+       (let* ([kind (alist-ref/default spec 'kind 'plain)]
+              [name (alist-ref/default spec 'name "")]
+              [decl-start (re-match-start match)]
+              [match-end (re-match-end match)]
+              [name-start (string-find-substring-from
+                            source
+                            name
+                            decl-start)]
+              [start (if (eq? kind 'statement-sequence)
+                         decl-start
+                         name-start)])
+         (and start (cons start match-end))))
+  (def (scan-javascript-vardef-declarations
+         rule
+         path
+         source
+         spec)
+       (let* ([name (alist-ref/default spec 'name "")]
+              [value (alist-ref/default spec 'value "")]
+              [rx (re (string-append
+                        "(var|let|const)[ \\t]+"
+                        (regex-escape-string name)
+                        "[ \\t]*=[ \\t]*"
+                        (regex-escape-string value)))]
+              [len (string-length source)])
+         (let loop ([start 0] [acc '()])
+           (let ([match (re-search rx source start)])
+             (if (not match)
+                 (reverse acc)
+                 (let* ([range (and (identifier-boundary-before?
+                                      source
+                                      (re-match-start match))
+                                    (identifier-boundary-after?
+                                      source
+                                      (re-match-end match))
+                                    (javascript-vardef-declaration-range
+                                      source
+                                      spec
+                                      match))]
+                        [finding (and range
+                                      (javascript-vardef-assignment-finding rule path source (car range)
+                                        (cdr range)))]
+                        [next (max (+ (re-match-start match) 1)
+                                   (re-match-end match))])
+                   (loop next (if finding (cons finding acc) acc))))))))
+  (def (scan-javascript-vardef-assign-pattern
+         rule
+         path
+         source
+         pattern)
+       (let ([option (rule-option-entry rule "vardef_assign")])
+         (and option
+              (let ([spec (javascript-vardef-assign-pattern-spec
+                            pattern)])
+                (and spec
+                     (let ([assignments (scan-javascript-vardef-actual-assignments
+                                          rule
+                                          path
+                                          source
+                                          spec)])
+                       (if (rule-option-explicit-false?
+                             rule
+                             "vardef_assign")
+                           assignments
+                           (append
+                             (scan-javascript-vardef-declarations
+                               rule
+                               path
+                               source
+                               spec)
+                             assignments))))))))
   (def (json-key-value-pattern-spec pattern)
        (let* ([match (re-search
                        (re "\\$([A-Za-z_][A-Za-z0-9_]*)[ \\t]*:[ \\t]*([^,}\\n]+)")
@@ -4883,6 +5052,12 @@
                      path
                      source
                      (cdr entry)))
+              (and (javascript-like-language? language)
+                   (scan-javascript-vardef-assign-pattern
+                     rule
+                     path
+                     source
+                     (cdr entry)))
               (scan-string-key-object-pattern
                 rule
                 path
@@ -5129,6 +5304,12 @@
                      path
                      source
                      (cdr entry)))
+              (and (javascript-like-language? language)
+                   (scan-javascript-vardef-assign-pattern
+                     rule
+                     path
+                     source
+                     (cdr entry)))
               (and (string=? language "json")
                    (scan-json-key-value-pattern
                      rule
@@ -11698,6 +11879,12 @@
                          path
                          source
                          (rule-pattern rule)))
+                  (and (javascript-like-language? language)
+                       (scan-javascript-vardef-assign-pattern
+                         rule
+                         path
+                         source
+                         (rule-pattern rule)))
                   (and (string=? language "json")
                        (scan-json-key-value-pattern
                          rule
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 0bf88a4..9c06653 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -3,7 +3,7 @@
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
   ("src/semgrep/lang.ss" . "7E5441BD00A7F1D4")
   ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA")
-  ("src/semgrep/scan.ss" . "E3D9FE6B8D7E1CE8")
+  ("src/semgrep/scan.ss" . "6F89C2FDF56F5DE7")
   ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
   ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index d6db94e..3ba25a4 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -65,6 +65,10 @@
   (let ([found (rule-option-entry rule key)])
     (and found (cdr found))))
 
+(def (rule-option-explicit-false? rule key)
+  (let ([found (rule-option-entry rule key)])
+    (and found (not (cdr found)))))
+
 (def (sg-filter pred xs)
   (let loop ([remaining xs] [acc '()])
     (cond
@@ -1346,6 +1350,164 @@
                      (loop next
                            (if finding (cons finding acc) acc))))))))))
 
+(def (javascript-vardef-assign-pattern-kind pattern)
+  (let ([trimmed (string-trim pattern)])
+    (cond
+      [(sg-string-prefix? "<..." trimmed) 'expression-ellipsis]
+      [(string-find-substring pattern "\n") 'statement-sequence]
+      [else 'plain])))
+
+(def (javascript-vardef-assign-pattern-spec pattern)
+  (let* ([match (re-search
+                  (re "([A-Za-z_$][A-Za-z0-9_$]*)[ \\t]*=[ \\t]*([^;>\\n]+)")
+                  pattern
+                  0)]
+         [name (and match (re-match-group match 1))]
+         [value0 (and match (string-trim (re-match-group match 2)))]
+         [ellipsis (and value0 (string-find-substring value0 "..."))]
+         [value (and value0
+                     (if ellipsis
+                         (string-trim (substring value0 0 ellipsis))
+                         value0))])
+    (and name
+         value
+         (not (string-find-substring name "$"))
+         (not (string-find-substring value "$"))
+         (list (cons 'name name)
+               (cons 'value value)
+               (cons 'kind
+                     (javascript-vardef-assign-pattern-kind pattern))))))
+
+(def (identifier-boundary-before? source index)
+  (or (= index 0)
+      (not (identifier-char? (string-ref source (- index 1))))))
+
+(def (identifier-boundary-after? source index)
+  (or (>= index (string-length source))
+      (not (identifier-char? (string-ref source index)))))
+
+(def (previous-word-before source index)
+  (let ([before (skip-horizontal-backward source (- index 1))])
+    (and (>= before 0)
+         (identifier-char? (string-ref source before))
+         (let loop ([start before])
+           (if (and (> start 0)
+                    (identifier-char? (string-ref source (- start 1))))
+               (loop (- start 1))
+               (substring source start (+ before 1)))))))
+
+(def (javascript-declaration-keyword? word)
+  (or (string=? word "var")
+      (string=? word "let")
+      (string=? word "const")))
+
+(def (javascript-vardef-assignment-preceded-by-declaration? source start)
+  (let ([word (previous-word-before source start)])
+    (and word (javascript-declaration-keyword? word))))
+
+(def (javascript-vardef-assignment-finding rule path source start end)
+  (finding-for-range-with-bindings rule path source start end '()))
+
+(def (scan-javascript-vardef-actual-assignments rule path source spec)
+  (let* ([name (alist-ref/default spec 'name "")]
+         [value (alist-ref/default spec 'value "")]
+         [rx (re (string-append
+                   (regex-escape-string name)
+                   "[ \\t]*=[ \\t]*"
+                   (regex-escape-string value)))]
+         [len (string-length source)])
+    (let loop ([start 0] [acc '()])
+      (let ([match (re-search rx source start)])
+        (if (not match)
+            (reverse acc)
+            (let* ([match-start (re-match-start match)]
+                   [match-end (re-match-end match)]
+                   [finding
+                    (and (identifier-boundary-before? source match-start)
+                         (identifier-boundary-after?
+                           source
+                           (+ match-start (string-length name)))
+                         (identifier-boundary-after? source match-end)
+                         (not (javascript-vardef-assignment-preceded-by-declaration?
+                                source
+                                match-start))
+                         (javascript-vardef-assignment-finding
+                           rule
+                           path
+                           source
+                           match-start
+                           match-end))]
+                   [next (max (+ match-start 1) match-end)])
+              (loop next (if finding (cons finding acc) acc))))))))
+
+(def (javascript-vardef-declaration-range source spec match)
+  (let* ([kind (alist-ref/default spec 'kind 'plain)]
+         [name (alist-ref/default spec 'name "")]
+         [decl-start (re-match-start match)]
+         [match-end (re-match-end match)]
+         [name-start (string-find-substring-from source name decl-start)]
+         [start (if (eq? kind 'statement-sequence)
+                    decl-start
+                    name-start)])
+    (and start (cons start match-end))))
+
+(def (scan-javascript-vardef-declarations rule path source spec)
+  (let* ([name (alist-ref/default spec 'name "")]
+         [value (alist-ref/default spec 'value "")]
+         [rx (re (string-append
+                   "(var|let|const)[ \\t]+"
+                   (regex-escape-string name)
+                   "[ \\t]*=[ \\t]*"
+                   (regex-escape-string value)))]
+         [len (string-length source)])
+    (let loop ([start 0] [acc '()])
+      (let ([match (re-search rx source start)])
+        (if (not match)
+            (reverse acc)
+            (let* ([range
+                    (and (identifier-boundary-before?
+                           source
+                           (re-match-start match))
+                         (identifier-boundary-after?
+                           source
+                           (re-match-end match))
+                         (javascript-vardef-declaration-range
+                           source
+                           spec
+                           match))]
+                   [finding
+                    (and range
+                         (javascript-vardef-assignment-finding
+                           rule
+                           path
+                           source
+                           (car range)
+                           (cdr range)))]
+                   [next (max (+ (re-match-start match) 1)
+                              (re-match-end match))])
+              (loop next (if finding (cons finding acc) acc))))))))
+
+(def (scan-javascript-vardef-assign-pattern rule path source pattern)
+  (let ([option (rule-option-entry rule "vardef_assign")])
+    (and option
+         (let ([spec (javascript-vardef-assign-pattern-spec pattern)])
+           (and spec
+                (let ([assignments
+                       (scan-javascript-vardef-actual-assignments
+                         rule
+                         path
+                         source
+                         spec)])
+                  (if (rule-option-explicit-false? rule "vardef_assign")
+                      assignments
+                      (append
+                        (scan-javascript-vardef-declarations
+                          rule
+                          path
+                          source
+                          spec)
+                        assignments))))))))
+
 (def (json-key-value-pattern-spec pattern)
   (let* ([match (re-search
                   (re "\\$([A-Za-z_][A-Za-z0-9_]*)[ \\t]*:[ \\t]*([^,}\\n]+)")
@@ -5060,6 +5222,12 @@
                 path
                 source
                 (cdr entry)))
+         (and (javascript-like-language? language)
+              (scan-javascript-vardef-assign-pattern
+                rule
+                path
+                source
+                (cdr entry)))
          (scan-string-key-object-pattern
            rule
            path
@@ -5357,6 +5525,12 @@
                 path
                 source
                 (cdr entry)))
+         (and (javascript-like-language? language)
+              (scan-javascript-vardef-assign-pattern
+                rule
+                path
+                source
+                (cdr entry)))
          (and (string=? language "json")
               (scan-json-key-value-pattern
                 rule
@@ -12346,6 +12520,12 @@
                     path
                     source
                     (rule-pattern rule)))
+             (and (javascript-like-language? language)
+                  (scan-javascript-vardef-assign-pattern
+                    rule
+                    path
+                    source
+                    (rule-pattern rule)))
              (and (string=? language "json")
                   (scan-json-key-value-pattern
                     rule
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 3907d4e..64459dc 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -409,6 +409,35 @@
                               "function f(x) { console.log(x); console.log(y); }\n")])
     (check (length findings) => 2)))
 
+(test-case "scan JavaScript vardef_assign option"
+  (let* ([false-config
+          "rules:\n  - id: demo.vardef.false\n    languages: [javascript]\n    message: vardef false\n    severity: WARNING\n    options:\n      vardef_assign: false\n    pattern: x = 1\n"]
+         [true-config
+          "rules:\n  - id: demo.vardef.true\n    languages: [javascript]\n    message: vardef true\n    severity: WARNING\n    options:\n      vardef_assign: true\n    pattern: x = 1\n"]
+         [sequence-config
+          "rules:\n  - id: demo.vardef.sequence\n    languages: [javascript]\n    message: vardef sequence\n    severity: WARNING\n    options:\n      vardef_assign: true\n    pattern: |\n      ...\n      x = 1;\n"]
+         [ellipsis-config
+          "rules:\n  - id: demo.vardef.ellipsis\n    languages: [javascript]\n    message: vardef ellipsis\n    severity: WARNING\n    options:\n      vardef_assign: true\n    pattern: |\n      <... x = 1 ...>\n"]
+         [false-findings
+          (scan-config-string false-config "javascript" "demo.js" "var x = 1;\n")]
+         [actual-findings
+          (scan-config-string false-config "javascript" "demo.js" "x = 1;\n")]
+         [true-findings
+          (scan-config-string true-config "javascript" "demo.js" "var x = 1;\n")]
+         [sequence-findings
+          (scan-config-string sequence-config "javascript" "demo.js" "var x = 1;\n")]
+         [ellipsis-findings
+          (scan-config-string ellipsis-config "javascript" "demo.js" "var x = 1;\n")])
+    (check (length false-findings) => 0)
+    (check (length actual-findings) => 1)
+    (check (finding-start-col (car actual-findings)) => 1)
+    (check (length true-findings) => 1)
+    (check (finding-start-col (car true-findings)) => 5)
+    (check (length sequence-findings) => 1)
+    (check (finding-start-col (car sequence-findings)) => 1)
+    (check (length ellipsis-findings) => 1)
+    (check (finding-start-col (car ellipsis-findings)) => 5)))
+
 (test-case "scan JavaScript symbolic reassignment chain"
   (let* ([js-config
           "rules:\n  - id: demo.js.symbolic.reassign\n    languages: [javascript]\n    message: reassigned chain\n    severity: WARNING\n    options:\n      symbolic_propagation: true\n    pattern: ff(ff(..., -1), ..., -2)\n"]