mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 01:06:36 +00:00
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:
committed by
safishamsi
parent
be5110a789
commit
7f476e1447
+84
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user