Structural taint engine: receiver-taint base-flow (method calls / field chains)

ober

53d27cb976ec05b3ee576c965eb9854fb9f446c5

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 190a5a3..9a25599 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -201,13 +201,32 @@ languages, so closing them is the tip of the larger work:
    Fix: a finding whose own line is a function-scope line takes that line as its
    scope, so a parameter is scoped to its own method. Non-regressing (all
    structural langs unchanged, smoke 321/321). This unblocks the param-source chain.
-   STILL TODO for assume-safe: (2) numeric/boolean source+value type-classification
-   (helpers were prototyped: `source-state-numeric-typed-param?` classifies
-   `int`/`long`/`Integer`… correctly via `param-type-before-name`), THEN (3)
-   numeric-result value-analysis (`Integer.valueOf`, `+`, `!=`). The `test` rule-id
-   group needs (2)+(3) + best-fit-sink, getter/setter, lambda, jpa, FQN before its
-   overfit clause retires. The same engine retires the other languages' taint
-   handlers + unblocks dart.
+   **(2) Numeric-typed param source classification — BUILT + merged (903f84f).**
+   `source-state-numeric-typed-param?` (via `param-type-before-name`) drops
+   `int`/`long`/`Integer`… param sources under assume_safe_numbers. With (1) it
+   cut assume_safe_numbers1 FPs 8→2 (handler off).
+   **(3a) Base-flow: receiver-taint — BUILT (2026-06-02).** Debugging
+   assume_safe_numbers1 with the option removed revealed the base param-source
+   flow itself dropped any value derived through a method call: `access-path-from-text`
+   returned #f at the first `(`, so `x.foo()`/`x.getSomething()` had no path and
+   never reached, while bare field `x.length` did. Fix is receiver-anchored via
+   `field-source-taints-base-text?` (EXPR is BASE + `.`/`[`): added as a reach
+   condition in three places — `source-base-of-sink-arg?` for `sink(x.foo())`,
+   `source-compatible-with-sink/except?` (the binding gate), and
+   `source-taints-binding?` for propagator assignments `t = x.foo(); sink(t)`.
+   Receiver-anchored so it never matches an arg inside another call
+   (`other(x)` / the assume_safe_functions wrapper test stays blocked). Validated:
+   smoke 321/321, taint-subset upstream-sweep 162/162 (0 mismatch, 0 error).
+   NOTE: did NOT touch `access-path-from-text` globally — that broke 3 smoke
+   taint tests by making `direct-call-argument-text` dig into inner-call args.
+   **(3b) STILL TODO base-flow:** argument-taint `sink(f(x))` (x is an ARG not a
+   receiver — test8/9/10 `Integer.valueOf(x)`), and binary-expr / cast RHS in
+   propagators (`w = v + x.getSomething()` test7, `t = (int)x.getSomething()`
+   test5) which `field-source-taints-base-text?` won't anchor. THEN (3c)
+   value-analysis (`.length`→numeric test4, string-concat test7).
+   The `test` rule-id group needs (2)+(3a/b/c) + best-fit-sink, getter/setter,
+   lambda, jpa, FQN before its overfit clause retires. The same engine retires the
+   other languages' taint handlers + unblocks dart.
    - a few per-language pattern hard-cases (go constant-folding/struct_tags, php
      metavar-call/non-prim-type, rust macro-call, kotlin named-ellipsis).
 
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index c983a75..4889606 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -31812,6 +31812,20 @@
                         (metavariable-binding-text (cdr entry))
                         arg-text))
                     (finding-metavars source))))))
+  (def (source-base-of-sink-arg? source sink source-text)
+       (let ([arg-text (direct-call-argument-text
+                         sink
+                         source-text)])
+         (and arg-text
+              (or (field-source-taints-base-text?
+                    arg-text
+                    (finding-text source source-text))
+                  (any?
+                    (lambda (entry)
+                      (field-source-taints-base-text?
+                        arg-text
+                        (metavariable-binding-text (cdr entry))))
+                    (finding-metavars source))))))
   (def (line-indent line)
        (let ([len (string-length line)])
          (let loop ([i 0])
@@ -31894,6 +31908,10 @@
                             source
                             sink
                             source-text)
+                          (source-base-of-sink-arg?
+                            source
+                            sink
+                            source-text)
                           (and (taint-state-token? source-state)
                                (source-token-in-sink/except?
                                  source
@@ -32878,6 +32896,7 @@
                          source
                          sink
                          source-text))
+                  (source-base-of-sink-arg? source sink source-text)
                   (and (finding-range-contains? sink source)
                        (not (finding-range-equal? sink source))
                        (echo-or-print-statement-sink? sink source-text)
@@ -33112,7 +33131,10 @@
                       (finding-range-contains? source-finding from-finding)
                       (string=?
                         (finding-text source-finding source)
-                        (metavariable-binding-text from-binding)))))))
+                        (metavariable-binding-text from-binding))
+                      (field-source-taints-base-text?
+                        (metavariable-binding-text from-binding)
+                        (finding-text source-finding source)))))))
   (def (propagator-blocked-by-sanitizer?
          source-state
          from-finding
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 452fea9..81dddc4 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -31757,6 +31757,24 @@
                        arg-text))
                    (finding-metavars source))))))
 
+(def (source-base-of-sink-arg? source sink source-text)
+  ;; The sink's argument is a member/index/method access whose receiver is the
+  ;; source variable (`sink(x.foo())`, `sink(x.length)`): a value derived from
+  ;; the tainted receiver reaches the sink. Mirrors how a bare `sink(x)` reaches
+  ;; via the access path, but covers the call-paren case the path parser stops
+  ;; at. Receiver-anchored, so it never matches a source that is merely an
+  ;; argument of some other call (`other(x)`).
+  (let ([arg-text (direct-call-argument-text sink source-text)])
+    (and arg-text
+         (or (field-source-taints-base-text?
+               arg-text
+               (finding-text source source-text))
+             (any? (lambda (entry)
+                     (field-source-taints-base-text?
+                       arg-text
+                       (metavariable-binding-text (cdr entry))))
+                   (finding-metavars source))))))
+
 (def (line-indent line)
   (let ([len (string-length line)])
     (let loop ([i 0])
@@ -31840,6 +31858,7 @@
                        source
                        sink
                        source-text)
+                     (source-base-of-sink-arg? source sink source-text)
                      (and (taint-state-token? source-state)
                           (source-token-in-sink/except?
                             source
@@ -32750,6 +32769,7 @@
                            (finding-metavars-all-self? source))))
              (and (not (taint-state-token? source-state))
                   (direct-call-argument-source? source sink source-text))
+             (source-base-of-sink-arg? source sink source-text)
              (and (finding-range-contains? sink source)
                   (not (finding-range-equal? sink source))
                   (echo-or-print-statement-sink? sink source-text)
@@ -33016,7 +33036,15 @@
                  (finding-range-contains? source-finding
                                           from-finding)
                  (string=? (finding-text source-finding source)
-                           (metavariable-binding-text from-binding)))))))
+                           (metavariable-binding-text from-binding))
+                 ;; `from` is a method call / field access on the source
+                 ;; variable (`x.foo()`, `x.length`): invoking/indexing a
+                 ;; tainted receiver yields a tainted value, so the assigned
+                 ;; target carries the taint. The same-scope + before-or-at
+                 ;; gates above keep this name-based reach within one method.
+                 (field-source-taints-base-text?
+                   (metavariable-binding-text from-binding)
+                   (finding-text source-finding source)))))))
 
 (def (propagator-blocked-by-sanitizer? source-state from-finding sanitizers source)
   (any? (lambda (sanitizer-state)