From 7f476e1447828037ac7766fd35f1f473016cb385 Mon Sep 17 00:00:00 2001 From: durmazoguzhan <81313884+durmazoguzhan@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:17:23 +0100 Subject: [PATCH] fix(csharp): emit calls edges for object creation expressions (#2997) A `new Foo()` object-creation expression emitted no edge, so constructor usage was invisible in the graph. Add object_creation_expression to the C# call types and emit a calls edge to the constructed type (member/qualified/generic forms), resolving a qualified construction against declared namespaces without binding to an in-file placeholder. Built-in and out-of-corpus types are not fabricated, and repeated constructions dedup to one edge. --- graphify/extract.py | 85 ++++++++- graphify/extractors/engine.py | 34 ++++ tests/test_csharp_object_creation.py | 250 +++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 1 deletion(-) create mode 100644 tests/test_csharp_object_creation.py diff --git a/graphify/extract.py b/graphify/extract.py index 994010c3..36117582 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -929,7 +929,9 @@ _CSHARP_CONFIG = LanguageConfig( }), function_types=frozenset({"method_declaration"}), import_types=frozenset({"using_directive"}), - call_types=frozenset({"invocation_expression"}), + # `object_creation_expression` joins the invocation node so `new Foo(...)` + # links the constructing method to Foo, the way Java has since #1373. + call_types=frozenset({"invocation_expression", "object_creation_expression"}), call_function_field="function", call_accessor_node_types=frozenset({"member_access_expression"}), call_accessor_field="name", @@ -3856,6 +3858,79 @@ def _resolve_kotlin_import_targets( e["target"] = candidates[0] +def _resolve_csharp_qualified_calls( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Resolve C# constructions that name their namespace (#2997). + + `new Infra.Data.Cache()` reaches the shared pass as the bare name `Cache`, + so a second `Cache` in another namespace makes it ambiguous and it gets no + edge, even though the source says which one it means. The reference paths + (field, property, parameter, return) already honour the qualifier through + `CsharpNameResolver`; this is the construction twin, built the way + `_resolve_kotlin_qualified_calls` handles the same shape in Kotlin. + + The prefix must equal a declared namespace exactly. A partially qualified + `new Data.Cache()` under `using Infra;` stays unresolved rather than + guessing at the using directives in scope. Exactly one candidate produces an + edge; zero or several leave the call alone. The pass is additive: an + ambiguous bare name never had an edge to overwrite. + """ + raw = [ + rc + for result in per_file + for rc in result.get("raw_calls", []) + if rc.get("lang") == "csharp" and rc.get("qualified_prefix") + and rc.get("callee") and rc.get("caller_nid") + ] + if not raw: + return + + # (namespace, type name) -> nids, over sourced type declarations only, so a + # sourceless stub minted for a dangling reference cannot win the match. + by_namespace: dict[tuple[str, str], list[str]] = {} + for n in all_nodes: + if not n.get("_callable_class") or not n.get("source_file"): + continue + namespace = str((n.get("metadata") or {}).get("namespace") or "") + label = str(n.get("label", "")).strip("()") + if namespace and label: + by_namespace.setdefault((namespace, label), []).append(n["id"]) + if not by_namespace: + return + + # Scoped to `calls`: a method that both takes a type as a parameter and + # constructs it already has a `references` edge to it, and that edge says + # nothing about whether the construction was resolved. + existing_pairs = { + (e.get("source"), e.get("target")) + for e in all_edges + if e.get("relation") == "calls" + } + for rc in raw: + candidates = by_namespace.get((rc["qualified_prefix"], rc["callee"]), []) + if len(candidates) != 1: + continue + caller = rc["caller_nid"] + tgt = candidates[0] + if tgt == caller or (caller, tgt) in existing_pairs: + continue + existing_pairs.add((caller, tgt)) + all_edges.append({ + "source": caller, + "target": tgt, + "relation": "calls", + "context": "call", + "confidence": "EXTRACTED", # the namespace is written verbatim in source + "confidence_score": 1.0, + "source_file": rc.get("source_file", ""), + "source_location": rc.get("source_location"), + "weight": 1.0, + }) + + def _resolve_kotlin_qualified_calls( per_file: list[dict], all_nodes: list[dict], @@ -4042,6 +4117,14 @@ register_language_resolver( "kotlin_qualified_calls", frozenset({".kt", ".kts"}), _resolve_kotlin_qualified_calls ) ) +# C# qualified construction (#2997): `new A.B.Cache()` arrives as the bare name, +# so a colliding `Cache` elsewhere makes it ambiguous. Runs in the tail registry +# beside csharp_member_calls and matches the prefix against declared namespaces. +register_language_resolver( + LanguageResolver( + "csharp_qualified_calls", frozenset({".cs"}), _resolve_csharp_qualified_calls + ) +) # Inline markdown link: [text](target "optional title"). The negative lookbehind diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index 859257d0..bbef99ea 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -4979,6 +4979,7 @@ def _extract_generic( swift_receiver: str | None = None member_receiver: str | None = None kotlin_qualified_prefix: str | None = None + csharp_qualified_prefix: str | None = None # Special handling per language if config.ts_module == "tree_sitter_swift": @@ -5043,6 +5044,27 @@ def _extract_generic( if child.type == "identifier": callee_name = _read_text(child, source) break + elif config.ts_module == "tree_sitter_c_sharp" and node.type == "object_creation_expression": + # `new Foo(...)` keeps the constructed type in the `type` field, so + # the invocation path below never sees it and a type a method only + # constructs stays unlinked — the C# twin of the Java gap in #1373. + # Types reached solely through a method body are exactly the ones + # this misses: message classes handed straight to a bus + # (`Send(new OrderPlaced { ... })`) and locally built collaborators. + # `_read_csharp_type_name` drops the generic arguments and the + # namespace qualifier, so `new A.B.Cache()` names `Cache`. + # Target-typed `new()` parses as `implicit_object_creation_expression` + # and stays out of `call_types`: naming it needs the declared type of + # whatever it is being assigned to, which is a separate problem. + # A qualifier written in source is kept for + # `_resolve_csharp_qualified_calls`, so `new A.B.Cache()` can still + # pick one of several `Cache` classes instead of hitting the + # ambiguity guard on the bare name. + type_info = _read_csharp_type_name(node.child_by_field_name("type"), source) + if type_info and type_info[0]: + callee_name = type_info[0] + if type_info[1] and type_info[2]: + csharp_qualified_prefix = type_info[2] elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression": # C#: the invoked function is the `function` field. A member call # `recv.Method(...)` is a member_access_expression (receiver in its @@ -5338,6 +5360,16 @@ def _extract_generic( tgt_nid = None else: tgt_nid = label_to_nid.get(callee_name) + # A qualified `new A.B.Foo()` whose bare name matches only a + # sourceless stub in this file would bind the call to the stub + # and never reach _resolve_csharp_qualified_calls, the one pass + # that can honour the namespace. Defer so it can. + if ( + csharp_qualified_prefix + and tgt_nid + and not nid_to_sf.get(tgt_nid) + ): + tgt_nid = None if tgt_nid and tgt_nid != caller_nid: pair = (caller_nid, tgt_nid) if pair not in seen_call_pairs: @@ -5383,6 +5415,8 @@ def _extract_generic( # class fields/properties are the base scope. if config.ts_module == "tree_sitter_c_sharp": rc_entry["lang"] = "csharp" + if csharp_qualified_prefix: + rc_entry["qualified_prefix"] = csharp_qualified_prefix receiver_type = _csharp_scoped_receiver_type( receiver_types, member_receiver, node.start_byte ) diff --git a/tests/test_csharp_object_creation.py b/tests/test_csharp_object_creation.py new file mode 100644 index 00000000..af33c6c3 --- /dev/null +++ b/tests/test_csharp_object_creation.py @@ -0,0 +1,250 @@ +"""C# `new Foo(...)` links the constructing method to Foo. + +The C# config only listed `invocation_expression` in `call_types`, so an +`object_creation_expression` was never dispatched and a type a method merely +constructs got no edge at all. Java has taken `new Foo(...)` as a call since +#1373; C# had not caught up. The types this loses are the ones handed straight +to something else — `Send(new OrderPlaced { ... })` on a message bus, a locally +built collaborator — so publishers looked unconnected while the class sat right +there in the graph. + +Only the explicit form is claimed. Target-typed `new()` parses as +`implicit_object_creation_expression` and needs the assignment target's declared +type to name anything, so it stays out. +""" +from __future__ import annotations + +import os +import tempfile +from pathlib import Path + +from graphify.extract import extract + + +def _extract(tmp_path, files: dict[str, str]): + for name, body in files.items(): + p = tmp_path / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + old = os.getcwd() + try: + os.chdir(tmp_path) + r = extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp())) + finally: + os.chdir(old) + calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"} + return calls, r + + +def _find(r, label, id_contains): + return next(n["id"] for n in r["nodes"] + if n["label"] == label and id_contains in n["id"]) + + +def test_explicitly_declared_local_links_to_constructed_type(tmp_path): + calls, r = _extract(tmp_path, {"S.cs": ( + "public class Worker { }\n" + "public class Caller {\n" + " public void Go() { Worker w = new Worker(); }\n" + "}\n" + )}) + assert (_find(r, ".Go()", "go"), _find(r, "Worker", "worker")) in calls + + +def test_var_local_links_to_constructed_type(tmp_path): + calls, r = _extract(tmp_path, {"S.cs": ( + "public class Worker { }\n" + "public class Caller {\n" + " public void Go() { var w = new Worker(); }\n" + "}\n" + )}) + assert (_find(r, ".Go()", "go"), _find(r, "Worker", "worker")) in calls + + +def test_argument_position_links_to_constructed_type(tmp_path): + # The publish shape: the message type never appears in a declared position. + calls, r = _extract(tmp_path, {"S.cs": ( + "public class OrderPlaced { }\n" + "public class Publisher {\n" + " public void Publish() { Send(new OrderPlaced()); }\n" + " private void Send(object payload) { }\n" + "}\n" + )}) + assert (_find(r, ".Publish()", "publish"), _find(r, "OrderPlaced", "orderplaced")) in calls + + +def test_object_initializer_without_parens_links(tmp_path): + calls, r = _extract(tmp_path, {"S.cs": ( + "public class OrderPlaced { public long Id { get; set; } }\n" + "public class Publisher {\n" + " public void Publish(long id) { Send(new OrderPlaced { Id = id }); }\n" + " private void Send(object payload) { }\n" + "}\n" + )}) + assert (_find(r, ".Publish()", "publish"), _find(r, "OrderPlaced", "orderplaced")) in calls + + +def test_generic_construction_names_the_outer_type(tmp_path): + calls, r = _extract(tmp_path, {"S.cs": ( + "public class Cache { }\n" + "public class Caller {\n" + " public void Go() { var c = new Cache(); }\n" + "}\n" + )}) + assert (_find(r, ".Go()", "go"), _find(r, "Cache", "cache")) in calls + + +def test_qualified_construction_names_the_last_segment(tmp_path): + calls, r = _extract(tmp_path, { + "Store.cs": ( + "namespace Infra.Data;\n" + "public class Cache { }\n" + ), + "Caller.cs": ( + "public class Caller {\n" + " public void Go() { var c = new Infra.Data.Cache(); }\n" + "}\n" + ), + }) + assert (_find(r, ".Go()", "go"), _find(r, "Cache", "cache")) in calls + + +def test_target_typed_new_produces_no_edge(tmp_path): + # `new()` carries no type node; guessing one needs the target's declared type. + calls, r = _extract(tmp_path, {"S.cs": ( + "public class Worker { }\n" + "public class Caller {\n" + " public Worker Build() { Worker w = new(); return w; }\n" + "}\n" + )}) + worker = _find(r, "Worker", "worker") + assert not any(target == worker for _, target in calls) + + +def test_cross_file_publisher_reaches_the_message_class(tmp_path): + calls, r = _extract(tmp_path, { + "Events/OrderPlaced.cs": ( + "namespace Demo.Events;\n" + "public class OrderPlaced { public long Id { get; set; } }\n" + ), + "Publisher.cs": ( + "using Demo.Events;\n" + "public class Publisher {\n" + " public void Publish(long id) { Send(new OrderPlaced { Id = id }); }\n" + " private void Send(object payload) { }\n" + "}\n" + ), + }) + assert (_find(r, ".Publish()", "publish"), _find(r, "OrderPlaced", "orderplaced")) in calls + + +def test_ambiguous_type_name_produces_no_edge(tmp_path): + # Two classes share a name, so the construction site cannot be pinned to one + # of them. No edge beats a coin flip here — #437 is about exactly the kind of + # false edge a name-only guess creates. + calls, r = _extract(tmp_path, { + "Left.cs": "namespace Left;\npublic class Cache { }\n", + "Right.cs": "namespace Right;\npublic class Cache { }\n", + "Caller.cs": ( + "using Right;\n" + "public class Caller {\n" + " public void Go() { var c = new Cache(); }\n" + "}\n" + ), + }) + caches = {n["id"] for n in r["nodes"] if n["label"] == "Cache"} + assert len(caches) == 2 + assert not any(target in caches for _, target in calls) + + +_COLLIDING_CACHES = { + "Left.cs": "namespace Infra.Data;\npublic class Cache { }\n", + "Right.cs": "namespace Other;\npublic class Cache { }\n", +} + + +def test_qualified_construction_picks_the_named_namespace(tmp_path): + # The bare name is ambiguous, but the source says which Cache it means. + calls, r = _extract(tmp_path, {**_COLLIDING_CACHES, "Caller.cs": ( + "public class Caller {\n" + " public void Go() { var c = new Infra.Data.Cache(); }\n" + "}\n" + )}) + wanted = _find(r, "Cache", "left") + other = _find(r, "Cache", "right") + go = _find(r, ".Go()", "go") + assert (go, wanted) in calls + assert (go, other) not in calls + + +def test_partially_qualified_construction_stays_unresolved(tmp_path): + # `new Data.Cache()` under `using Infra;` would need the using directives in + # scope to become a namespace. Resolving it by suffix would be a guess. + calls, r = _extract(tmp_path, {**_COLLIDING_CACHES, "Caller.cs": ( + "using Infra;\n" + "public class Caller {\n" + " public void Go() { var c = new Data.Cache(); }\n" + "}\n" + )}) + caches = {n["id"] for n in r["nodes"] if n["label"] == "Cache"} + assert not any(target in caches for _, target in calls) + + +def test_qualified_construction_with_two_candidates_in_one_namespace(tmp_path): + # Same namespace declared in two files, both holding a Cache: the qualifier + # cannot separate them either, so the guard still applies. + calls, r = _extract(tmp_path, { + "One.cs": "namespace Infra.Data;\npublic class Cache { }\n", + "Two.cs": "namespace Infra.Data;\npublic class Cache { }\n", + "Caller.cs": ( + "public class Caller {\n" + " public void Go() { var c = new Infra.Data.Cache(); }\n" + "}\n" + ), + }) + caches = {n["id"] for n in r["nodes"] if n["label"] == "Cache"} + assert not any(target in caches for _, target in calls) + + +def test_receiver_typed_member_call_still_resolves(tmp_path): + # Guard for #1609: adding a node type to call_types must not disturb the + # invocation path that binds a call to its receiver's declared type. + calls, r = _extract(tmp_path, {"S.cs": ( + "public class Server { public bool Save() => true; }\n" + "public class Cache { public bool Save() => false; }\n" + "public class Repo {\n" + " private Server _server = new Server();\n" + " public bool Commit() { return _server.Save(); }\n" + "}\n" + )}) + commit = _find(r, ".Commit()", "commit") + assert (commit, _find(r, ".Save()", "server")) in calls + assert (commit, _find(r, ".Save()", "cache")) not in calls + +def test_qualified_construction_resolves_when_the_method_also_declares_the_type(tmp_path): + # The method takes the type as a parameter and constructs it. The declared + # position mints a bare-name stub in this file, and binding the construction + # to that stub would keep it away from the namespace resolver, so the call + # would land on a sourceless node instead of the class. + calls, r = _extract(tmp_path, {**_COLLIDING_CACHES, "Caller.cs": ( + "public class Caller {\n" + " public void Go(Infra.Data.Cache existing) { var c = new Infra.Data.Cache(); }\n" + "}\n" + )}) + go = _find(r, ".Go()", "go") + assert (go, _find(r, "Cache", "left")) in calls + sourceless = {n["id"] for n in r["nodes"] + if n["label"] == "Cache" and not n.get("source_file")} + assert not any(target in sourceless for _, target in calls) + + +def test_qualified_construction_of_a_foreign_type_makes_no_stub_edge(tmp_path): + # `new System.Text.StringBuilder()` has no declaration in the corpus. No edge + # beats an edge into a sourceless placeholder that stands for nothing. + calls, r = _extract(tmp_path, {"Caller.cs": ( + "public class Caller {\n" + " public void Go() { var b = new System.Text.StringBuilder(); }\n" + "}\n" + )}) + builders = {n["id"] for n in r["nodes"] if n["label"] == "StringBuilder"} + assert not any(target in builders for _, target in calls)