Files
graphify/tests/test_csharp_object_creation.py
durmazoguzhan 7f476e1447 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.
2026-08-24 13:17:23 +01:00

251 lines
9.7 KiB
Python

"""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)