feat(extract): C# receiver-typed member-call resolution (#1609)
C# had no member-call resolver (unlike Swift/Python/Ruby/TS/C++/ObjC), so `recv.Method()` fell back to a bare method-name match against label_to_nid — which, under ambiguity, silently mis-bound `_server.Save()` to an unrelated `Cache.Save()`. That's a WRONG edge, not just a missing one, and it left delegation-heavy C# call graphs (wrappers, service layers) blind across typed member/param boundaries. Mirrors the C++ #1547 pattern: - capture the member_access_expression receiver (simple identifier or `this`) into member_receiver and set is_member_call in the C# invocation branch; - defer ALL C# member calls with a receiver to the resolver (tgt_nid = None) so the bare in-file match can't fire, and emit a raw_call tagged lang="csharp"; - _csharp_member_type_table: file-wide name -> Type from fields, properties, parameters, and locals (incl. `var v = new T()`), first-binding-wins; - _resolve_csharp_member_calls: `this` -> enclosing class (EXTRACTED), capitalized -> the named type (EXTRACTED), else the receiver's table type (INFERRED), each gated by the single-definition guard; no method on the type -> no edge. Registered for .cs. Verified: the ambiguous `_server.Save()` now resolves to Server.Save and NOT Cache.Save; field/param/local/this/Type.static/cross-file all resolve; dynamic receiver and absent-method emit nothing; unqualified calls unregressed. 8 new tests, full suite 2841. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
41ce87e442
commit
eebc406773
@@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Feat: C# receiver-typed member-call resolution (#1609, thanks @JensD-git). `recv.Method()` where `recv` is a typed field, property, parameter, or local now resolves to the receiver *type's* method. C# previously had no member-call resolver, so the bare method name matched any same-named method in the corpus — `_server.Save()` silently mis-bound to an unrelated `Cache.Save()` (a wrong edge, not just a missing one), leaving delegation-heavy call graphs blind across typed boundaries. The receiver is now typed from a per-file field/property/param/local table (incl. `var v = new T()`) and resolved with the single-definition god-node guard; `this.M()` binds to the enclosing class and `Type.M()` to the named type. An untypable receiver (e.g. `dynamic`) or a method absent on the type produces no edge — precision over recall, matching the Swift/C++/Python resolvers.
|
||||
- Fix: `graphify cluster-only` now writes `.graphify_analysis.json` alongside `graph.json` (#1617 / #1610, thanks @sanmaxdev). Without it, a re-cluster left a stale/absent sidecar and a later `export html` silently reported "Single community". The sidecar now carries communities/cohesion/gods/surprises/questions, matching the full extract path.
|
||||
- Fix: `.mts` / `.cts` (TypeScript module extensions) are now treated as TypeScript (#1607, thanks @ashmitg). They were missing from the code-extension set and the JS/TS language maps, so `.mts`/`.cts` sources were detected as non-code and silently skipped.
|
||||
- Fix: four TS/JS extractor gaps (#1615, thanks @papinto). Generator functions (`function*`) now register as callables; `namespace`/`module`/`declare module` containers become queryable nodes; and the TS import-equals form (`import x = require("./m")`) now emits an import edge (its module string nests in an `import_require_clause` the direct-child scan missed).
|
||||
|
||||
+251
-14
@@ -2270,6 +2270,76 @@ def _swift_local_var_types(body_node, source: bytes, table: dict[str, str]) -> N
|
||||
stack.append(c)
|
||||
|
||||
|
||||
def _csharp_member_type_table(root, source: bytes) -> dict[str, str]:
|
||||
"""Collect ``name -> TypeName`` for C# receiver typing (#1609): class fields,
|
||||
properties, method parameters, and local variable declarations.
|
||||
|
||||
File-scoped, first-binding-wins (like the C++ table): a field declared once at
|
||||
class scope is visible to every method's `field.Method()`, and a param/local
|
||||
shadowing the same name is a conservative approximation graphify already accepts
|
||||
for receiver typing. Only a resolvable, non-`var` type name is recorded; `var`
|
||||
without a `new T()` initializer, and predefined/lower-cased primitives, are
|
||||
skipped (precision over recall — an untypable receiver is left for the resolver
|
||||
to drop rather than guess). `var v = new T()` is typed from the object-creation.
|
||||
"""
|
||||
table: dict[str, str] = {}
|
||||
|
||||
def _typed(type_node) -> str | None:
|
||||
info = _read_csharp_type_name(type_node, source)
|
||||
if not info:
|
||||
return None
|
||||
name = info[0]
|
||||
# A genuine C# class name is Pascal-cased; skip predefined primitives
|
||||
# (int/bool/string) which never own a resolvable method definition here.
|
||||
return name if name and name[:1].isupper() else None
|
||||
|
||||
def _decl_names(var_decl):
|
||||
for c in var_decl.children:
|
||||
if c.type == "variable_declarator":
|
||||
nm = c.child_by_field_name("name") or next(
|
||||
(g for g in c.children if g.type == "identifier"), None)
|
||||
if nm is not None:
|
||||
yield _read_text(nm, source), c
|
||||
|
||||
def _new_type(declarator) -> str | None:
|
||||
# `var v = new Server()` — recover the type from the object_creation_expression.
|
||||
for g in declarator.children:
|
||||
if g.type == "object_creation_expression":
|
||||
return _typed(g.child_by_field_name("type"))
|
||||
return None
|
||||
|
||||
stack = [root]
|
||||
while stack:
|
||||
n = stack.pop()
|
||||
t = n.type
|
||||
if t in ("field_declaration", "local_declaration_statement"):
|
||||
vd = next((c for c in n.children if c.type == "variable_declaration"), None)
|
||||
if vd is not None:
|
||||
type_node = vd.child_by_field_name("type")
|
||||
declared = _typed(type_node)
|
||||
for name, decl in _decl_names(vd):
|
||||
resolved = declared or _new_type(decl)
|
||||
if name and resolved and name not in table:
|
||||
table[name] = resolved
|
||||
elif t == "property_declaration":
|
||||
nm = n.child_by_field_name("name")
|
||||
resolved = _typed(n.child_by_field_name("type"))
|
||||
if nm is not None and resolved:
|
||||
pname = _read_text(nm, source)
|
||||
if pname not in table:
|
||||
table[pname] = resolved
|
||||
elif t == "parameter":
|
||||
nm = n.child_by_field_name("name")
|
||||
resolved = _typed(n.child_by_field_name("type"))
|
||||
if nm is not None and resolved:
|
||||
pname = _read_text(nm, source)
|
||||
if pname not in table:
|
||||
table[pname] = resolved
|
||||
for c in n.children:
|
||||
stack.append(c)
|
||||
return table
|
||||
|
||||
|
||||
def _objc_local_var_types(body_node, source: bytes, table: dict[str, str]) -> None:
|
||||
"""Collect ``var -> ClassName`` from ObjC local declarations (``Foo *f = ...;``)
|
||||
in a method body, for receiver typing in the cross-file message-send pass
|
||||
@@ -4776,20 +4846,45 @@ def _extract_generic(
|
||||
callee_name = _read_text(child, source)
|
||||
break
|
||||
elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression":
|
||||
# C#: try name field, then first named child
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
callee_name = _read_text(name_node, source)
|
||||
# C#: the invoked function is the `function` field. A member call
|
||||
# `recv.Method(...)` is a member_access_expression (receiver in its
|
||||
# `expression` field, method in `name`). Capture a simple-identifier
|
||||
# or `this` receiver + set is_member_call so the receiver-typed
|
||||
# resolver (_resolve_csharp_member_calls) can bind it to the
|
||||
# receiver's declared type. Without this the bare method name matched
|
||||
# any same-named method in the corpus, silently mis-resolving
|
||||
# `_server.Save()` to an unrelated `Cache.Save()` (#1609).
|
||||
fn_node = node.child_by_field_name("function")
|
||||
if fn_node is not None and fn_node.type == "member_access_expression":
|
||||
mname = fn_node.child_by_field_name("name")
|
||||
recv = fn_node.child_by_field_name("expression")
|
||||
if mname is not None:
|
||||
callee_name = _read_text(mname, source)
|
||||
is_member_call = True
|
||||
if recv is not None and recv.type == "identifier":
|
||||
member_receiver = _read_text(recv, source)
|
||||
elif recv is not None and recv.type == "this_expression":
|
||||
member_receiver = "this"
|
||||
elif fn_node is not None and fn_node.type == "identifier":
|
||||
callee_name = _read_text(fn_node, source)
|
||||
else:
|
||||
for child in node.children:
|
||||
if child.is_named:
|
||||
raw = _read_text(child, source)
|
||||
if "." in raw:
|
||||
callee_name = raw.split(".")[-1]
|
||||
is_member_call = True
|
||||
else:
|
||||
callee_name = raw
|
||||
break
|
||||
# Fallback: original name-field / first-named-child scan.
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
callee_name = _read_text(name_node, source)
|
||||
else:
|
||||
for child in node.children:
|
||||
if child.is_named:
|
||||
raw = _read_text(child, source)
|
||||
if "." in raw:
|
||||
callee_name = raw.split(".")[-1]
|
||||
is_member_call = True
|
||||
parts = raw.split(".")
|
||||
if len(parts) == 2 and parts[0]:
|
||||
member_receiver = parts[0]
|
||||
else:
|
||||
callee_name = raw
|
||||
break
|
||||
elif config.ts_module == "tree_sitter_php":
|
||||
# PHP: distinguish call expression subtypes
|
||||
if node.type == "function_call_expression":
|
||||
@@ -4903,7 +4998,18 @@ def _extract_generic(
|
||||
# viewset action delegates to a same-named service action — which would
|
||||
# match `tgt_nid == caller_nid` and silently drop the call (#1446). The
|
||||
# captured receiver is resolved later in _resolve_python_member_calls.
|
||||
if is_member_call and member_receiver and (member_receiver[:1].isupper() or is_this_field_call):
|
||||
# C#: ANY member call with a captured receiver defers to the
|
||||
# receiver-typed resolver — a bare method-name match ignores the
|
||||
# receiver's declared type and mis-binds to an unrelated same-named
|
||||
# method (#1609). The receiver may be lowercase (`_server.Save()`),
|
||||
# so this is broader than the capitalized/this-field Python rule.
|
||||
_csharp_defer = (
|
||||
config.ts_module == "tree_sitter_c_sharp"
|
||||
and is_member_call and member_receiver
|
||||
)
|
||||
if is_member_call and member_receiver and (
|
||||
member_receiver[:1].isupper() or is_this_field_call or _csharp_defer
|
||||
):
|
||||
tgt_nid = None
|
||||
else:
|
||||
tgt_nid = label_to_nid.get(callee_name)
|
||||
@@ -4944,6 +5050,11 @@ def _extract_generic(
|
||||
# suffix sets, so a source_file suffix alone can't separate them.
|
||||
if config.ts_module == "tree_sitter_cpp":
|
||||
rc_entry["lang"] = "cpp"
|
||||
# C#: tag the raw_call so _resolve_csharp_member_calls claims it
|
||||
# and types the receiver against the file's field/param/local
|
||||
# type table (#1609).
|
||||
if config.ts_module == "tree_sitter_c_sharp":
|
||||
rc_entry["lang"] = "csharp"
|
||||
raw_calls.append(rc_entry)
|
||||
|
||||
# Indirect dispatch: a function passed BY NAME as a call argument
|
||||
@@ -5284,6 +5395,13 @@ def _extract_generic(
|
||||
result["ts_type_table"] = {"path": str_path, "table": type_table}
|
||||
elif config.ts_module == "tree_sitter_cpp":
|
||||
result["cpp_type_table"] = {"path": str_path, "table": type_table}
|
||||
# C#: a file-wide receiver type table (field/property/param/local -> Type) for
|
||||
# _resolve_csharp_member_calls (#1609). Built from the whole tree, not just
|
||||
# function bodies, so class-level fields/properties are in scope for every method.
|
||||
if config.ts_module == "tree_sitter_c_sharp":
|
||||
cs_table = _csharp_member_type_table(root, source)
|
||||
if cs_table:
|
||||
result["csharp_type_table"] = {"path": str_path, "table": cs_table}
|
||||
return result
|
||||
|
||||
|
||||
@@ -11589,6 +11707,120 @@ def _resolve_cpp_member_calls(
|
||||
})
|
||||
|
||||
|
||||
def _resolve_csharp_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Resolve C# member calls (``recv.Method()``) to the receiver's declared type
|
||||
(#1609).
|
||||
|
||||
The shared cross-file pass drops every ``is_member_call`` because a bare method
|
||||
name collides across the corpus — and for C# an in-file bare match silently
|
||||
mis-bound ``_server.Save()`` to an unrelated ``Cache.Save()``. The C# extractor
|
||||
now records each member call's receiver plus a per-file ``name -> Type`` table
|
||||
(``csharp_type_table``) of fields/properties/params/locals. This pass types the
|
||||
receiver, then emits an edge ONLY when that type resolves to exactly ONE
|
||||
definition (the god-node guard); an untypable receiver is skipped (no guess).
|
||||
|
||||
Receiver typing, by precision tier:
|
||||
* ``this.M()`` — receiver is the caller's own enclosing class -> EXTRACTED.
|
||||
* ``Type.M()`` (capitalized) — the type is named explicitly in source -> EXTRACTED.
|
||||
* ``recv.M()`` — ``recv`` typed via the file's field/param/local table -> INFERRED.
|
||||
|
||||
Must run after id-disambiguation so node ids and caller_nids are final.
|
||||
"""
|
||||
type_table_by_file: dict[str, dict[str, str]] = {}
|
||||
for result in per_file:
|
||||
tt = result.get("csharp_type_table")
|
||||
if tt and tt.get("path"):
|
||||
type_table_by_file[tt["path"]] = tt.get("table", {})
|
||||
|
||||
def _key(label: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9]+", "", str(label)).lower()
|
||||
|
||||
contained = {e.get("target") for e in all_edges if e.get("relation") == "contains"}
|
||||
|
||||
type_def_nids: dict[str, list[str]] = {}
|
||||
node_by_id: dict[str, dict] = {}
|
||||
for n in all_nodes:
|
||||
node_by_id[n.get("id")] = n
|
||||
if n.get("source_file") and n.get("id") in contained and _is_type_like_definition(n):
|
||||
type_def_nids.setdefault(_key(n.get("label", "")), []).append(n["id"])
|
||||
|
||||
# (type_node_id, method_key) -> method_node_id, and caller -> enclosing type.
|
||||
# C# owns its methods via `method` edges.
|
||||
method_index: dict[tuple[str, str], str] = {}
|
||||
enclosing_type: dict[str, str] = {}
|
||||
for e in all_edges:
|
||||
if e.get("relation") != "method":
|
||||
continue
|
||||
src, tgt = e.get("source"), e.get("target")
|
||||
tnode = node_by_id.get(tgt)
|
||||
if tnode is None:
|
||||
continue
|
||||
enclosing_type.setdefault(tgt, src)
|
||||
method_index[(src, _key(tnode.get("label", "")))] = tgt
|
||||
|
||||
all_raw_calls: list[dict] = []
|
||||
for result in per_file:
|
||||
all_raw_calls.extend(result.get("raw_calls", []))
|
||||
|
||||
existing_pairs = {(e.get("source"), e.get("target")) for e in all_edges}
|
||||
for rc in all_raw_calls:
|
||||
if rc.get("lang") != "csharp" or not rc.get("is_member_call"):
|
||||
continue
|
||||
receiver = rc.get("receiver")
|
||||
callee = rc.get("callee")
|
||||
caller = rc.get("caller_nid")
|
||||
if not receiver or not callee or not caller:
|
||||
continue
|
||||
src_file = rc.get("source_file", "")
|
||||
if receiver == "this":
|
||||
type_nid = enclosing_type.get(caller)
|
||||
if not type_nid:
|
||||
continue
|
||||
type_qualified = True
|
||||
elif receiver[:1].isupper():
|
||||
# Type.M() — the type is named explicitly (also covers a Pascal-cased
|
||||
# local whose name equals its type, resolved via the table below if the
|
||||
# explicit-type lookup misses).
|
||||
type_defs = type_def_nids.get(_key(receiver), [])
|
||||
if len(type_defs) != 1:
|
||||
type_name = type_table_by_file.get(src_file, {}).get(receiver)
|
||||
type_defs = type_def_nids.get(_key(type_name), []) if type_name else []
|
||||
if len(type_defs) != 1:
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = True
|
||||
else:
|
||||
type_name = type_table_by_file.get(src_file, {}).get(receiver)
|
||||
if not type_name:
|
||||
continue
|
||||
type_defs = type_def_nids.get(_key(type_name), [])
|
||||
if len(type_defs) != 1: # ambiguous or absent -> bail (god-node guard)
|
||||
continue
|
||||
type_nid = type_defs[0]
|
||||
type_qualified = False
|
||||
method_nid = method_index.get((type_nid, _key(callee)))
|
||||
if not method_nid:
|
||||
continue # receiver typed, but the type has no such method — skip
|
||||
if method_nid == caller or (caller, method_nid) in existing_pairs:
|
||||
continue
|
||||
existing_pairs.add((caller, method_nid))
|
||||
all_edges.append({
|
||||
"source": caller,
|
||||
"target": method_nid,
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"confidence": "EXTRACTED" if type_qualified else "INFERRED",
|
||||
"confidence_score": 1.0 if type_qualified else 0.8,
|
||||
"source_file": src_file,
|
||||
"source_location": rc.get("source_location"),
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
|
||||
def _resolve_objc_member_calls(
|
||||
per_file: list[dict],
|
||||
all_nodes: list[dict],
|
||||
@@ -11734,6 +11966,11 @@ register_language_resolver(
|
||||
_resolve_objc_member_calls,
|
||||
)
|
||||
)
|
||||
# C# receiver-typed member-call resolution (#1609): `field/param/local.Method()`
|
||||
# bound to the receiver's declared type instead of a bare same-named match.
|
||||
register_language_resolver(
|
||||
LanguageResolver("csharp_member_calls", frozenset({".cs"}), _resolve_csharp_member_calls)
|
||||
)
|
||||
|
||||
|
||||
def extract_objc(path: Path) -> dict:
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""C# receiver-typed member-call resolution (#1609).
|
||||
|
||||
`recv.Method()` where `recv` is a typed field / property / parameter / local must
|
||||
resolve to the receiver TYPE's method — not a bare same-named match. Before this,
|
||||
C# had no member-call resolver: the bare method name matched any same-named method
|
||||
in the corpus, so `_server.Save()` silently mis-bound to an unrelated `Cache.Save()`
|
||||
(a WRONG edge, not just a missing one). Resolution is by receiver type with the
|
||||
single-definition god-node guard; an untypable receiver 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():
|
||||
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=tmp_path / ".cache")
|
||||
finally:
|
||||
os.chdir(old)
|
||||
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
|
||||
return calls, r
|
||||
|
||||
|
||||
_AMBIG = {
|
||||
"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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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_field_receiver_resolves_to_declared_type_not_bare_match(tmp_path):
|
||||
calls, r = _calls(tmp_path, _AMBIG)
|
||||
commit = _find(r, ".Commit()", "commit")
|
||||
server_save = _find(r, ".Save()", "server")
|
||||
cache_save = _find(r, ".Save()", "cache")
|
||||
assert (commit, server_save) in calls, "field.Method() must resolve to the field's type"
|
||||
assert (commit, cache_save) not in calls, "must NOT mis-bind to an unrelated same-named method"
|
||||
|
||||
|
||||
def test_parameter_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class Server { public bool Save() => true; }\n"
|
||||
"public class Cache { public bool Save() => false; }\n"
|
||||
"public class Svc { public static bool Copy(Server server) { return server.Save(); } }\n"
|
||||
)
|
||||
})
|
||||
assert any("copy" in s and "server_save" in t for s, t in calls)
|
||||
assert not any("copy" in s and "cache_save" in t for s, t in calls)
|
||||
|
||||
|
||||
def test_local_var_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class Server { public bool Save() => true; }\n"
|
||||
"public class R {\n"
|
||||
" public bool A() { Server s = new Server(); return s.Save(); }\n"
|
||||
" public bool B() { var v = new Server(); return v.Save(); }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
assert any("_r_a" in s and "server_save" in t for s, t in calls), "explicit-typed local"
|
||||
assert any("_r_b" in s and "server_save" in t for s, t in calls), "var = new T() local"
|
||||
|
||||
|
||||
def test_cross_file_receiver_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"Server.cs": (
|
||||
"public class Server { public bool Save() => true; }\n"
|
||||
"public class Cache { public bool Save() => false; }\n"
|
||||
),
|
||||
"Repo.cs": (
|
||||
"public class Repo { private Server _s = new Server(); "
|
||||
"public bool Commit() { return _s.Save(); } }\n"
|
||||
),
|
||||
})
|
||||
assert any("commit" in s and "server_save" in t for s, t in calls)
|
||||
assert not any("commit" in s and "cache_save" in t for s, t in calls)
|
||||
|
||||
|
||||
def test_this_and_static_receivers(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class Util { public static int F() => 1; }\n"
|
||||
"public class R {\n"
|
||||
" public bool A() { return this.B(); }\n"
|
||||
" public bool B() => true;\n"
|
||||
" public int G() { return Util.F(); }\n"
|
||||
"}\n"
|
||||
)
|
||||
})
|
||||
assert any("_r_a" in s and "_r_b" in t for s, t in calls), "this.B() -> R.B"
|
||||
assert any("_r_g" in s and "util_f" in t for s, t in calls), "Util.F() -> Util.F"
|
||||
|
||||
|
||||
def test_untyped_receiver_emits_no_edge(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class Server { public bool Save() => true; }\n"
|
||||
"public class R { public bool C(dynamic x) { return x.Save(); } }\n"
|
||||
)
|
||||
})
|
||||
assert not any("save" in t.lower() for _s, t in calls), "dynamic receiver must not resolve"
|
||||
|
||||
|
||||
def test_method_absent_on_type_emits_no_edge(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class Server { public bool Save() => true; }\n"
|
||||
"public class R { private Server _s = new Server(); "
|
||||
"public bool C() { return _s.Missing(); } }\n"
|
||||
)
|
||||
})
|
||||
assert not any("_r_c" in s and "save" in t.lower() for s, t in calls)
|
||||
|
||||
|
||||
def test_unqualified_call_still_resolves(tmp_path):
|
||||
calls, r = _calls(tmp_path, {
|
||||
"S.cs": (
|
||||
"public class R { public bool A() { Helper(); return true; } "
|
||||
"private void Helper() {} }\n"
|
||||
)
|
||||
})
|
||||
assert any("_r_a" in s and "helper" in t for s, t in calls), "no regression on unqualified calls"
|
||||
Reference in New Issue
Block a user