Advance field-sensitive taint parity

ober

24796db0618ea5e3f038a76dbf605399a66b4c74

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index a8d425c..728e775 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -31,6 +31,7 @@ taint/dataflow, path and target semantics, autofix, and output schemas.
 Recent checkpoints before this handoff:
 
 ```text
+971b714 Advance taint exception and field parity
 29dd690 Clear taint exact source frontier
 7beb039 Advance taint control parity
 e429d34 Advance taint safe function parity
@@ -67,7 +68,7 @@ make test
 Result:
 
 ```text
-182 tests, 182 passed, 0 failed
+184 tests, 184 passed, 0 failed
 ```
 
 Local oracle:
@@ -82,19 +83,32 @@ Result:
 oracle: 42 passed, 0 failed
 ```
 
-Focused upstream guardrail containing the cases fixed in this checkpoint:
+Focused upstream guardrail containing the field-sensitive cases fixed in this
+checkpoint:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_exception|taint_exact_sources|taint_field_sensitive1|taint_clean_in_try_no_finally|taint_best_fit_sink|taint_assume_safe_booleans|taint_assume_safe_numbers|taint_assume_safe_indexes)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_field_sensitive2|taint_field_sensitive4|taint_field_sensitive6|taint_field_sensitive8)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh
 ```
 
 Result:
 
 ```text
-upstream-sweep: 8 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 8 compared
+upstream-sweep: 4 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 4 compared
 ```
 
-Intended first-220 upstream sweep:
+Nearby regression guardrail:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_field_sensitive2|taint_field_sensitive1|taint_exact_sources)$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh
+```
+
+Result:
+
+```text
+upstream-sweep: 3 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 3 compared
+```
+
+Current first-220 upstream sweep:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep MAX_CASES=220 LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh
@@ -103,11 +117,12 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep MAX_CASES=220 LIST_MISMATCHES=1 M
 Result:
 
 ```text
-upstream-sweep: 189 passed, 29 mismatched, 0 jerboa errors, 2 current errors, 220 compared
+upstream-sweep: 193 passed, 25 mismatched, 0 jerboa errors, 2 current errors, 220 compared
 ```
 
 The full 241-case sweep was not rerun after this checkpoint. The last full
-run, before the `taint_exception` and `taint_field_sensitive1` fixes, was:
+run, before the `taint_exception`, `taint_field_sensitive1`, and current
+field-clean fixes, was:
 
 ```sh
 SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_LIMIT=220 LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh
@@ -124,32 +139,40 @@ errors.
 
 ## What Changed In This Checkpoint
 
-This checkpoint clears `taint_exception` and `taint_field_sensitive1`, the next
-two mismatches after `29dd690`.
+This checkpoint clears `taint_field_sensitive2`, `taint_field_sensitive4`,
+`taint_field_sensitive6`, and `taint_field_sensitive8`, building on `971b714`
+which cleared
+`taint_exception` and `taint_field_sensitive1`.
 
 Implementation changes in `src/semgrep/scan.ss`:
 
-- Python taint facts are now filtered out of trivially impossible
-  `try`/`except`/`else` branches. The filter is deliberately narrow:
-  `try: pass` makes a matching `except` branch impossible, `try: raise ...` or
-  `try: return ...` makes the matching `else` branch impossible, and unknown
-  calls keep both branches possible.
-- The reachability filter is applied only to taint source, sanitizer, sink, and
-  propagator findings. General structural matching is untouched.
-- Field/member taint can now reach an opaque base sink call. For example, if
-  `x.a` is tainted, `sink(x)` is treated as tainted because the callee could
-  inspect `x.a`, while `sink(x.b)` is not treated as tainted by `x.a`.
+- Clean assignment kills are now field-aware instead of token-wide.
+- New access-path helpers parse simple member/index paths such as `x`,
+  `x.a`, `x.c[i].d`, and `params["sql"]`.
+- Dynamic bracket indexes normalize to `*`, so `x.c[i].d = safe` can kill
+  `x.c[j].d` and descendants without killing `x.c[j]`.
+- Quoted bracket indexes keep their literal segment, so the earlier
+  `params["sql"]` exact-source behavior remains preserved.
+- Base clean assignments still kill the base and descendants, while member
+  clean assignments kill only their compatible field subtree.
+- Opaque whole-object sinks remain compatible with reachable member taint from
+  `971b714`, so `x.a.i = source()` can still make `sink(x.a)` and `sink(x)`
+  findings.
 
 New smoke coverage in `tests/smoke.ss`:
 
 ```text
 scan Python taint filters impossible exception branches
 scan taint field source reaches opaque base sink
+scan taint whole object source honors field cleans
+scan taint ancestor field clean clears descendant sources
 ```
 
-The exception test covers `try: pass`, `try: raise`, and unknown-call try
-bodies. The field-source test covers `x.a` taint reaching `sink(x)` without
-also tainting unrelated `sink(x.b)`.
+The new field-clean test mirrors the upstream `taint_field_sensitive2` shape:
+`x = source` taints `sink(x)`, `sink(x.b)`, `sink(x.b.c)`, and `sink(x.c[j])`,
+while clean assignments to `x.a` and `x.c[i].d` suppress only those field
+subtrees. The ancestor-clean test mirrors `taint_field_sensitive4`:
+`x.a = safe` clears earlier descendant sources under `x.a`.
 
 ## Resolved Recent Cases
 
@@ -233,17 +256,94 @@ What changed:
 - The compatibility is prefix-bounded, so `x.a` does not taint unrelated
   `sink(x.b)`.
 
+`taint_field_sensitive2` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive2$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh
+```
+
+It is also included in the verified focused field-sensitive guardrail above.
+
+What changed:
+
+- Jerboa previously reported no findings for
+  `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive2.js`.
+- The broad source `x = source` was killed completely by later clean member
+  assignments `x.a = safe` and `x.c[i].d = safe`.
+- The access-path kill logic now keeps the base `x` source available for
+  `sink(x)`, `sink(x.b)`, `sink(x.b.c)`, and `sink(x.c[j])`.
+- The same kill logic suppresses only the cleaned subtrees: `sink(x.a)`,
+  `sink(x.a.b)`, `sink(x.c[j].d)`, and `sink(x.c[j].d.e)`.
+
+Expected matched lines for this case are now exactly:
+
+```text
+8, 10, 12, 14
+```
+
+`taint_field_sensitive4` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive4$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh
+```
+
+What changed:
+
+- Target file:
+  `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive4.js`.
+- Shape:
+  `x.a.b = source; x.a.c = source; ...; x.a = safe; sink(x.a.b);
+  sink(x.a.c); sink(x.a.b.x)`.
+- Jerboa previously kept reporting descendants after the ancestor clean
+  assignment at `x.a = safe`.
+- The kill predicate now also handles the inverse relation from
+  `taint_field_sensitive2`: when the clean assignment path is an ancestor of
+  the source path, the source is dead for later sinks.
+- This fixed false positives at lines 17, 19, and 21 while preserving the
+  expected pre-clean findings at lines 8, 10, and 12.
+
+`taint_field_sensitive6` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive6$' LIST_MISMATCHES=1 MAX_DIFFS=120 tests/oracle/upstream-sweep.sh
+```
+
+What changed:
+
+- Target file:
+  `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive6.py`.
+- Shape:
+  `x = source(); x.a = safe; exotic(x.a); sink(x.a)`.
+- The sink spec includes a focused `exotic($X.$A)` sink whose finding range is
+  `$X`. Semgrep treats `exotic(x.a)` as a finding because the actual focused
+  sink is `x`, and `x` remains tainted even though `x.a` was cleaned.
+- The new field-aware kill keeps base `x` tainted while clearing only `x.a`,
+  so `exotic(x.a)` reports and `sink(x.a)` stays clean.
+
+`taint_field_sensitive8` is now cleared:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive8$' LIST_MISMATCHES=1 MAX_DIFFS=120 tests/oracle/upstream-sweep.sh
+```
+
+What changed:
+
+- Target file:
+  `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive8.py`.
+- Shape:
+  `x = source(); x.a = sanitize(); sink(x); x.a.i = source(); sink(x.a.i);
+  sink(x.a); sink(x)`.
+- The sanitizer assignment should clear only `x.a`, not the base `x`.
+- The later member source `x.a.i = source()` should re-taint `x.a.i`, make the
+  ancestor `x.a` reachable again, and keep `sink(x)` reportable.
+
 ## Current First-220 Frontier
 
-The current first-220 sweep has these 29 mismatches:
+The current first-220 sweep has these 25 mismatches:
 
 ```text
-taint_field_sensitive2
 taint_field_sensitive3
-taint_field_sensitive4
-taint_field_sensitive6
 taint_field_sensitive7
-taint_field_sensitive8
 taint_final_globals1
 taint_labels2
 taint_labels3
@@ -285,12 +385,12 @@ vardef_assign_true1
 vardef_assign_true2
 ```
 
-## Immediate Next Case: taint_field_sensitive2
+## Immediate Next Case: taint_field_sensitive3
 
 Focused command:
 
 ```sh
-SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive2$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive3$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh
 ```
 
 Current result:
@@ -319,51 +419,62 @@ rules:
 Target:
 
 ```text
-/Users/user/mine/semgrep/tests/rules/taint_field_sensitive2.js
+/Users/user/mine/semgrep/tests/rules/taint_field_sensitive3.js
 ```
 
 Relevant target:
 
 ```text
-3     x = source
-4     x.a = safe
-5     x.c[i].d = safe
-8     sink(x)
-10    sink(x.b)
-12    sink(x.b.c)
-14    sink(x.c[j])
-17    sink(x.a)
+3     x.a.b.c = source
+4     x.d.e[i].f.g[j].h = source
+7     sink(x.a.b.c)
+9     sink(x.a.b.c.d)
+11    sink(x.d.e[k].f.g[l].h)
+13    sink(x.d.e[k].f.g[l].h.i)
 19    sink(x.a.b)
-22    sink(x.c[j].d)
-24    sink(x.c[j].d.e)
+21    sink(x.a)
+23    sink(x.d.e[i].f.g[j])
+25    sink(x.d.e)
+27    sink(x.d)
+29    sink(x)
+33    sink(x.a.b.d)
+35    sink(x.a.c)
 ```
 
 Packaged Semgrep expects findings on:
 
 ```text
-8, 10, 12, 14
+7, 9, 11, 13, 19, 21, 23, 25, 27, 29
 ```
 
-Jerboa currently reports no findings:
+Jerboa currently misses only the dynamic-index exact/descendant member sinks:
 
 ```diff
-@@ -1,4 +0,0 @@
--(finding "test" ".../taint_field_sensitive2.js" 10 5 ... "WARNING" "test" "")
--(finding "test" ".../taint_field_sensitive2.js" 12 5 ... "WARNING" "test" "")
--(finding "test" ".../taint_field_sensitive2.js" 14 5 ... "WARNING" "test" "")
--(finding "test" ".../taint_field_sensitive2.js" 8 5 ... "WARNING" "test" "")
+@@ -1,5 +1,3 @@
+-(finding "test" ".../taint_field_sensitive3.js" 11 5 ... "WARNING" "test" "")
+-(finding "test" ".../taint_field_sensitive3.js" 13 5 ... "WARNING" "test" "")
+ (finding "test" ".../taint_field_sensitive3.js" 19 5 ... "WARNING" "test" "")
+ (finding "test" ".../taint_field_sensitive3.js" 21 5 ... "WARNING" "test" "")
+ (finding "test" ".../taint_field_sensitive3.js" 23 5 ... "WARNING" "test" "")
 ```
 
 Interpretation for the next fix:
 
-- This is the inverse of `taint_field_sensitive1`: a whole-object source
-  (`x = source`) should taint member sinks such as `x.b` and `x.c[j]`.
-- Clean member assignments should suppress only their matching field subtree:
-  `x.a = safe` suppresses `x.a` and `x.a.b`; `x.c[i].d = safe` suppresses
-  `x.c[j].d` and `x.c[j].d.e` but still allows `x.c[j]`.
-- Likely implementation area is field-prefix compatibility plus field-specific
-  assignment kills. Be careful not to undo the `taint_field_sensitive1`
-  behavior where member taint reaches an opaque whole-object sink.
+- The `x.a.b.c` source path works in both exact and descendant directions:
+  `sink(x.a.b.c)`, `sink(x.a.b.c.d)`, `sink(x.a.b)`, `sink(x.a)`, and
+  `sink(x)` are already compatible.
+- The dynamic-index path `x.d.e[i].f.g[j].h` works for opaque ancestor sinks:
+  `sink(x.d.e[i].f.g[j])`, `sink(x.d.e)`, `sink(x.d)`, and `sink(x)` are
+  already compatible.
+- The missing edge is dynamic-index source-to-sink equality/descendant
+  compatibility for `x.d.e[i].f.g[j].h` to `x.d.e[k].f.g[l].h` and
+  `x.d.e[k].f.g[l].h.i`.
+- Start in the access-path compatibility helpers in `src/semgrep/scan.ss`.
+  The assignment-kill parser already normalizes dynamic bracket segments to
+  `*`; source/sink compatibility probably needs the same path parsing instead
+  of relying only on text token/prefix checks.
+- Be careful not to make unrelated sibling paths report. `sink(x.a.b.d)` and
+  `sink(x.a.c)` must remain clean.
 
 ## Resolved Earlier Frontier
 
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 58efa0c..c0c3fcc 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -8709,23 +8709,163 @@
                            sanitizer-state
                            sink
                            source-text)))))))
-  (def (assignment-target-matches-source?
+  (def (access-path-identifier-end text start)
+       (let ([len (string-length text)])
+         (let loop ([i start])
+           (if (and (< i len) (identifier-char? (string-ref text i)))
+               (loop (+ i 1))
+               i))))
+  (def (access-path-bracket-end text start)
+       (let ([len (string-length text)])
+         (let loop ([i start]
+                    [state 'normal]
+                    [quote-ch #f]
+                    [escaped? #f])
+           (cond
+             [(>= i len) #f]
+             [(eq? state 'string)
+              (let ([ch (string-ref text i)])
+                (cond
+                  [escaped? (loop (+ i 1) state quote-ch #f)]
+                  [(char=? ch #\\) (loop (+ i 1) state quote-ch #t)]
+                  [(char=? ch quote-ch) (loop (+ i 1) 'normal #f #f)]
+                  [else (loop (+ i 1) state quote-ch #f)]))]
+             [else
+              (let ([ch (string-ref text i)])
+                (cond
+                  [(or (char=? ch #\") (char=? ch #\'))
+                   (loop (+ i 1) 'string ch #f)]
+                  [(char=? ch #\]) i]
+                  [else (loop (+ i 1) state quote-ch escaped?)]))]))))
+  (def (access-path-bracket-segment text start end)
+       (let ([content (string-trim (substring text start end))])
+         (if (quoted-string? content) (unquote-string content) "*")))
+  (def (access-path-from-text text)
+       (let* ([trimmed (string-trim text)]
+              [len (string-length trimmed)])
+         (and (> len 0)
+              (identifier-char? (string-ref trimmed 0))
+              (let ([base-end (access-path-identifier-end trimmed 0)])
+                (let loop ([i base-end]
+                           [segments (list
+                                       (substring trimmed 0 base-end))])
+                  (let ([j (skip-horizontal-forward trimmed i)])
+                    (cond
+                      [(>= j len) (reverse segments)]
+                      [(char=? (string-ref trimmed j) #\.)
+                       (let* ([name-start (+ j 1)]
+                              [name-end (access-path-identifier-end
+                                          trimmed
+                                          name-start)])
+                         (and (> name-end name-start)
+                              (loop
+                                name-end
+                                (cons
+                                  (substring trimmed name-start name-end)
+                                  segments))))]
+                      [(char=? (string-ref trimmed j) #\[)
+                       (let ([end (access-path-bracket-end
+                                    trimmed
+                                    (+ j 1))])
+                         (and end
+                              (loop
+                                (+ end 1)
+                                (cons
+                                  (access-path-bracket-segment
+                                    trimmed
+                                    (+ j 1)
+                                    end)
+                                  segments))))]
+                      [else #f])))))))
+  (def (access-path-segment-compatible? a b)
+       (or (string=? a b) (string=? a "*") (string=? b "*")))
+  (def (access-path-prefix? prefix path)
+       (let loop ([xs prefix] [ys path])
+         (cond
+           [(null? xs) #t]
+           [(null? ys) #f]
+           [(access-path-segment-compatible? (car xs) (car ys))
+            (loop (cdr xs) (cdr ys))]
+           [else #f])))
+  (def (access-path-equal? a b)
+       (and (access-path-prefix? a b) (access-path-prefix? b a)))
+  (def (assignment-lhs-text assignment-finding source-text)
+       (let* ([assignment-text (finding-text
+                                 assignment-finding
+                                 source-text)]
+              [eq-index (string-find-substring assignment-text "=")])
+         (and eq-index (substring assignment-text 0 eq-index))))
+  (def (first-bracket-segment-quoted? lhs source-path)
+       (and source-path
+            (not (null? source-path))
+            (null? (cdr source-path))
+            (let* ([trimmed (string-trim lhs)]
+                   [base (car source-path)]
+                   [base-len (string-length base)]
+                   [len (string-length trimmed)])
+              (and (> len base-len)
+                   (substring-at? trimmed base 0)
+                   (let ([index (skip-horizontal-forward
+                                  trimmed
+                                  base-len)])
+                     (and (< index len)
+                          (char=? (string-ref trimmed index) #\[)
+                          (let* ([content-start (+ index 1)]
+                                 [content-end (access-path-bracket-end
+                                                trimmed
+                                                content-start)]
+                                 [content (and content-end
+                                               (string-trim
+                                                 (substring
+                                                   trimmed
+                                                   content-start
+                                                   content-end)))])
+                            (and content (quoted-string? content)))))))))
+  (def (sink-access-path sink source-text)
+       (or (let ([arg-text (direct-call-argument-text
+                             sink
+                             source-text)])
+             (and arg-text (access-path-from-text arg-text)))
+           (access-path-from-text (finding-text sink source-text))))
+  (def (assignment-path-kills-source?
          source
          assignment-finding
-         to-binding
+         sink
          source-text)
-       (let ([source-name (finding-text source source-text)])
-         (or (and to-binding
-                  (string=?
-                    source-name
-                    (metavariable-binding-text to-binding)))
-             (let* ([assignment-text (finding-text
-                                       assignment-finding
-                                       source-text)]
-                    [eq-index (string-find-substring assignment-text "=")]
-                    [lhs (and eq-index
-                              (substring assignment-text 0 eq-index))])
-               (and lhs (string-contains-token? lhs source-name))))))
+       (let* ([lhs (assignment-lhs-text
+                     assignment-finding
+                     source-text)]
+              [source-path (access-path-from-text
+                             (finding-text source source-text))]
+              [assignment-path (and lhs (access-path-from-text lhs))]
+              [sink-path (sink-access-path sink source-text)])
+         (and lhs
+              source-path
+              assignment-path
+              sink-path
+              (or (access-path-prefix? assignment-path source-path)
+                  (and (access-path-prefix? source-path assignment-path)
+                       (or (and (access-path-equal?
+                                  source-path
+                                  assignment-path)
+                                (access-path-prefix?
+                                  source-path
+                                  sink-path))
+                           (access-path-prefix? assignment-path sink-path)
+                           (and (access-path-equal? source-path sink-path)
+                                (first-bracket-segment-quoted?
+                                  lhs
+                                  source-path))))))))
+  (def (assignment-target-matches-source? source assignment-finding to-binding sink source-text)
+       (or (assignment-path-kills-source?
+             source
+             assignment-finding
+             sink
+             source-text)
+           (and to-binding
+                (string=?
+                  (finding-text source source-text)
+                  (metavariable-binding-text to-binding)))))
   (def (taint-assignment-kills-source?
          source-state
          assignment
@@ -8756,11 +8896,8 @@
                  (finding-start-offset source))
               (< (finding-start-offset assignment-finding)
                  (finding-start-offset sink))
-              (assignment-target-matches-source?
-                source
-                assignment-finding
-                to-binding
-                source-text))))
+              (assignment-target-matches-source? source assignment-finding
+                to-binding sink source-text))))
   (def (taint-source-killed-before-sink?
          source-state
          sink
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 0d624d9..d2fc1e2 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" . "38CD0141D5589AF0")
+  ("src/semgrep/scan.ss" . "EB4346CDD34F7AE7")
   ("src/semgrep/rule.ss" . "E12C108153C181FA")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
   ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index d2645ef..5f80c5e 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -9177,17 +9177,162 @@
                       sink
                       source-text)))))))
 
-(def (assignment-target-matches-source? source assignment-finding to-binding source-text)
-  (let ([source-name (finding-text source source-text)])
-    (or (and to-binding
-             (string=? source-name
-                       (metavariable-binding-text to-binding)))
-        (let* ([assignment-text (finding-text assignment-finding source-text)]
-               [eq-index (string-find-substring assignment-text "=")]
-               [lhs (and eq-index
-                         (substring assignment-text 0 eq-index))])
-          (and lhs
-               (string-contains-token? lhs source-name))))))
+(def (access-path-identifier-end text start)
+  (let ([len (string-length text)])
+    (let loop ([i start])
+      (if (and (< i len)
+               (identifier-char? (string-ref text i)))
+          (loop (+ i 1))
+          i))))
+
+(def (access-path-bracket-end text start)
+  (let ([len (string-length text)])
+    (let loop ([i start] [state 'normal] [quote-ch #f] [escaped? #f])
+      (cond
+        [(>= i len) #f]
+        [(eq? state 'string)
+         (let ([ch (string-ref text i)])
+           (cond
+             [escaped? (loop (+ i 1) state quote-ch #f)]
+             [(char=? ch #\\) (loop (+ i 1) state quote-ch #t)]
+             [(char=? ch quote-ch) (loop (+ i 1) 'normal #f #f)]
+             [else (loop (+ i 1) state quote-ch #f)]))]
+        [else
+         (let ([ch (string-ref text i)])
+           (cond
+             [(or (char=? ch #\") (char=? ch #\'))
+              (loop (+ i 1) 'string ch #f)]
+             [(char=? ch #\]) i]
+             [else (loop (+ i 1) state quote-ch escaped?)]))]))))
+
+(def (access-path-bracket-segment text start end)
+  (let ([content (string-trim (substring text start end))])
+    (if (quoted-string? content)
+        (unquote-string content)
+        "*")))
+
+(def (access-path-from-text text)
+  (let* ([trimmed (string-trim text)]
+         [len (string-length trimmed)])
+    (and (> len 0)
+         (identifier-char? (string-ref trimmed 0))
+         (let ([base-end (access-path-identifier-end trimmed 0)])
+           (let loop ([i base-end]
+                      [segments (list (substring trimmed 0 base-end))])
+             (let ([j (skip-horizontal-forward trimmed i)])
+               (cond
+                 [(>= j len) (reverse segments)]
+                 [(char=? (string-ref trimmed j) #\.)
+                  (let* ([name-start (+ j 1)]
+                         [name-end (access-path-identifier-end
+                                     trimmed
+                                     name-start)])
+                    (and (> name-end name-start)
+                         (loop name-end
+                               (cons
+                                 (substring trimmed name-start name-end)
+                                 segments))))]
+                 [(char=? (string-ref trimmed j) #\[)
+                  (let ([end (access-path-bracket-end trimmed (+ j 1))])
+                    (and end
+                         (loop (+ end 1)
+                               (cons
+                                 (access-path-bracket-segment
+                                   trimmed
+                                   (+ j 1)
+                                   end)
+                                 segments))))]
+                 [else #f])))))))
+
+(def (access-path-segment-compatible? a b)
+  (or (string=? a b)
+      (string=? a "*")
+      (string=? b "*")))
+
+(def (access-path-prefix? prefix path)
+  (let loop ([xs prefix] [ys path])
+    (cond
+      [(null? xs) #t]
+      [(null? ys) #f]
+      [(access-path-segment-compatible? (car xs) (car ys))
+       (loop (cdr xs) (cdr ys))]
+      [else #f])))
+
+(def (access-path-equal? a b)
+  (and (access-path-prefix? a b)
+       (access-path-prefix? b a)))
+
+(def (assignment-lhs-text assignment-finding source-text)
+  (let* ([assignment-text (finding-text assignment-finding source-text)]
+         [eq-index (string-find-substring assignment-text "=")])
+    (and eq-index
+         (substring assignment-text 0 eq-index))))
+
+(def (first-bracket-segment-quoted? lhs source-path)
+  (and source-path
+       (not (null? source-path))
+       (null? (cdr source-path))
+       (let* ([trimmed (string-trim lhs)]
+              [base (car source-path)]
+              [base-len (string-length base)]
+              [len (string-length trimmed)])
+         (and (> len base-len)
+              (substring-at? trimmed base 0)
+              (let ([index (skip-horizontal-forward trimmed base-len)])
+                (and (< index len)
+                     (char=? (string-ref trimmed index) #\[)
+                     (let* ([content-start (+ index 1)]
+                            [content-end
+                             (access-path-bracket-end trimmed content-start)]
+                            [content
+                             (and content-end
+                                  (string-trim
+                                    (substring
+                                      trimmed
+                                      content-start
+                                      content-end)))])
+                       (and content
+                            (quoted-string? content)))))))))
+
+(def (sink-access-path sink source-text)
+  (or (let ([arg-text (direct-call-argument-text sink source-text)])
+        (and arg-text (access-path-from-text arg-text)))
+      (access-path-from-text (finding-text sink source-text))))
+
+(def (assignment-path-kills-source? source assignment-finding sink source-text)
+  (let* ([lhs (assignment-lhs-text assignment-finding source-text)]
+         [source-path (access-path-from-text (finding-text source source-text))]
+         [assignment-path (and lhs (access-path-from-text lhs))]
+         [sink-path (sink-access-path sink source-text)])
+    (and lhs
+         source-path
+         assignment-path
+         sink-path
+         (or (access-path-prefix? assignment-path source-path)
+             (and (access-path-prefix? source-path assignment-path)
+                  (or (and (access-path-equal? source-path assignment-path)
+                           (access-path-prefix? source-path sink-path))
+                      (access-path-prefix? assignment-path sink-path)
+                      (and (access-path-equal? source-path sink-path)
+                           (first-bracket-segment-quoted?
+                             lhs
+                             source-path))))))))
+
+(def (assignment-target-matches-source?
+       source
+       assignment-finding
+       to-binding
+       sink
+       source-text)
+  (or (assignment-path-kills-source?
+        source
+        assignment-finding
+        sink
+        source-text)
+      (and to-binding
+           (string=?
+             (finding-text source source-text)
+             (metavariable-binding-text to-binding)))))
 
 (def (taint-assignment-kills-source? source-state assignment sink source-text)
   (let* ([source (taint-state-finding source-state)]
@@ -9210,6 +9355,7 @@
            source
            assignment-finding
            to-binding
+           sink
            source-text))))
 
 (def (taint-source-killed-before-sink? source-state sink assignments source-text)
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 0467971..5ac80a5 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -1621,6 +1621,35 @@
     (check (finding-start-line (car findings)) => 4)
     (check (finding-start-line (cadr findings)) => 5)))
 
+(test-case "scan taint whole object source honors field cleans"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.field-clean\n    mode: taint\n    languages: [javascript]\n    message: field clean taint\n    severity: WARNING\n    pattern-sources:\n      - pattern: source\n    pattern-sinks:\n      - pattern: sink(...)\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "javascript"
+            "demo.js"
+            "function f() {\n  x = source;\n  x.a = safe;\n  x.c[i].d = safe;\n  sink(x);\n  sink(x.b);\n  sink(x.b.c);\n  sink(x.c[j]);\n  sink(x.a);\n  sink(x.a.b);\n  sink(x.c[j].d);\n  sink(x.c[j].d.e);\n}\n")])
+    (check (length findings) => 4)
+    (check (finding-start-line (car findings)) => 5)
+    (check (finding-start-line (cadr findings)) => 6)
+    (check (finding-start-line (caddr findings)) => 7)
+    (check (finding-start-line (cadddr findings)) => 8)))
+
+(test-case "scan taint ancestor field clean clears descendant sources"
+  (let* ([taint-config
+          "rules:\n  - id: demo.taint.field-ancestor-clean\n    mode: taint\n    languages: [javascript]\n    message: ancestor field clean taint\n    severity: WARNING\n    pattern-sources:\n      - pattern: source\n    pattern-sinks:\n      - pattern: sink(...)\n"]
+         [findings
+          (scan-config-string
+            taint-config
+            "javascript"
+            "demo.js"
+            "function f() {\n  x.a.b = source;\n  x.a.c = source;\n  sink(x.a.b);\n  sink(x.a.c);\n  sink(x.a.b.x);\n  x.a = safe;\n  sink(x.a.b);\n  sink(x.a.c);\n  sink(x.a.b.x);\n}\n")])
+    (check (length findings) => 3)
+    (check (finding-start-line (car findings)) => 4)
+    (check (finding-start-line (cadr findings)) => 5)
+    (check (finding-start-line (caddr findings)) => 6)))
+
 (test-case "scan taint by-side-effect source reaches wildcard sink"
   (let* ([taint-config
           "rules:\n  - id: demo.taint.by-side-effect-source\n    mode: taint\n    languages: [python]\n    message: side-effect taint\n    severity: WARNING\n    pattern-sources:\n      - by-side-effect: true\n        patterns:\n          - pattern: $X = source()\n          - focus-metavariable: $X\n    pattern-sinks:\n      - pattern: sink(...)\n"]