tests/csharp_extension_harness.py
Rama v8 · réplica de lectura del repositorio original.
"""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}