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.
This commit is contained in:
durmazoguzhan
2026-08-24 13:17:23 +01:00
committed by safishamsi
parent be5110a789
commit 7f476e1447
3 changed files with 368 additions and 1 deletions
+84 -1
View File
@@ -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
+34
View File
@@ -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<string>()` 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
)
+250
View File
@@ -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<T> { }\n"
"public class Caller {\n"
" public void Go() { var c = new Cache<string>(); }\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)