mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 16:09:26 +00:00
A C# member call whose receiver has no capturable name — a member chain
(`run.Sequence.Apply()`, `s.Items.Count()`), an invocation (`Make().Apply()`)
or a `new X(...)` — reached the per-file bare-name map, because the C# defer
rule required `member_receiver`. It then bound to the caller's own same-named
method as an EXTRACTED edge: a false self-loop (`a.look.LateUpdate()` inside
`LateUpdate()`), LINQ `Count()` bound to a local `Count()`, or a typed chain
landing on the wrong class instead of reaching `receiver_chain` resolution.
Every C# member call now defers. A typed chain still resolves through
`receiver_chain`; `new X(...).M()` (#1770) is typed from the construction so
it keeps its edge; anything untypable gets no edge (fail-closed).
Fixes #4418.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit d5544b125f)
283 lines
10 KiB
Python
283 lines
10 KiB
Python
"""C# calls through a member chain, element access, or a constructor (#4246).
|
|
|
|
`run.Sequence.Apply()`, `_configs[i].WriteIds()` and a call inside a
|
|
constructor body produced no calls edge when the callee lived in another file.
|
|
Each now resolves through the receiver's declared type; anything untypable
|
|
still produces no edge.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract
|
|
|
|
|
|
def _calls(tmp_path, files: dict[str, str]):
|
|
for name, body in files.items():
|
|
(tmp_path / name).write_text(body)
|
|
old = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
r = extract([Path(n) for n in files], cache_root=tmp_path / ".cache")
|
|
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"])
|
|
|
|
|
|
_MODEL = (
|
|
"namespace Demo\n{\n"
|
|
" public class Sequence { public void Apply() { } }\n"
|
|
" public class Decoy { public void Apply() { } }\n"
|
|
" public class Run { public Sequence Sequence; public Decoy[] Decoys; }\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
def _controller(body: str, extra: str = "") -> str:
|
|
return (
|
|
"namespace Demo\n{\n"
|
|
f" public class Controller {{ {extra} public void Tick(Run run) {{ {body} }} }}\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
def test_member_chain_on_parameter_field_resolves(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Controller.cs": _controller("run.Sequence.Apply();"),
|
|
})
|
|
tick = _find(r, ".Tick()", "controller")
|
|
assert (tick, _find(r, ".Apply()", "sequence")) in calls
|
|
assert (tick, _find(r, ".Apply()", "decoy")) not in calls
|
|
|
|
|
|
def test_member_chain_on_field_receiver_resolves(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Controller.cs": _controller("_run.Sequence.Apply();", "private Run _run;"),
|
|
})
|
|
tick = _find(r, ".Tick()", "controller")
|
|
assert (tick, _find(r, ".Apply()", "sequence")) in calls
|
|
|
|
|
|
def test_member_chain_with_unknown_field_gives_no_edge(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Controller.cs": _controller("run.Missing.Apply(); run.Decoys.Apply();"),
|
|
})
|
|
tick = _find(r, ".Tick()", "controller")
|
|
assert not {t for s, t in calls if s == tick}
|
|
|
|
|
|
def test_member_chain_with_untyped_root_gives_no_edge(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Controller.cs": _controller("var x = Make(); x.Sequence.Apply();"),
|
|
})
|
|
tick = _find(r, ".Tick()", "controller")
|
|
assert not {t for s, t in calls if s == tick}
|
|
|
|
|
|
_CONFIG = (
|
|
"namespace Demo\n{\n"
|
|
" public class Config { public void WriteIds() { } }\n"
|
|
" public class Other { public void WriteIds() { } }\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
def test_element_access_on_array_field_resolves_to_element_type(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Config.cs": _CONFIG,
|
|
"Anim.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public class Anim\n {\n"
|
|
" private Config[] _configs;\n"
|
|
" public void Save() { for (int i = 0; i < _configs.Length; i++)"
|
|
" if (_configs[i] != null) _configs[i].WriteIds(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
save = _find(r, ".Save()", "anim")
|
|
assert (save, _find(r, ".WriteIds()", "config_demo_config")) in calls
|
|
assert (save, _find(r, ".WriteIds()", "other")) not in calls
|
|
|
|
|
|
def test_element_access_on_list_local_resolves(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Config.cs": _CONFIG,
|
|
"Anim.cs": (
|
|
"using System.Collections.Generic;\n"
|
|
"namespace Demo\n{\n"
|
|
" public class Anim\n {\n"
|
|
" public void Save() { List<Config> items = Load(); items[0].WriteIds(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
save = _find(r, ".Save()", "anim")
|
|
assert (save, _find(r, ".WriteIds()", "config_demo_config")) in calls
|
|
assert (save, _find(r, ".WriteIds()", "other")) not in calls
|
|
|
|
|
|
def test_element_access_without_element_type_gives_no_edge(tmp_path):
|
|
# a non-collection local shadows the array field; object[] has no class
|
|
calls, r = _calls(tmp_path, {
|
|
"Config.cs": _CONFIG,
|
|
"Anim.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public class Anim\n {\n"
|
|
" private Config[] _configs;\n"
|
|
" private object[] _objs;\n"
|
|
" public void Save() { var _configs = Load(); _configs[0].WriteIds();"
|
|
" _objs[0].WriteIds(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
save = _find(r, ".Save()", "anim")
|
|
assert not {t for s, t in calls if s == save}
|
|
|
|
|
|
def test_constructor_calls_are_attributed_to_the_type(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Bound.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public abstract class Bound { protected object GameObjectOf(object o) { return o; } }\n"
|
|
"}\n"
|
|
),
|
|
"BoundFunc.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public class BoundFunc : Bound\n {\n"
|
|
" private object _go;\n"
|
|
" public BoundFunc(object o) { _go = GameObjectOf(o); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
assert (_find(r, "BoundFunc", "boundfunc"), _find(r, ".GameObjectOf()", "bound")) in calls
|
|
|
|
|
|
def test_constructor_typed_parameter_and_this_calls_resolve(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Base.cs": "namespace Demo\n{\n public class BaseSvc { public void Ping() { } }\n}\n",
|
|
"Holder.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public class Holder : BaseSvc\n {\n"
|
|
" public Holder(Run run) { run.Sequence.Apply(); this.Ping(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
holder = _find(r, "Holder", "holder")
|
|
assert (holder, _find(r, ".Apply()", "sequence")) in calls
|
|
assert (holder, _find(r, ".Apply()", "decoy")) not in calls
|
|
assert (holder, _find(r, ".Ping()", "basesvc")) in calls
|
|
|
|
|
|
def test_issue_3797_shapes_still_resolve(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Store.cs": (
|
|
"public interface IStore { void Save(); }\n"
|
|
"public class Store : IStore { public void Save() { } }\n"
|
|
),
|
|
"Editor.cs": (
|
|
"public class Editor {\n"
|
|
" private object _obj; private IStore _s;\n"
|
|
" public void A() { ((IStore)_obj).Save(); }\n"
|
|
" public void B() { _s?.Save(); }\n"
|
|
"}\n"
|
|
),
|
|
})
|
|
istore_save = _find(r, ".Save()", "istore")
|
|
store_save = next(n["id"] for n in r["nodes"]
|
|
if n["label"] == ".Save()" and n["id"] != istore_save)
|
|
for name in (".A()", ".B()"):
|
|
caller = _find(r, name, "editor")
|
|
assert (caller, istore_save) in calls
|
|
assert (caller, store_save) not in calls
|
|
|
|
|
|
_CONFIGS = (
|
|
"namespace Demo\n{\n"
|
|
" public class Config { public void WriteIds() { } }\n"
|
|
" public class List { public void WriteIds() { } }\n"
|
|
"}\n"
|
|
)
|
|
|
|
|
|
def test_element_of_type_parameter_list_stays_unresolved(tmp_path):
|
|
# `Config` here is the class's type parameter, not Demo.Config.
|
|
calls, r = _calls(tmp_path, {
|
|
"Config.cs": _CONFIGS,
|
|
"Holder.cs": (
|
|
"using System.Collections.Generic;\nnamespace Demo\n{\n"
|
|
" public class Holder<Config>\n {\n"
|
|
" public List<Config> Items { get; set; }\n"
|
|
" public void Run() { Items[0].WriteIds(); }\n"
|
|
" public void Local(List<Config> xs) { xs[0].WriteIds(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
assert not [c for c in calls if "holder" in c[0]]
|
|
|
|
|
|
def test_element_of_nested_list_stays_unresolved(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Config.cs": _CONFIGS,
|
|
"Grid.cs": (
|
|
"using System.Collections.Generic;\nnamespace Demo\n{\n"
|
|
" public class Grid\n {\n"
|
|
" private List<List<Config>> _grid;\n"
|
|
" public void Run() { _grid[0].WriteIds(); }\n"
|
|
" }\n}\n"
|
|
),
|
|
})
|
|
assert not [c for c in calls if "grid" in c[0]]
|
|
|
|
|
|
# A member call whose receiver is not a simple identifier, `this` or `base`
|
|
# (`run.Sequence.Apply()`, `Make().Apply()`, `s.Items.Count()`) captured no
|
|
# receiver name. With no receiver to defer on, the call fell back to the
|
|
# file's bare-name map and bound to the caller's own same-named method — a
|
|
# false self-loop or a wrong sibling edge, written as EXTRACTED.
|
|
|
|
|
|
def test_member_chain_never_binds_to_the_callers_same_named_method(tmp_path):
|
|
calls, r = _calls(tmp_path, {
|
|
"Model.cs": _MODEL,
|
|
"Controller.cs": _controller("run.Sequence.Apply();", "public void Apply() { }"),
|
|
})
|
|
tick = _find(r, ".Tick()", "controller")
|
|
assert (tick, _find(r, ".Apply()", "controller")) not in calls
|
|
assert (tick, _find(r, ".Apply()", "sequence")) in calls
|
|
|
|
|
|
def test_chained_receiver_in_one_file_gives_no_self_loop(tmp_path):
|
|
calls, r = _calls(tmp_path, {"Crowd.cs": (
|
|
"namespace Demo\n{\n"
|
|
" public class Crowd\n {\n"
|
|
" public void LateUpdate() { Pick().look.LateUpdate(); }\n"
|
|
" object Pick() { return null; }\n"
|
|
" }\n}\n"
|
|
)})
|
|
late = _find(r, ".LateUpdate()", "crowd")
|
|
assert (late, late) not in calls
|
|
|
|
|
|
def test_untyped_chain_never_binds_to_a_same_file_method(tmp_path):
|
|
calls, r = _calls(tmp_path, {"Inventory.cs": (
|
|
"using System.Collections.Generic;\nnamespace Demo\n{\n"
|
|
" public class State { public List<int> Items; }\n"
|
|
" public class Inventory\n {\n"
|
|
" public int Count() { return 0; }\n"
|
|
" public int Owned(State s) { return s.Items.Count(); }\n"
|
|
" }\n}\n"
|
|
)})
|
|
owned = _find(r, ".Owned()", "inventory")
|
|
assert (owned, _find(r, ".Count()", "inventory")) not in calls
|