Ctrl K
ComunidadGuías…

tests/csharp_extension_harness.py

Rama v8 · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""A/B harness for candidate C# extension-call binding strategies (#4828).

The issue is a *design* question, not just a bug: binding ``x.Ext()`` to a
``this``-parameter method can be done with very different amounts of proof. This
module implements the candidates behind one engine so the only thing that varies
is a flag, and pairs them with fixtures whose ground truth is decidable from the
C# spec alone (``expect`` = a correct implementation must bind, ``forbid`` = a
correct implementation must not bind, ``optional`` = genuinely unknowable without
a BCL symbol table, never scored).

Strategy ids:

  S0  baseline            current main (no extension pass)
  S1  typed_exact         in-corpus receiver, extended type name == receiver name
  S2  typed_exact_scope   S1 + declaring namespace must be in scope at the call
  S3  typed_assignable    S2 + inherits/implements applicability + unresolved-base poison
  S4  roc_naive           S3 + out-of-corpus receivers, no deny-list
  S5  roc_guarded         S3 + out-of-corpus receivers with deny-list + uniqueness
  S6  reporter_patch      the issue's local patch: exact match, no scope, deny-list,
                          untyped-receiver rung
  S7  all_rungs           S5 + untyped-receiver rung
  S8  bare_name_control   control: bind any unique same-named method in the corpus
  S9  signature_parse     S3 but reads the extended type out of ``metadata.signature``
"""
from __future__ import annotations

import os
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path

from graphify import resolver_registry
from graphify.extract import extract, extract_csharp
from graphify.extractors.csharp import CsharpNameResolver

# --------------------------------------------------------------------------- #
# ground-truth fixtures
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class Fixture:
    """One corpus with markers; expectations are markers, not line numbers.

    ``expect[m]  = (decl_file, decl_marker)``  must bind (compile-time certain)
    ``forbid``   markers that must NOT bind    (compile-time certain, any target)
    ``forbid_pairs[m] = (decl_file, decl_marker)`` must not bind to that target
    ``optional`` markers whose truth needs a BCL symbol table (never scored)
    """

    files: dict[str, str]
    expect: dict[str, tuple[str, str]] = field(default_factory=dict)
    forbid: list[str] = field(default_factory=list)
    forbid_pairs: dict[str, tuple[str, str]] = field(default_factory=dict)
    optional: list[str] = field(default_factory=list)
    note: str = ""


FIXTURES: dict[str, Fixture] = {
    # The reporter's exact corpus. `s.Mask()` is decidably an extension call;
    # `v.ToString().Mask()` is a chain whose inner return type is BCL, so the
    # *only* way to bind it is the untyped rung (kept as `expect` because the
    # issue states it, and scoring shows exactly which strategies reach it).
    "issue_repro": Fixture(
        files={
            "Ext.cs": (
                "namespace Ext {\n"
                "  public static class StringExtensions {\n"
                "    public static string Mask(this string s, char m = '*') { return s; } // D1\n"
                "  }\n"
                "}\n"
            ),
            "Svc.cs": (
                "using Ext;\n"
                "public class Svc {\n"
                "  public string A(string s) { return s.Mask(); } // E1\n"
                "  public string B(long v) { return v.ToString().Mask(); } // E2\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Ext.cs", "// D1"), "// E2": ("Ext.cs", "// D1")},
        note="issue #4828 body",
    ),
    # Typed receiver, cross-file, overload chosen by argument count.
    "domain_cross_file": Fixture(
        files={
            "Model.cs": (
                "namespace App {\n"
                "  public class Widget { }\n"
                "  public static class WidgetExtensions {\n"
                "    public static void Paint(this Widget w) { } // D1\n"
                "    public static void Paint(this Widget w, int c) { } // D2\n"
                "  }\n"
                "}\n"
            ),
            "Use.cs": (
                "using App;\n"
                "namespace App {\n"
                "  public class Painter {\n"
                "    public void Run(Widget w) {\n"
                "      w.Paint(); // E1\n"
                "      w.Paint(3); // E2\n"
                "    }\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Model.cs", "// D1"), "// E2": ("Model.cs", "// D2")},
    ),
    # Same file as the declaration: proves this is not a cross-file bug.
    "same_file": Fixture(
        files={
            "All.cs": (
                "namespace App {\n"
                "  public class Widget { }\n"
                "  public static class WExt {\n"
                "    public static void Paint(this Widget w) { } // D1\n"
                "  }\n"
                "  public class Painter {\n"
                "    public void Run(Widget w) { w.Paint(); } // E1\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={"// E1": ("All.cs", "// D1")},
    ),
    # Applicability beyond name equality: base class and implemented interface.
    "base_and_interface": Fixture(
        files={
            "Domain.cs": (
                "namespace App {\n"
                "  public class Animal { }\n"
                "  public class Dog : Animal { }\n"
                "  public interface IStore { }\n"
                "  public class SqlStore : IStore { }\n"
                "  public static class Exts {\n"
                "    public static void Speak(this Animal a) { } // D1\n"
                "    public static void SaveStore(this IStore s) { } // D2\n"
                "  }\n"
                "}\n"
            ),
            "UseApp.cs": (
                "using App;\n"
                "namespace App {\n"
                "  public class Svc {\n"
                "    public void A(Dog d) { d.Speak(); } // E1\n"
                "    public void B(SqlStore s) { s.SaveStore(); } // E2\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Domain.cs", "// D1"), "// E2": ("Domain.cs", "// D2")},
        note="exact-name matching cannot reach these",
    ),
    # The receiver's base is out of corpus: it may declare an instance member of
    # that name, which always beats an extension — so nothing may bind.
    "unresolved_base_poison": Fixture(
        files={
            "W.cs": (
                "namespace App {\n"
                "  public class W : ThirdParty.Base { }\n"
                "  public static class WExt {\n"
                "    public static void Reset(this W w) { } // D1\n"
                "  }\n"
                "}\n"
            ),
            "Use.cs": (
                "using App;\n"
                "namespace App {\n"
                "  public class U {\n"
                "    public void A(W w) { w.Reset(); } // E1\n"
                "  }\n"
                "}\n"
            ),
        },
        forbid=["// E1"],
        note="an out-of-corpus base may declare Reset() and win",
    ),
    # Two applicable extensions in the same scope: C# rejects the call.
    "ambiguous_same_scope": Fixture(
        files={
            "Common.cs": "public class Thing { }\n",
            "A.cs": (
                "namespace N1 { public static class X1 {\n"
                "  public static void Go(this Thing t) { } // D1\n"
                "} }\n"
            ),
            "B.cs": (
                "namespace N2 { public static class X2 {\n"
                "  public static void Go(this Thing t) { } // D2\n"
                "} }\n"
            ),
            "Use.cs": (
                "using N1;\n"
                "using N2;\n"
                "public class U {\n"
                "  public void Run(Thing o) { o.Go(); } // E1\n"
                "}\n"
            ),
        },
        forbid=["// E1"],
    ),
    # Nested-namespace preference: the inner declaration wins, per the spec's
    # innermost-scope-first lookup.
    "inner_scope_wins": Fixture(
        files={
            "Outer.cs": (
                "namespace N {\n"
                "  public class Thing { }\n"
                "  public static class OuterExt {\n"
                "    public static void Go(this Thing t) { } // D1\n"
                "  }\n"
                "}\n"
            ),
            "Inner.cs": (
                "namespace N.Inner { public static class InnerExt {\n"
                "  public static void Go(this Thing t) { } // D2\n"
                "} }\n"
            ),
            "Use.cs": (
                "using N;\n"
                "namespace N.Inner {\n"
                "  public class U { public void Run(Thing o) { o.Go(); } // E1\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Inner.cs", "// D2")},
        forbid_pairs={"// E1": ("Outer.cs", "// D1")},
        note="bind D2, never D1",
    ),
    # The declaration's namespace is not imported at the call site.
    "out_of_scope": Fixture(
        files={
            "Other.cs": (
                "namespace Other {\n"
                "  public static class OExt {\n"
                "    public static void Secret(this App.Widget w) { } // D1\n"
                "  }\n"
                "}\n"
            ),
            "Model.cs": "namespace App { public class Widget { } }\n",
            "Use.cs": (
                "using App;\n"
                "namespace App {\n"
                "  public class U { public void Run(Widget w) { w.Secret(); } // E1\n"
                "  }\n"
                "}\n"
            ),
        },
        forbid=["// E1"],
    ),
    # Instance member always wins over an extension of the same name.
    "instance_wins": Fixture(
        files={
            "Cache.cs": (
                "namespace App {\n"
                "  public class Cache {\n"
                "    public object GetValue() { return null; } // D1\n"
                "  }\n"
                "  public static class CacheExt {\n"
                "    public static object GetValue(this Cache c) { return c; } // D2\n"
                "  }\n"
                "  public class U {\n"
                "    public object Run(Cache c) { return c.GetValue(); } // E1\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Cache.cs", "// D1")},
        forbid_pairs={"// E1": ("Cache.cs", "// D2")},
        note="forbid D2; the D1 edge is baseline, the harness scores only added edges",
    ),
    # A BCL member name on an out-of-corpus receiver: unknowable without a symbol
    # table, so it is `forbid` for the guarded strategies and the cost of the
    # deny-list is visible as a missed `optional` call.
    "bcl_name_cost": Fixture(
        files={
            "E.cs": (
                "public static class ObjExt {\n"
                "  public static object GetValue(this object o) { return o; } // D1\n"
                "  public static object GetValue(this object o, object idx) { return o; } // D2\n"
                "}\n"
            ),
            "U.cs": (
                "using System.Reflection;\n"
                "public class U {\n"
                "  public object A(PropertyInfo p) { return p.GetValue(null); } // E1\n"
                "  public object B(object o) { return o.GetValue(); } // E2\n"
                "}\n"
            ),
        },
        forbid=["// E1"],
        optional=["// E2"],
        note="E1 is a real BCL instance member; no strategy may bind it to ObjExt",
    ),
    # Arity / optional / params, all on an in-corpus receiver. One caller per
    # call site: the graph keeps one edge per (caller, target) pair, so two calls
    # from one caller to the same declaration would collapse into one edge and
    # the call-site expectation would be unobservable.
    "arity": Fixture(
        files={
            "Arity.cs": (
                "namespace App {\n"
                "  public class W { }\n"
                "  public static class E {\n"
                "    public static void Log(this W w) { } // D1\n"
                "    public static void Log(this W w, int n) { } // D2\n"
                "    public static void Opt(this W w, int a, int b = 0) { } // D3\n"
                "    public static void Many(this W w, params object[] rest) { } // D4\n"
                "  }\n"
                "  public class U {\n"
                "    public void C1(W w) { w.Log(); } // E1\n"
                "    public void C2(W w) { w.Log(1); } // E2\n"
                "    public void C3(W w) { w.Opt(1); } // E3\n"
                "    public void C4(W w) { w.Opt(1, 2); } // E4\n"
                "    public void C5(W w) { w.Many(); } // E5\n"
                "    public void C6(W w) { w.Many(1, 2, 3); } // E6\n"
                "    public void C7(W w) { w.Opt(); } // F1\n"
                "    public void C8(W w) { w.Log(1, 2); } // F2\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={
            "// E1": ("Arity.cs", "// D1"),
            "// E2": ("Arity.cs", "// D2"),
            "// E3": ("Arity.cs", "// D3"),
            "// E4": ("Arity.cs", "// D3"),
            "// E5": ("Arity.cs", "// D4"),
            "// E6": ("Arity.cs", "// D4"),
        },
        forbid=["// F1", "// F2"],
    ),
    # Primitive and third-party receivers: the reporter's headline case.
    "primitive_and_thirdparty": Fixture(
        files={
            "Ext.cs": (
                "namespace App {\n"
                "  public static class StringExt {\n"
                "    public static string Mask(this string s) { return s; } // D1\n"
                "    public static int Twice(this int i) { return i * 2; } // D2\n"
                "  }\n"
                "  public static class HttpExt {\n"
                "    public static string GetJson(this System.Net.Http.HttpClient c) { return \"\"; } // D3\n"
                "  }\n"
                "}\n"
            ),
            "Use.cs": (
                "using App;\n"
                "using System.Net.Http;\n"
                "namespace App {\n"
                "  public class U {\n"
                "    public string A(string s) { return s.Mask(); } // E1\n"
                "    public int B(int i) { return i.Twice(); } // E2\n"
                "    public string C(HttpClient c) { return c.GetJson(); } // E3\n"
                "  }\n"
                "}\n"
            ),
        },
        expect={
            "// E1": ("Ext.cs", "// D1"),
            "// E2": ("Ext.cs", "// D2"),
            "// E3": ("Ext.cs", "// D3"),
        },
    ),
    # A real class named T vs a generic `this T`: the non-generic exact match
    # wins; the generic one is the wildcard.
    "type_param_vs_class": Fixture(
        files={
            "T.cs": (
                "public class T { }\n"
                "public static class TExt {\n"
                "  public static void Go(this T t) { } // D1\n"
                "}\n"
                "public static class GenExt {\n"
                "  public static void Go<T>(this T t) { } // D2\n"
                "}\n"
            ),
            "U.cs": (
                "public class U {\n"
                "  public void A(T t) { t.Go(); } // E1\n"
                "}\n"
            ),
        },
        expect={"// E1": ("T.cs", "// D1")},
        note="signature-only resolution cannot tell the two `this T`s apart",
    ),
    # A generic extension reaches any receiver, including one the corpus never
    # declares. This is the wildcard rung, and it needs the `extended_is_type_param`
    # fact: the concrete class named T above must keep winning over it.
    "generic_wildcard": Fixture(
        files={
            "Gen.cs": (
                "public static class GenExt {\n"
                "  public static void Go<T>(this T t) { } // D1\n"
                "}\n"
            ),
            "U.cs": (
                "public class U {\n"
                "  public void A(int i) { i.Go(); } // E1\n"
                "}\n"
            ),
        },
        expect={"// E1": ("Gen.cs", "// D1")},
    ),
    # A capitalized untyped receiver is a type name (`Convert.ToInt32`), never a
    # variable an extension could apply to.
    "static_class_receiver": Fixture(
        files={
            "E.cs": (
                "public static class Conv {\n"
                "  public static int ToInt32(object o) { return 0; } // D1\n"
                "}\n"
                "public static class SExt {\n"
                "  public static int ToInt32(this string s) { return 0; } // D2\n"
                "}\n"
            ),
            "U.cs": (
                "public class U {\n"
                "  public int A(object o) { return Conv.ToInt32(o); } // B1\n"
                "  public int B(string s) { return s.ToInt32(); } // E1\n"
                "}\n"
            ),
        },
        expect={"// E1": ("E.cs", "// D2"), "// B1": ("E.cs", "// D1")},
        forbid_pairs={"// B1": ("E.cs", "// D2")},
        note="B1 is a correct baseline static call; it must never be re-bound to the extension",
    ),
    # A chained receiver with no type: the untyped rung's name proofs are the
    # whole evidence. Here a regular method elsewhere spells the name, so the
    # conservative rule refuses, and the call stays unbound (documented miss).
    "untyped_regular_name": Fixture(
        files={
            "Cache.cs": "public class Cache {\n  public void Flush() { } // D1\n}\n",
            "E.cs": (
                "public static class Ext {\n"
                "  public static void Flush(this string s) { } // D2\n"
                "}\n"
            ),
            "U.cs": (
                "public class U {\n"
                "  public void A(long v) { v.ToString().Flush(); } // F1\n"
                "}\n"
            ),
        },
        forbid=["// F1"],
        note="the regular-name guard refuses this binding on purpose",
    ),
    # The #1609 regression the fail-closed guard prevents: a member call on a type
    # that declares no such member must not bind to an unrelated same-named method.
    "god_node_control": Fixture(
        files={
            "Cache.cs": "public class Cache {\n  public void Save() { } // D1\n}\n",
            "Server.cs": "public class Server { }\n",
            "Use.cs": (
                "public class U {\n"
                "  public void Run(Server s) { s.Save(); } // F1\n"
                "}\n"
            ),
        },
        forbid=["// F1"],
        note="only the bare-name control may bind this; it is why the guard exists",
    ),
}

# The production deny-list, so the A/B record cannot drift from the rule the
# fix actually applies.
from graphify.extract import _CSHARP_BCL_MEMBER_NAMES as BCL_MEMBER_NAMES  # noqa: E402


# --------------------------------------------------------------------------- #
# strategy configs
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class Strategy:
    id: str
    scope: bool = False
    assignable: bool = False
    poison: bool = False
    # None: refuse out-of-corpus receivers; "naive": allow; "guarded": deny-list
    # + no same-named regular method in the corpus.
    roc: str | None = None
    untyped: bool = False
    data: str = "metadata"  # "metadata" | "signature"
    bare_name: bool = False


STRATEGIES: dict[str, Strategy] = {
    s.id: s
    for s in (
        Strategy("S0_baseline"),
        Strategy("S1_typed_exact"),
        Strategy("S2_typed_exact_scope", scope=True),
        Strategy("S3_typed_assignable", scope=True, assignable=True, poison=True),
        Strategy("S4_roc_naive", scope=True, assignable=True, poison=True, roc="naive"),
        Strategy("S5_roc_guarded", scope=True, assignable=True, poison=True, roc="guarded"),
        Strategy("S6_reporter_patch", roc="guarded", untyped=True),
        Strategy("S7_all_rungs", scope=True, assignable=True, poison=True,
                 roc="guarded", untyped=True),
        Strategy("S8_bare_name_control", bare_name=True),
        Strategy("S9_signature_parse", scope=True, assignable=True, poison=True,
                 data="signature"),
    )
}

STRATEGY_IDS = list(STRATEGIES)


# --------------------------------------------------------------------------- #
# index + engine
# --------------------------------------------------------------------------- #


def _key(label: str) -> str:
    return re.sub(r"\W+", "", str(label))


def _site(node: dict) -> tuple[str, str]:
    return str(node.get("source_file") or ""), str(node.get("source_location") or "")


@dataclass
class ExtDecl:
    method_id: str
    name_key: str
    extended: str
    extended_arity: int
    is_type_param: bool
    arity: int
    low: int
    variadic: bool
    namespace: str


def _signature_extended_type(meta: dict) -> tuple[str, int, bool]:
    """Derive the extended type from ``metadata.signature`` (S9's data source).

    Returns ``(simple_name, generic_arity, is_unknown)``. A ``this T`` where T is
    a method type parameter is indistinguishable from a class named T here: the
    signature only carries the parameter *text*, not the declaration's type-param
    names, so S9 has to guess (it guesses "not a type param").
    """
    sig = str(meta.get("signature") or "")
    if ":" in sig.split(",", 1)[0]:
        sig = sig.split(":", 1)[1]
    depth = 0
    first = ""
    for ch in sig:
        if ch == "<":
            depth += 1
        elif ch == ">":
            depth -= 1
        elif ch == "," and depth == 0:
            break
        first += ch
    first = first.strip()
    if not first.startswith("this "):
        return "", 0, True
    type_text = first[len("this "):].strip()
    if not type_text:
        return "", 0, True
    arity = 0
    depth = 0
    for ch in type_text:
        if ch == "<":
            if depth == 0:
                arity = 1
            elif depth == 1 and ch == ",":
                arity += 1
            depth += 1
        elif ch == ">":
            depth -= 1
    base = type_text.split("<", 1)[0]
    simple = base.rsplit(".", 1)[-1]
    return simple, arity, False


def _build_decls(nodes: list[dict], strategy: Strategy) -> list[ExtDecl]:
    decls: list[ExtDecl] = []
    for n in nodes:
        meta = n.get("metadata") or {}
        if not isinstance(meta, dict):
            continue
        if not n.get("source_file"):
            continue
        if strategy.data == "metadata":
            if not meta.get("extension"):
                continue
            extended = str(meta.get("extended_type") or "")
            ext_arity = int(meta.get("extended_type_arity") or 0)
            is_tp = bool(meta.get("extended_is_type_param"))
        else:
            extended, ext_arity, unknown = _signature_extended_type(meta)
            if unknown:
                continue
            is_tp = False
        arity = meta.get("arity")
        if not isinstance(arity, int):
            continue
        low = meta.get("min_arity", arity)
        decls.append(ExtDecl(
            method_id=n["id"],
            name_key=_key(n.get("label", "")),
            extended=extended,
            extended_arity=ext_arity,
            is_type_param=is_tp,
            arity=arity,
            low=low if isinstance(low, int) else arity,
            variadic=bool(meta.get("variadic")),
            namespace=str(meta.get("namespace") or ""),
        ))
    return decls


class _Graph:
    """The graph facts every strategy is allowed to use."""

    def __init__(self, nodes: list[dict], edges: list[dict]) -> None:
        self.nodes = nodes
        self.node_by_id = {n["id"]: n for n in nodes if n.get("id")}
        contained = {e.get("target") for e in edges if e.get("relation") == "contains"}
        self.is_type = {
            n["id"] for n in nodes
            if n.get("id") in contained and n.get("source_file") and n.get("id") in self.node_by_id
        }
        self.type_index: dict[str, list[str]] = {}
        for nid in self.is_type:
            self.type_index.setdefault(_key(self.node_by_id[nid].get("label", "")), []).append(nid)

        self.owner: dict[str, str] = {}
        self.methods_by_owner: dict[tuple[str, str], list[str]] = {}
        for e in edges:
            if e.get("relation") != "method":
                continue
            owner, mid = e.get("source"), e.get("target")
            mn = self.node_by_id.get(mid)
            if not owner or mn is None:
                continue
            self.owner.setdefault(mid, owner)
            self.methods_by_owner.setdefault((owner, _key(mn.get("label", ""))), []).append(mid)

        self.supers: dict[str, set[str]] = {}
        self.supers_unresolved: dict[str, bool] = {}
        for e in edges:
            if e.get("relation") not in ("inherits", "implements"):
                continue
            src, tgt = e.get("source"), e.get("target")
            if not src or not tgt:
                continue
            self.supers.setdefault(src, set()).add(tgt)

        # "a regular method in the corpus has this name" — extensions excluded,
        # since they are the thing being resolved.
        ext_ids = {
            n["id"] for n in nodes
            if isinstance(n.get("metadata"), dict) and n["metadata"].get("extension")
        }
        self.methods_by_name: dict[str, list[str]] = {}
        for (owner, key), mids in self.methods_by_owner.items():
            self.methods_by_name.setdefault(key, []).extend(mids)

        self.regular_method_names = {
            _key(self.node_by_id[mid].get("label", ""))
            for mids in self.methods_by_owner.values() for mid in mids
            if mid not in ext_ids
        }

    def type_closure(self, nid: str) -> tuple[set[str], bool]:
        """(self + supertypes, unresolved?) — a source-less super is a stub."""
        out: set[str] = set()
        unresolved = False
        stack = [nid]
        while stack:
            cur = stack.pop()
            if cur in out:
                continue
            out.add(cur)
            for sup in self.supers.get(cur, ()):
                sn = self.node_by_id.get(sup)
                if sn is None or not sn.get("source_file"):
                    unresolved = True
                    continue
                stack.append(sup)
        return out, unresolved

    def has_instance_member(self, nid: str, name_key: str) -> bool:
        closure, _ = self.type_closure(nid)
        return any((owner, name_key) in self.methods_by_owner for owner in closure)

    def resolve_type(self, name: str | None, caller_node: dict | None,
                     src_file: str, resolver: CsharpNameResolver) -> tuple[str | None, bool]:
        if not name:
            return None, False
        if caller_node is not None:
            nid, decisive = resolver.resolve_type_name(name, caller_node, src_file)
            if nid:
                return nid, True
            if decisive:
                return None, True
        hits = self.type_index.get(_key(name), [])
        return (hits[0], True) if len(hits) == 1 else (None, False)


def apply_strategy(
    strategy: Strategy,
    per_file: list[dict],
    nodes: list[dict],
    edges: list[dict],
) -> list[dict]:
    """Run one candidate over the extraction and return the edges it adds."""
    if strategy.id.startswith("S0"):
        return []

    g = _Graph(nodes, edges)
    resolver = CsharpNameResolver(nodes, edges)
    decls = _build_decls(nodes, strategy)
    existing = {(e.get("source"), e.get("target")) for e in edges}
    added: list[dict] = []

    for result in per_file:
        for rc in result.get("raw_calls") or []:
            if rc.get("lang") != "csharp" or not rc.get("is_member_call"):
                continue
            caller = rc.get("caller_nid")
            callee = rc.get("callee")
            if not caller or not callee or caller not in g.node_by_id:
                continue
            name_key = _key(callee)
            argc = rc.get("arity")
            if not isinstance(argc, int):
                continue
            src_file = str(rc.get("source_file") or "")
            caller_node = g.node_by_id.get(caller)

            recv_type = rc.get("receiver_type")
            chain = rc.get("receiver_chain") or []
            receiver = rc.get("receiver")
            if receiver == "base":
                continue
            if not recv_type and chain:
                recv_type = chain[0]
            if not recv_type and receiver == "this":
                owner = g.owner.get(caller)
                recv_type = (g.node_by_id.get(owner) or {}).get("label") if owner else None

            recv_nid: str | None = None
            roc = False  # receiver type is not declared in this corpus
            if recv_type:
                recv_nid, decisive = g.resolve_type(recv_type, caller_node, src_file, resolver)
                if recv_nid is None:
                    if decisive:
                        # The receiver's own type is ambiguous at this call site:
                        # nothing about the call is provable, so bail rather than
                        # treat it as an out-of-corpus receiver.
                        continue
                    roc = True
                    # A capitalized receiver that is not a typed local is a type
                    # name (`Convert.ToInt32`) — never a value an extension can
                    # apply to.
                    if receiver and not rc.get("receiver_type") and str(receiver)[:1].isupper():
                        continue

            # Instance members always win over extensions.
            if recv_nid and g.has_instance_member(recv_nid, name_key):
                continue
            # An out-of-corpus base may declare the member; cannot prove anything.
            if strategy.poison and recv_nid:
                _, unresolved = g.type_closure(recv_nid)
                if unresolved:
                    continue

            if strategy.bare_name:
                # Control: the pre-#1609 behaviour — resolve a member call by the
                # bare method name, ignoring the receiver entirely. Restricted to
                # a corpus-wide unique name, which is exactly the fallback the
                # god-node guard removed.
                by_name = g.methods_by_name.get(name_key, [])
                if len(by_name) != 1:
                    continue
                target = by_name[0]
                if (caller, target) in existing:
                    continue
                existing.add((caller, target))
                added.append(_edge_id(caller, target, rc, 0.55))
                continue

            candidates = [d for d in decls if d.name_key == name_key]
            if not candidates:
                continue

            # Scope: walk the enclosing namespace declarations outward, and at
            # each level consider (a) that namespace itself and (b) the usings
            # declared at that level. The first level with candidates wins; more
            # than one candidate at that level is ambiguous (dropped later).
            if strategy.scope:
                levels = resolver.visible_namespaces(caller_node, src_file)
                for visible in levels:
                    at_level = [d for d in candidates if d.namespace in visible]
                    if at_level:
                        candidates = at_level
                        break
                else:
                    candidates = []
                if not candidates:
                    continue

            wildcards = [d for d in candidates
                         if d.is_type_param or d.extended in ("object", "dynamic")]
            if recv_type:
                if strategy.assignable and recv_nid is not None:
                    closure, _ = g.type_closure(recv_nid)
                    names = {_key(g.node_by_id[c].get("label", "")) for c in closure}
                    exact = [d for d in candidates
                             if not d.is_type_param and _key(d.extended) == _key(recv_type)]
                    parents = [d for d in candidates
                               if not d.is_type_param and _key(d.extended) in names
                               and _key(d.extended) != _key(recv_type)]
                    applicable = exact or parents
                else:
                    applicable = [d for d in candidates
                                  if not d.is_type_param
                                  and _key(d.extended) == _key(recv_type)]
                if not applicable:
                    applicable = wildcards
                if not applicable:
                    continue
                if roc:
                    if strategy.roc is None:
                        continue
                    if strategy.roc == "guarded":
                        if name_key in g.regular_method_names:
                            continue
                        if callee in BCL_MEMBER_NAMES:
                            continue
                    applicable = [d for d in applicable if True]
            else:
                # Untyped receiver (a chain): only the explicit untyped rung.
                if not strategy.untyped:
                    continue
                if name_key in g.regular_method_names:
                    continue
                if callee in BCL_MEMBER_NAMES:
                    continue
                applicable = candidates

            fits = [d for d in applicable
                    if d.low <= argc and (argc <= d.arity - 1 or d.variadic)]
            if len(fits) != 1:
                continue
            cand = fits[0]
            if (caller, cand.method_id) in existing:
                continue
            existing.add((caller, cand.method_id))
            score = 0.85 if not (roc or recv_type is None) else 0.75
            added.append(_edge(caller, cand, rc, score))
    return added


def _edge(caller: str, decl: ExtDecl, rc: dict, score: float) -> dict:
    return _edge_id(caller, decl.method_id, rc, score)


def _edge_id(caller: str, target: str, rc: dict, score: float) -> dict:
    return {
        "source": caller,
        "target": target,
        "relation": "calls",
        "context": "call",
        "confidence": "INFERRED",
        "confidence_score": score,
        "source_file": rc.get("source_file", ""),
        "source_location": rc.get("source_location"),
        "weight": 1.0,
    }


# --------------------------------------------------------------------------- #
# scoring helpers used by the tests
# --------------------------------------------------------------------------- #


def line_of(text: str, marker: str) -> str:
    """The ``L<n>`` location of the single line carrying ``marker``."""
    hits = [i for i, line in enumerate(text.splitlines(), start=1) if marker in line]
    assert len(hits) == 1, (marker, hits)
    return f"L{hits[0]}"


def marker_at_line(text: str, location: str) -> str:
    n = int(str(location).lstrip("L"))
    line = text.splitlines()[n - 1]
    for token in re.findall(r"//\s*[A-Z]\d+", line):
        return token
    raise AssertionError(f"no marker on {location}: {line!r}")


# --------------------------------------------------------------------------- #
# corpus extraction + scoring
# --------------------------------------------------------------------------- #

_CORPORA: dict[tuple[str, bool], dict] = {}


def corpus(name: str, *, production_extension: bool = True) -> dict:
    """Extract a fixture once.

    ``production_extension=False`` removes the production extension resolver from
    the registry for the extraction, so the candidate strategies are measured on
    the same nominal graph no matter which stage of the fix has landed. It is the
    only way for the decision record to stay comparable across commits.
    """
    key = (name, production_extension)
    if key in _CORPORA:
        return _CORPORA[key]
    fixture = FIXTURES[name]
    tmp = Path(tempfile.mkdtemp(prefix=f"csharp-ext-{name}-"))
    for filename, body in fixture.files.items():
        path = tmp / filename
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(body)
    saved = resolver_registry._REGISTRY[:]
    if not production_extension:
        resolver_registry._REGISTRY[:] = [
            r for r in saved if r.name != "csharp_extension_calls"
        ]
    old = os.getcwd()
    try:
        os.chdir(tmp)
        paths = [Path(f) for f in fixture.files]
        result = extract(paths, cache_root=Path(tempfile.mkdtemp()))
        per_file = [extract_csharp(Path(f)) for f in fixture.files]
    finally:
        resolver_registry._REGISTRY[:] = saved
        os.chdir(old)
    payload = {
        "fixture": fixture,
        "nodes": result["nodes"],
        "edges": result["edges"],
        "per_file": per_file,
    }
    _CORPORA[key] = payload
    return payload


def _bindings_from_edges(fixture: Fixture, nodes: list[dict], edges: list[dict]):
    """Map `calls` edges to ``(call_marker, (target_file, target_marker))``.

    Edges whose source or target line has no marker are returned as phantoms: a
    strategy must never invent a call site, and a fixture must not silently
    accept an edge it did not describe.
    """
    by_id = {n["id"]: n for n in nodes}
    bindings: set[tuple[str, tuple[str, str]]] = set()
    phantoms: list[dict] = []
    for edge in edges:
        if edge.get("relation") != "calls":
            continue
        src_file = str(edge.get("source_file") or "")
        if src_file not in fixture.files:
            phantoms.append(edge)
            continue
        try:
            call_marker = marker_at_line(fixture.files[src_file], str(edge.get("source_location") or ""))
        except AssertionError:
            phantoms.append(edge)
            continue
        target = by_id.get(edge.get("target"))
        if target is None or str(target.get("source_file") or "") not in fixture.files:
            phantoms.append(edge)
            continue
        try:
            t_marker = marker_at_line(
                fixture.files[str(target["source_file"])],
                str(target.get("source_location") or ""),
            )
        except AssertionError:
            phantoms.append(edge)
            continue
        bindings.add((call_marker, (str(target["source_file"]), t_marker)))
    return bindings, phantoms


def expected_pairs(name: str) -> set[tuple[str, tuple[str, str]]]:
    return {(marker, target) for marker, target in FIXTURES[name].expect.items()}


def strategy_bindings(name: str, strategy_id: str) -> dict:
    """Final graph of one candidate strategy + its violations."""
    data = corpus(name, production_extension=False)
    fixture: Fixture = data["fixture"]
    added = apply_strategy(STRATEGIES[strategy_id], data["per_file"], data["nodes"], data["edges"])
    bindings, phantoms = _bindings_from_edges(fixture, data["nodes"], list(data["edges"]) + list(added))
    violations: set = set(phantoms)
    for call_marker, target in bindings:
        if call_marker in fixture.forbid:
            violations.add((call_marker, target))
        if fixture.forbid_pairs.get(call_marker) == target:
            violations.add((call_marker, target))
    return {"bindings": bindings, "added": added, "phantoms": phantoms, "violations": violations}


def graph_bindings(name: str) -> dict:
    """Final graph as the production pipeline builds it."""
    data = corpus(name)
    fixture: Fixture = data["fixture"]
    bindings, phantoms = _bindings_from_edges(fixture, data["nodes"], data["edges"])
    violations: set = set(phantoms)
    for call_marker, target in bindings:
        if call_marker in fixture.forbid:
            violations.add((call_marker, target))
        if fixture.forbid_pairs.get(call_marker) == target:
            violations.add((call_marker, target))
    return {"bindings": bindings, "phantoms": phantoms, "violations": violations}