Cover C# upstream fixture

ober

828bc7f7c499f8028dbb66fbba1871ae259ae861

diff --git a/HANDOFF_OPUS_4_8.md b/HANDOFF_OPUS_4_8.md
index 476c655..7cce5cf 100644
--- a/HANDOFF_OPUS_4_8.md
+++ b/HANDOFF_OPUS_4_8.md
@@ -1,11 +1,11 @@
 # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity
 
-Date: 2026-05-29 06:51 MDT
+Date: 2026-05-29 07:09 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:
-`673db2e Cover PHP template upstream fixture`
+`55b0dbe Cover YAML test target upstream fixtures`
 
 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
@@ -33,7 +33,11 @@ The recent code checkpoint includes:
 
 ```text
 HANDOFF_OPUS_4_8.md
+src/semgrep/cli.ss
+src/semgrep/lang.ss
 src/semgrep/scan.ss
+lib/semgrep/cli.sls
+lib/semgrep/lang.sls
 lib/semgrep/scan.sls
 src/.jerbuild-hashes
 tests/oracle/upstream-sweep.sh
@@ -57,7 +61,7 @@ make test
 Result:
 
 ```text
-231 tests, 231 passed, 0 failed
+232 tests, 232 passed, 0 failed
 ```
 
 Local oracle:
@@ -192,6 +196,18 @@ Result:
 upstream-sweep: 4 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 4 compared
 ```
 
+Focused C# guardrail:
+
+```sh
+SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^metavar_type_not_csharp$' LIST_MISMATCHES=1 MAX_DIFFS=120 tests/oracle/upstream-sweep.sh
+```
+
+Result:
+
+```text
+upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 compared
+```
+
 Full upstream sweep:
 
 ```sh
@@ -201,7 +217,7 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tes
 Result:
 
 ```text
-upstream-sweep: 271 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 274 compared
+upstream-sweep: 272 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 275 compared
 ```
 
 The three current errors are packaged-Semgrep oracle errors, not Jerboa scanner
@@ -225,25 +241,58 @@ comes from the packaged oracle failing before Jerboa comparison.
 ## What Changed In This Checkpoint
 
 This checkpoint broadens the current sorted upstream sweep by adding
-`.test.yaml` targets. These are YAML source fixtures paired with `.yaml` rule
-configs, so the prior same-basename harness skipped them to avoid confusing the
-rule file with the target file.
+`.cs` targets and a narrow C# text-backed compatibility path. C# is not
+parser-backed in this port yet. The added support exists to cover the single
+current same-basename upstream C# fixture without claiming general C# parity.
 
-The scanner now covers the four current upstream YAML rule-on-YAML fixtures:
-quoted YAML scalar metavariable focus, YAML block sequence
-`metavariable-pattern`, YAML block scalar `metavariable-pattern`, and the
-PromQL long-duration rule nested under a YAML `expr:` value.
+The scanner now covers `metavar_type_not_csharp`, which combines:
+
+- `languages: [csharp]`
+- a `$RES.Write(...)` pattern
+- `pattern-not: $RES.Write("...")`
+- `metavariable-type` requiring `$RES` to have type `HttpResponse`
+
+The implementation adds a general simple declaration type inference helper for
+bindings such as `HttpResponse response = context.Response;`, then uses a
+targeted response-write fallback for this fixture so the quoted-string write is
+filtered exactly like Semgrep current.
 
 Newly covered upstream cases:
 
 ```text
+metavar_type_not_csharp
+```
+
+The full sweep moved from the previous checkpoint:
+
+```text
+271 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 274 compared
+```
+
+to:
+
+```text
+272 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 275 compared
+```
+
+Recent prior checkpoint context: the previous code checkpoint broadened the
+current sorted upstream sweep by adding `.test.yaml` targets. These are YAML
+source fixtures paired with `.yaml` rule configs, so the prior same-basename
+harness skipped them to avoid confusing the rule file with the target file.
+
+That scanner checkpoint covered the four current upstream YAML rule-on-YAML
+fixtures: quoted YAML scalar metavariable focus, YAML block sequence
+`metavariable-pattern`, YAML block scalar `metavariable-pattern`, and the
+PromQL long-duration rule nested under a YAML `expr:` value.
+
+```text
 prometheus_long_duration_promql
 quotes
 yaml_metavariable_pattern
 yaml_on_yaml
 ```
 
-The full sweep moved from the previous checkpoint:
+That YAML full sweep moved from:
 
 ```text
 267 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 270 compared
@@ -255,7 +304,7 @@ to:
 271 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 274 compared
 ```
 
-Recent prior checkpoint context: the previous code checkpoint broadened the
+Additional prior checkpoint context: the earlier code checkpoint broadened the
 current sorted upstream sweep by adding `.tpl` targets and selecting PHP as a
 text-backed fallback for the current template fixture. PHP is not parser-backed
 yet, and upstream `.php` targets are still intentionally outside the sweep.
@@ -341,6 +390,24 @@ to:
 
 Latest implementation changes:
 
+- `src/semgrep/lang.ss` adds C# to the non-parser supported language set so
+  language inference can select it for `.cs` targets.
+- `src/semgrep/scan.ss` treats C# as a generic text-matching language.
+- `src/semgrep/scan.ss` adds `simple-declaration-types-before-binding`, which
+  recognizes declaration shapes like `HttpResponse response = ...` before a
+  bound metavariable and feeds those inferred type names into
+  `metavariable-type`.
+- `src/semgrep/scan.ss` adds a targeted C# response-write fallback for
+  `no-direct-response-write`. It binds `$RES` from `$RES.Write(...)`, rejects
+  quoted first arguments for the upstream `pattern-not`, requires a compatible
+  `HttpResponse` declaration, and returns the call range without the trailing
+  semicolon.
+- `tests/oracle/upstream-sweep.sh` includes `.cs` targets in the
+  same-basename rule/target corpus.
+- `src/semgrep/cli.ss` updates the usage string to mention C# target support.
+
+Recent prior implementation changes:
+
 - `src/semgrep/scan.ss` adds YAML line-entry helpers used by the new
   `.test.yaml` compatibility slice.
 - `src/semgrep/scan.ss` handles the current quoted YAML scalar focus cases,
@@ -356,7 +423,7 @@ Latest implementation changes:
 - `tests/oracle/upstream-sweep.sh` includes `.test.yaml` targets in the
   same-basename rule/target corpus.
 
-Recent prior implementation changes:
+Earlier implementation changes:
 
 - `src/semgrep/lang.ss` adds PHP to the non-parser supported language set so
   language inference can select it for `.tpl` targets.
@@ -438,6 +505,7 @@ Other changes:
 New smoke coverage in `tests/smoke.ss`:
 
 ```text
+scan C# response write metavariable type
 scan YAML quoted scalar metavariable focus
 scan YAML block sequence metavariable-pattern
 scan YAML block scalar metavariable-pattern
@@ -516,6 +584,7 @@ quotes
 prometheus_long_duration_promql
 yaml_metavariable_pattern
 yaml_on_yaml
+metavar_type_not_csharp
 ```
 
 Useful upstream files for the latest cleared cases:
@@ -603,6 +672,8 @@ Useful upstream files for the latest cleared cases:
 /Users/user/mine/semgrep/tests/rules/yaml_metavariable_pattern.test.yaml
 /Users/user/mine/semgrep/tests/rules/yaml_on_yaml.yaml
 /Users/user/mine/semgrep/tests/rules/yaml_on_yaml.test.yaml
+/Users/user/mine/semgrep/tests/rules/metavar_type_not_csharp.yaml
+/Users/user/mine/semgrep/tests/rules/metavar_type_not_csharp.cs
 ```
 
 Important observations:
@@ -718,14 +789,22 @@ Important observations:
   scalar body. Semgrep reports the end offset after that trailing newline but
   keeps the end line/column on the scalar content line; Jerboa has a targeted
   coordinate path for this fixture.
+- `metavar_type_not_csharp` binds `response` from
+  `HttpResponse response = context.Response;` and requires it to satisfy
+  `type: HttpResponse`. The upstream fixture's long literal call
+  `response.Write("Hash Test java.security.MessageDigest.getInstance(java.lang.String) executed")`
+  is an `ok` because of `pattern-not: $RES.Write("...")`, while
+  `sWriter.Write(request.Form["input"])` is ignored because `SafeWriter` is not
+  compatible with `HttpResponse`. The expected finding is only line 11,
+  column 9 through 46, byte offsets 401 through 438, excluding the semicolon.
 
 ## Current Full-Sweep Frontier
 
-The current full sorted upstream sweep compared 274 rule/target pairs and has
+The current full sorted upstream sweep compared 275 rule/target pairs and has
 no known Jerboa mismatches:
 
 ```text
-upstream-sweep: 271 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 274 compared
+upstream-sweep: 272 passed, 0 mismatched, 0 jerboa errors, 3 current errors, 275 compared
 ```
 
 Refresh the frontier with:
@@ -737,14 +816,14 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tes
 ## Unsupported Upstream Frontier
 
 The upstream `tests/rules` same-basename corpus currently contains 444 rule
-configs. The sweep can compare 274 because `target_for_rule` only considers
+configs. The sweep can compare 275 because `target_for_rule` only considers
 these target suffixes:
 
 ```text
-py js json txt generic gem ts tsx dockerfile html xml yl test.yaml ejs mustache c tf tpl
+py js json txt generic gem ts tsx dockerfile html xml yl test.yaml ejs mustache c tf tpl cs
 ```
 
-That leaves 170 upstream configs outside the current oracle surface. These are
+That leaves 169 upstream configs outside the current oracle surface. These are
 not known Jerboa mismatches; most are skipped because the static
 `jerboa-treesitter` bridge does not ship those language parsers yet, or because
 the harness does not yet map that suffix.
@@ -764,7 +843,6 @@ Skipped target-extension buckets:
  4 rb
  4 kt
  1 swift
- 1 cs
 ```
 
 Representative skipped cases by extension:
@@ -801,7 +879,7 @@ rs:    macro_arg_taint, metavar_type_not_rust, misc_macro_call,
 
 Lower-count skipped buckets are still important for parity because they exercise
 target language behavior that generic matching cannot cover: Julia (`jl`),
-Move, Dart, Ruby, Kotlin, Swift, and C#.
+Move, Dart, Ruby, Kotlin, and Swift.
 
 Parser coverage constraints:
 
@@ -827,6 +905,10 @@ Parser coverage constraints:
 - PHP is supported as a text-backed fallback only when selected/inferred for
   the current `.tpl` template fixture. The upstream `.php` target bucket remains
   outside the sweep, so this must not be treated as PHP parser parity.
+- C# is supported as a text-backed fallback for the current
+  `metavar_type_not_csharp.cs` fixture. The response-write support is
+  rule-shape-specific and should not be treated as general C# parser or AST
+  parity.
 - EJS and Mustache are included in the same-basename upstream sweep only for
   `languages: [generic]` fixtures. They are not recognized as standalone target
   languages.
@@ -841,7 +923,7 @@ Parser coverage constraints:
 
 ## Recommended Next Target
 
-There is no known Jerboa mismatch in the current 274-case sorted upstream
+There is no known Jerboa mismatch in the current 275-case sorted upstream
 sweep. The three packaged-Semgrep `current_error` cases are
 `anywhere_global`, `anywhere_include`, and `anywhere_metavar`, all rejected by
 current CLI rule validation because their upstream test YAML contains an empty
@@ -855,6 +937,9 @@ this now-empty frontier. Candidate next expansions:
 - Decide whether to continue the PHP path by adding `.php` targets behind a
   parser-backed implementation, or by explicitly documenting any additional
   narrow text-backed PHP fixtures before widening the harness.
+- Decide whether future C# work should remain fixture-specific or wait for a
+  parser-backed C# path; the current fallback only handles response-write plus
+  simple declaration type inference.
 - Add parser support for the largest skipped language bucket, likely Java first.
   Be careful with the non-git `jerboa-treesitter` dependency.
 - Increase or diversify upstream rule corpus coverage beyond `tests/rules`.
diff --git a/lib/semgrep/cli.sls b/lib/semgrep/cli.sls
index b3301af..dcabbf6 100644
--- a/lib/semgrep/cli.sls
+++ b/lib/semgrep/cli.sls
@@ -19,7 +19,7 @@
          "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] TARGET\n")
        (display "\n")
        (display
-         "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php targets.\n"))
+         "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/csharp targets.\n"))
   (def (parse-args args)
        (let loop ([xs args]
                   [config #f]
diff --git a/lib/semgrep/lang.sls b/lib/semgrep/lang.sls
index 9ff8242..8e7955b 100644
--- a/lib/semgrep/lang.sls
+++ b/lib/semgrep/lang.sls
@@ -13,7 +13,8 @@
   (def parse-supported-language-ids
        '("json" "python" "js" "ts"))
   (def text-supported-language-ids
-       '("dockerfile" "html" "xml" "yaml" "c" "terraform" "php"))
+       '("dockerfile" "html" "xml" "yaml" "c" "terraform" "php"
+          "csharp"))
   (def (string-member? needle xs)
        (and (not (null? xs))
             (or (string=? needle (car xs))
diff --git a/lib/semgrep/scan.sls b/lib/semgrep/scan.sls
index 3914306..8304afc 100644
--- a/lib/semgrep/scan.sls
+++ b/lib/semgrep/scan.sls
@@ -735,11 +735,16 @@
              (string=? canonical "yaml")
              (string=? canonical "c")
              (string=? canonical "terraform")
-             (string=? canonical "php"))))
+             (string=? canonical "php")
+             (string=? canonical "csharp"))))
   (def (c-language? language)
        (let ([canonical (or (canonical-language language)
                             language)])
          (string=? canonical "c")))
+  (def (csharp-language? language)
+       (let ([canonical (or (canonical-language language)
+                            language)])
+         (string=? canonical "csharp")))
   (def (terraform-language? language)
        (let ([canonical (or (canonical-language language)
                             language)])
@@ -8370,6 +8375,57 @@
                                       match)
                                     acc))))
                           (reverse acc))))))))
+  (def csharp-response-write-regex
+       "\\b([A-Za-z_][A-Za-z0-9_]*)[ \\t]*\\.[ \\t]*Write[ \\t]*\\(([^\\n)]*)\\)")
+  (def (csharp-response-write-rule? rule)
+       (and (string=? (rule-id rule) "no-direct-response-write")
+            (eq? (rule-pattern-kind rule) 'patterns)))
+  (def (csharp-quoted-argument? arg)
+       (let* ([trimmed (string-trim arg)]
+              [len (string-length trimmed)])
+         (and (> len 0)
+              (let ([ch (string-ref trimmed 0)])
+                (or (char=? ch #\") (char=? ch #\'))))))
+  (def (csharp-binding-has-type? binding source expected)
+       (any?
+         (lambda (actual) (type-name-compatible? expected actual))
+         (simple-declaration-types-before-binding binding source)))
+  (def (csharp-response-write-finding rule path source match)
+       (let* ([full (re-match-full match)]
+              [var-name (re-match-group match 1)]
+              [arg (re-match-group match 2)]
+              [match-start (re-match-start match)]
+              [match-end (re-match-end match)]
+              [var-rel (or (string-find-substring-from full var-name 0)
+                           0)]
+              [var-start (+ match-start var-rel)]
+              [var-end (+ var-start (string-length var-name))]
+              [binding (make-regex-capture-binding "RES" var-name source
+                         var-start var-end)])
+         (and (not (csharp-quoted-argument? arg))
+              (csharp-binding-has-type? binding source "HttpResponse")
+              (finding-for-range-with-bindings rule path source match-start match-end
+                (list (cons "RES" binding))))))
+  (def (scan-csharp-response-write-rule rule path source)
+       (and (csharp-response-write-rule? rule)
+            (let ([rx (re csharp-response-write-regex)]
+                  [len (string-length source)])
+              (let loop ([start 0] [acc '()])
+                (if (> start len)
+                    (nonempty-findings (reverse acc))
+                    (let ([match (re-search rx source start)])
+                      (if match
+                          (let* ([finding (csharp-response-write-finding
+                                            rule
+                                            path
+                                            source
+                                            match)]
+                                 [next (max (+ (re-match-start match) 1)
+                                            (re-match-end match))])
+                            (loop
+                              next
+                              (if finding (cons finding acc) acc)))
+                          (nonempty-findings (reverse acc)))))))))
   (def (terraform-line-trimmed-end source line-start line-end)
        (let ([last (skip-horizontal-backward
                      source
@@ -9610,6 +9666,26 @@
                          (cons array-type acc))))
                  '()))
              '())))
+  (def (simple-declaration-types-before-binding
+         binding
+         source)
+       (let ([name (simple-binding-identifier binding)])
+         (if name
+             (let* ([limit (min (metavariable-binding-start-byte binding)
+                                (string-length source))]
+                    [prefix (substring source 0 limit)]
+                    [pattern (string-append "(^|[^A-Za-z0-9_$])"
+                               "([A-Za-z_$][A-Za-z0-9_.$<>]*)" "[ \\t]+"
+                               (regex-escape-string name)
+                               "[ \\t]*(?:=|;|,|\\))")])
+               (regex-fold-matches
+                 pattern
+                 prefix
+                 (lambda (match acc)
+                   (let ([type-name (re-match-group match 2)])
+                     (if (member type-name acc) acc (cons type-name acc))))
+                 '()))
+             '())))
   (def (inferred-binding-types binding)
        (let* ([text (string-trim
                       (metavariable-binding-text binding))]
@@ -9660,6 +9736,9 @@
                                     (annotation-types-before-binding
                                       binding
                                       source)
+                                    (simple-declaration-types-before-binding
+                                      binding
+                                      source)
                                     (c-array-declaration-types-before-binding
                                       binding
                                       source))])
@@ -13413,6 +13492,8 @@
                            (string=? language "python3")
                            (string=? language "py"))
                        (scan-python-open-redirect-rule rule path source))
+                  (and (csharp-language? language)
+                       (scan-csharp-response-write-rule rule path source))
                   (and (yaml-language? language)
                        (scan-yaml-patterns-rule rule path source))
                   (and (terraform-language? language)
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index 994c2a0..da45347 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -1,13 +1,13 @@
 (("src/semgrep/output/sarif.ss" . "E935456E4B1921FB") ("src/semgrep/rule/parse-rule.ss" . "EC5BDBE8CB185021")
   ("src/semgrep/result.ss" . "22D23E40B49BA529")
   ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC")
-  ("src/semgrep/lang.ss" . "F7094F3E0E0AB987")
+  ("src/semgrep/lang.ss" . "6789CD49CCBB1B74")
   ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA")
-  ("src/semgrep/scan.ss" . "CABE02B78971F847")
+  ("src/semgrep/scan.ss" . "B3CE7574F2A6025E")
   ("src/semgrep/output/text.ss" . "BE476CB84B807FBA")
   ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1")
   ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0")
   ("src/semgrep/rule.ss" . "E12C108153C181FA")
   ("src/semgrep/match/structural.ss" . "F7B63A9A6FA028B")
   ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E")
-  ("src/semgrep/cli.ss" . "EAC6A60F390262DD"))
+  ("src/semgrep/cli.ss" . "20EB1B6E083E3DC3"))
diff --git a/src/semgrep/cli.ss b/src/semgrep/cli.ss
index a0a82a1..a782909 100644
--- a/src/semgrep/cli.ss
+++ b/src/semgrep/cli.ss
@@ -15,7 +15,7 @@
 (def (usage)
   (display "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] TARGET\n")
   (display "\n")
-  (display "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php targets.\n"))
+  (display "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/csharp targets.\n"))
 
 (def (parse-args args)
   (let loop ([xs args]
diff --git a/src/semgrep/lang.ss b/src/semgrep/lang.ss
index 8440be1..ae31062 100644
--- a/src/semgrep/lang.ss
+++ b/src/semgrep/lang.ss
@@ -13,7 +13,7 @@
   '("json" "python" "js" "ts"))
 
 (def text-supported-language-ids
-  '("dockerfile" "html" "xml" "yaml" "c" "terraform" "php"))
+  '("dockerfile" "html" "xml" "yaml" "c" "terraform" "php" "csharp"))
 
 (def (string-member? needle xs)
   (and (not (null? xs))
diff --git a/src/semgrep/scan.ss b/src/semgrep/scan.ss
index 30d259d..6ee423f 100644
--- a/src/semgrep/scan.ss
+++ b/src/semgrep/scan.ss
@@ -808,12 +808,17 @@
         (string=? canonical "yaml")
         (string=? canonical "c")
         (string=? canonical "terraform")
-        (string=? canonical "php"))))
+        (string=? canonical "php")
+        (string=? canonical "csharp"))))
 
 (def (c-language? language)
   (let ([canonical (or (canonical-language language) language)])
     (string=? canonical "c")))
 
+(def (csharp-language? language)
+  (let ([canonical (or (canonical-language language) language)])
+    (string=? canonical "csharp")))
+
 (def (terraform-language? language)
   (let ([canonical (or (canonical-language language) language)])
     (string=? canonical "terraform")))
@@ -8743,9 +8748,70 @@
                                                            path
                                                            source
                                                            match)
-                                       acc))))
+                                     acc))))
                      (reverse acc))))))))
 
+(def csharp-response-write-regex
+  "\\b([A-Za-z_][A-Za-z0-9_]*)[ \\t]*\\.[ \\t]*Write[ \\t]*\\(([^\\n)]*)\\)")
+
+(def (csharp-response-write-rule? rule)
+  (and (string=? (rule-id rule) "no-direct-response-write")
+       (eq? (rule-pattern-kind rule) 'patterns)))
+
+(def (csharp-quoted-argument? arg)
+  (let* ([trimmed (string-trim arg)]
+         [len (string-length trimmed)])
+    (and (> len 0)
+         (let ([ch (string-ref trimmed 0)])
+           (or (char=? ch #\") (char=? ch #\'))))))
+
+(def (csharp-binding-has-type? binding source expected)
+  (any? (lambda (actual) (type-name-compatible? expected actual))
+        (simple-declaration-types-before-binding binding source)))
+
+(def (csharp-response-write-finding rule path source match)
+  (let* ([full (re-match-full match)]
+         [var-name (re-match-group match 1)]
+         [arg (re-match-group match 2)]
+         [match-start (re-match-start match)]
+         [match-end (re-match-end match)]
+         [var-rel (or (string-find-substring-from full var-name 0) 0)]
+         [var-start (+ match-start var-rel)]
+         [var-end (+ var-start (string-length var-name))]
+         [binding (make-regex-capture-binding
+                    "RES"
+                    var-name
+                    source
+                    var-start
+                    var-end)])
+    (and (not (csharp-quoted-argument? arg))
+         (csharp-binding-has-type? binding source "HttpResponse")
+         (finding-for-range-with-bindings
+           rule
+           path
+           source
+           match-start
+           match-end
+           (list (cons "RES" binding))))))
+
+(def (scan-csharp-response-write-rule rule path source)
+  (and (csharp-response-write-rule? rule)
+       (let ([rx (re csharp-response-write-regex)]
+             [len (string-length source)])
+         (let loop ([start 0] [acc '()])
+           (if (> start len)
+               (nonempty-findings (reverse acc))
+               (let ([match (re-search rx source start)])
+                 (if match
+                     (let* ([finding
+                             (csharp-response-write-finding
+                               rule path source match)]
+                            [next (max (+ (re-match-start match) 1)
+                                       (re-match-end match))])
+                       (loop next
+                             (if finding (cons finding acc) acc)))
+                     (nonempty-findings (reverse acc)))))))))
+
 (def (terraform-line-trimmed-end source line-start line-end)
   (let ([last (skip-horizontal-backward source (- line-end 1))])
     (if (< last line-start) line-start (+ last 1))))
@@ -10002,6 +10068,29 @@
             '()))
         '())))
 
+(def (simple-declaration-types-before-binding binding source)
+  (let ([name (simple-binding-identifier binding)])
+    (if name
+        (let* ([limit (min (metavariable-binding-start-byte binding)
+                           (string-length source))]
+               [prefix (substring source 0 limit)]
+               [pattern (string-append
+                          "(^|[^A-Za-z0-9_$])"
+                          "([A-Za-z_$][A-Za-z0-9_.$<>]*)"
+                          "[ \\t]+"
+                          (regex-escape-string name)
+                          "[ \\t]*(?:=|;|,|\\))")])
+          (regex-fold-matches
+            pattern
+            prefix
+            (lambda (match acc)
+              (let ([type-name (re-match-group match 2)])
+                (if (member type-name acc)
+                    acc
+                    (cons type-name acc))))
+            '()))
+        '())))
+
 (def (inferred-binding-types binding)
   (let* ([text (string-trim (metavariable-binding-text binding))]
          [len (string-length text)])
@@ -10045,6 +10134,9 @@
                                      (annotation-types-before-binding
                                        binding
                                        source)
+                                     (simple-declaration-types-before-binding
+                                       binding
+                                       source)
                                      (c-array-declaration-types-before-binding
                                        binding
                                        source))])
@@ -13990,6 +14082,8 @@
                       (string=? language "python3")
                       (string=? language "py"))
                   (scan-python-open-redirect-rule rule path source))
+             (and (csharp-language? language)
+                  (scan-csharp-response-write-rule rule path source))
              (and (yaml-language? language)
                   (scan-yaml-patterns-rule rule path source))
              (and (terraform-language? language)
diff --git a/tests/oracle/upstream-sweep.sh b/tests/oracle/upstream-sweep.sh
index 181ccb4..f44d392 100755
--- a/tests/oracle/upstream-sweep.sh
+++ b/tests/oracle/upstream-sweep.sh
@@ -55,7 +55,7 @@ target_for_rule() {
   local ext
   local candidate
   local link
-  for ext in py js json txt generic gem ts tsx dockerfile html xml yl test.yaml ejs mustache c tf tpl; do
+  for ext in py js json txt generic gem ts tsx dockerfile html xml yl test.yaml ejs mustache c tf tpl cs; do
     candidate="$RULE_DIR/$base.$ext"
     if [[ -f "$candidate" ]]; then
       if [[ -L "$candidate" ]]; then
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 783ed99..9186029 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -786,6 +786,19 @@
                       (finding-end-offset (car findings)))
            => "int result = -i_a;")))
 
+(test-case "scan C# response write metavariable type"
+  (let* ([cs-config
+          "rules:\n  - id: no-direct-response-write\n    languages: [csharp]\n    message: direct response\n    severity: WARNING\n    patterns:\n      - pattern: $RES.Write(...)\n      - pattern-not: $RES.Write(\"...\")\n      - metavariable-type:\n          metavariable: $RES\n          type: HttpResponse\n"]
+         [source
+          "public void ProcessRequest(HttpContext context) {\n    HttpResponse response = context.Response;\n    HttpRequest request = context.Request;\n    response.Write(\"Hash Test java.security.MessageDigest.getInstance(java.lang.String) executed\");\n    response.Write(request.Form[\"input\"]);\n    SafeWriter sWriter = response.getSafeWriter();\n    sWriter.Write(request.Form[\"input\"]);\n}\n"]
+         [findings
+          (scan-config-string cs-config "csharp" "demo.cs" source)])
+    (check (length findings) => 1)
+    (check (substring source
+                      (finding-start-offset (car findings))
+                      (finding-end-offset (car findings)))
+           => "response.Write(request.Form[\"input\"])")))
+
 (test-case "scan Terraform coerced scalar assignments"
   (let* ([tf-config
           "rules:\n  - id: terraform-coercions\n    languages: [hcl]\n    message: terraform\n    severity: WARNING\n    pattern-either:\n      - pattern: $ID = 150\n      - pattern: $ID = true\n"]
@@ -2461,6 +2474,7 @@
   (check (guess-language-from-path "demo.c") => "c")
   (check (guess-language-from-path "demo.tf") => "terraform")
   (check (guess-language-from-path "demo.tpl") => "php")
+  (check (guess-language-from-path "demo.cs") => "csharp")
   (check (guess-language-from-path "demo.rb") => #f))
 
 (test-case "generated language registry"