diff --git a/graphify/extract.py b/graphify/extract.py index 76bcb9aa6..3dd97f731 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -10174,6 +10174,7 @@ def extract( ) _csharp_stub_resolver: CsharpNameResolver | None = None # built on first use _csharp_method_owner: dict[str, str] | None = None # method nid -> type nid, built on first use + _csharp_outer_type: dict[str, str] | None = None # nested type nid -> outer type label, built on first use for rc in all_raw_calls: if rc.get("_ambiguous_python_import"): continue @@ -10230,6 +10231,16 @@ def extract( # #4245) bound to the case instead of getting no edge/deferring to # raw_calls resolution that could reject it on its own grounds. candidates = [c for c in candidates if c not in _member_nids] + if rc.get("csharp_new"): + # Nor does `new X()` construct a method that shares X's name: + # without this every `new List()` in the corpus binds to a + # `List()` method as an INFERRED hub. + if _csharp_method_owner is None: + _csharp_method_owner = { + e["target"]: e["source"] for e in all_edges + if e.get("relation") == "method" + } + candidates = [c for c in candidates if c not in _csharp_method_owner] if not candidates: continue # A call rescued from an annotation stub (#3888): the caller's own file @@ -10377,13 +10388,31 @@ def extract( # tie-breakers (non-test preference, then path proximity). # Resolve only if exactly one candidate survives; otherwise # the #543/#1219 guard still holds and we skip. - tgt = disambiguate_ambiguous_candidates( - candidates, - {c: nid_to_source_file.get(c, "") for c in candidates}, - rc.get("source_file", ""), - ) - if tgt is None: - continue + # A qualified C# construction (`new Outer.Slot()`) names + # which Slot it means, and a proximity guess over the bare + # name ignores that: it takes the one Slot nested in a type + # called Outer, or no edge. A namespace qualifier matches no + # outer type and is left to _resolve_csharp_qualified_calls. + if rc.get("csharp_new") and rc.get("qualified_prefix"): + if _csharp_outer_type is None: + labels = {n["id"]: str(n.get("label", "")) for n in all_nodes} + _csharp_outer_type = { + e["target"]: labels.get(e["source"], "") + for e in all_edges if e.get("relation") == "contains" + } + outer = str(rc["qualified_prefix"]).rsplit(".", 1)[-1] + nested = [c for c in candidates if _csharp_outer_type.get(c) == outer] + if len(nested) != 1: + continue + tgt = nested[0] + else: + tgt = disambiguate_ambiguous_candidates( + candidates, + {c: nid_to_source_file.get(c, "") for c in candidates}, + rc.get("source_file", ""), + ) + if tgt is None: + continue has_import_evidence = False # A source-less candidate is an unresolved external stub, not a # project-defined callable. It can remain a target of type/reference diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index a14cf8f9f..db2c4fa59 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -8758,6 +8758,16 @@ def _extract_generic( and not nid_to_sf.get(tgt_nid) ): tgt_nid = None + # `new X()` constructs a type, never a method that shares X's + # name (`static Bounds Bounds(..) => new Bounds(..)` on a type + # outside the corpus). Defer so the cross-file pass can look + # for the type itself. + if ( + config.ts_module == "tree_sitter_c_sharp" + and node.type == "object_creation_expression" + and tgt_nid in method_owner + ): + tgt_nid = None # A name-only reference can leave a source-less stub behind when # the target is outside the scanned corpus (for example, # ``HTTPException`` imported from FastAPI). It is useful for the diff --git a/tests/test_csharp_object_creation.py b/tests/test_csharp_object_creation.py index dca55c9af..902e631aa 100644 --- a/tests/test_csharp_object_creation.py +++ b/tests/test_csharp_object_creation.py @@ -281,3 +281,93 @@ def test_qualified_construction_of_a_foreign_type_makes_no_stub_edge(tmp_path): )}) builders = {n["id"] for n in r["nodes"] if n["label"] == "StringBuilder"} assert not any(target in builders for _, target in calls) + + +# `new X()` constructs a type. A method that merely shares X's name is never its +# target, yet the call was resolved against every same-named definition: inside +# the method it became a self-loop (`static Bounds Bounds(..) => new Bounds(..)` +# on Unity's Bounds), and across files the bare-name pass bound every +# `new List()` in the corpus to a `List()` method as an INFERRED hub. + + +def test_construction_never_binds_to_a_same_named_method_cross_file(tmp_path): + calls, r = _extract(tmp_path, { + "Market.cs": ( + "namespace Demo { public class Market {" + " public static int List(int a) { return a; } } }\n" + ), + "Use.cs": ( + "namespace Demo { public class Use {" + " public void Run() { var xs = new List(); } } }\n" + ), + }) + run = _find(r, ".Run()", "run") + assert (run, _find(r, ".List()", "market")) not in calls + + +def test_construction_inside_a_same_named_method_is_no_self_loop(tmp_path): + calls, r = _extract(tmp_path, {"Box.cs": ( + "namespace Demo { public class Box {" + " static Bounds Bounds(int a) { return new Bounds(a); } } }\n" + )}) + bounds = _find(r, ".Bounds()", "box") + assert (bounds, bounds) not in calls + + +def test_construction_still_binds_to_the_type_beside_a_same_named_method(tmp_path): + calls, r = _extract(tmp_path, { + "Kit.cs": "namespace Demo { public class Piece { } }\n", + "Shop.cs": ( + "namespace Demo { public class Shop {" + " public static int Piece() { return 0; } } }\n" + ), + "Setup.cs": ( + "namespace Demo { public class Setup {" + " public void Link() { var p = new Piece(); } } }\n" + ), + }) + link = _find(r, ".Link()", "link") + assert (link, _find(r, "Piece", "kit")) in calls + assert (link, _find(r, ".Piece()", "shop")) not in calls + + +def test_qualified_nested_construction_follows_its_outer_type(tmp_path): + # `new QuickSlotsBar.Slot()` names the outer type. Two `Slot` classes make + # the bare name ambiguous, and the path-proximity tie-break picked the one + # next to the caller — the wrong Slot. With method candidates no longer + # muddying the tie, that guess would fire more often; a qualified + # construction now takes the Slot nested in QuickSlotsBar, or no edge. + calls, r = _extract(tmp_path, { + "UI/Bar.cs": ( + "namespace Game.UI { public class QuickSlotsBar {" + " public class Slot { public int kind; } } }\n" + ), + "Editor/Towers.cs": ( + "namespace Game.Tools { public class Towers {" + " class Slot { public string texture; } } }\n" + ), + "Editor/Hud.cs": ( + "using Game.UI;\nnamespace Game.Tools { public class Hud {" + " public void Ensure() { var s = new QuickSlotsBar.Slot { kind = 1 }; } } }\n" + ), + }) + ensure = _find(r, ".Ensure()", "ensure") + def slot_in(stem): + return next(n["id"] for n in r["nodes"] + if n["label"] == "Slot" and stem in str(n.get("source_file", ""))) + assert (ensure, slot_in("Towers")) not in calls + assert (ensure, slot_in("Bar")) in calls + + +def test_qualified_construction_with_an_unknown_outer_type_gives_no_edge(tmp_path): + calls, r = _extract(tmp_path, { + "UI/Bar.cs": "namespace Game.UI { public class Bar { public class Slot { } } }\n", + "Editor/Towers.cs": "namespace Game.Tools { public class Towers { class Slot { } } }\n", + "Editor/Hud.cs": ( + "namespace Game.Tools { public class Hud {" + " public void Ensure() { var s = new Missing.Slot(); } } }\n" + ), + }) + ensure = _find(r, ".Ensure()", "ensure") + slots = {n["id"] for n in r["nodes"] if n["label"] == "Slot"} + assert not any(t in slots for s, t in calls if s == ensure)