fix(csharp): give each method overload its own node and bind calls by argument count (#4584, #4591)

Applied from PR #4591 (authorship preserved); stale cache-schema bump dropped for one combined bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mpasha17andClaude Opus 4.8 authored and safishamsi committed 2026-10-23 18:15:24 +01:00
1 parent 14514b37f6
commit 87151e40ac
7 files changed
+518 -38

No files matched your search

+61 -2
View File
@@ -183,7 +183,7 @@ from graphify.extractors.resolution import ( # noqa: E402,F401
from graphify.symbol_resolution import resolve_bash_source_edges # noqa: E402
from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401
from graphify.extractors.engine import PYTEST_FIXTURE_UNTYPED, REFERENCE_CONTEXTS, _CSHARP_TYPE_PARAMETER_SCOPE_DECLARATIONS, _C_PRIMITIVE_TYPE_NODES, _JAVA_BUILTIN_TYPES, _JAVA_TYPE_PARAMETER_SCOPE_DECLARATIONS, _JS_FUNCTION_VALUE_TYPES, _JS_SCOPE_BOUNDARY, _PYTHON_ANNOTATION_NOISE, _PYTHON_TYPE_CONTAINERS, _RUBY_CLASS_FACTORIES, _c_collect_type_refs, _cpp_collect_type_refs, _cpp_declarator_name, _cpp_local_var_types, _csharp_attribute_names, _csharp_classify_base, _csharp_collect_type_refs, _csharp_extra_walk, _csharp_namespace_id, _csharp_namespace_name, _csharp_pick_overload, _csharp_pre_scan_interfaces, _csharp_type_parameters_in_scope, _dynamic_import_js, _extract_generic, _find_body, _find_require_call, _get_cpp_func_name, _java_annotation_names, _java_collect_type_refs, _java_extra_walk, _java_type_parameters_in_scope, _js_collect_pattern_idents, _js_dispatch_value_idents, _js_extra_walk, _js_local_bound_names, _js_member_assignment_target, _js_module_bound_names, _kotlin_collect_type_refs, _kotlin_function_return_type_node, _kotlin_property_type_node, _kotlin_user_type_name, _php_collect_type_refs, _php_method_return_type_node, _php_name_text, _python_collect_assignment_targets, _python_collect_param_refs, _python_collect_type_refs, _python_local_bound_names, _python_module_bound_names, _python_param_names, _read_csharp_type_name, _require_imports_js, _ruby_const_last_name, _ruby_extra_walk, _ruby_local_class_bindings, _ruby_new_class_name, _scala_collect_type_refs, _semantic_reference_edge, _source_location, _swift_classify_base, _swift_collect_type_refs, _swift_constructor_type, _swift_declaration_keyword, _swift_extra_walk, _swift_local_var_types, _swift_pre_scan, _swift_property_name, _swift_property_type_node, _swift_receiver_name, _swift_user_type_name, _ts_decorator_name, _ts_descendant_decorators, _ts_emit_decorator_edges, _ts_extra_walk, _ts_method_name, _ts_receiver_type_table # noqa: E402,F401
from graphify.extractors.pascal import _PAS_BEGIN_END_TOKEN_RE, _PAS_CALL_RE, _PAS_END_SEMI_RE, _PAS_IMPL_HEADER_RE, _PAS_KEYWORDS, _PAS_METHOD_DECL_RE, _PAS_MODULE_RE, _PAS_TOKEN_RE, _PAS_TYPE_HEADER_RE, _PAS_USES_RE, _extract_pascal_regex, _pascal_find_body, _pascal_split_bases, _pascal_split_sections, _pascal_split_uses, _pascal_strip_comments, extract_pascal # noqa: E402,F401
@@ -5534,6 +5534,46 @@ def _resolve_cpp_member_calls(
})
def _csharp_overload_index(nodes: list[dict], edges: list[dict]) -> dict:
"""Method nodes by id and their owning type, for C# methods with an arity."""
node_by_id = {n.get("id"): n for n in nodes}
return {
"node": node_by_id,
"owner": {
e.get("target"): e.get("source") for e in edges
if e.get("relation") == "method"
and "arity" in ((node_by_id.get(e.get("target")) or {}).get("metadata") or {})
},
}
def _csharp_collapse_overloads(candidates: list[str], argc: object, index: dict) -> list[str]:
"""Fold the overloads of one C# method among ``candidates`` into one (#4584).
Overloads of a name on one type are separate nodes, so a bare-name lookup
sees them as rival definitions and, all in one file, no tie-breaker can
choose. The call's argument count picks the overload when exactly one fits;
otherwise the first declared one stands for the name, as it did when the
overloads were a single node. ``index`` is ``_csharp_overload_index``.
"""
owner = index["owner"]
groups: dict[tuple, list[str]] = {}
for c in candidates:
groups.setdefault((owner[c],) if c in owner else (None, c), []).append(c)
if len(groups) == len(candidates):
return candidates
keep: set[str] = set()
for members in groups.values():
if len(members) == 1:
keep.add(members[0])
continue
picked = _csharp_pick_overload(
[index["node"][m] for m in members], argc if isinstance(argc, int) else None
)
keep.add(picked["id"] if picked is not None else members[0])
return [c for c in candidates if c in keep]
def _resolve_csharp_member_calls(
per_file: list[dict],
all_nodes: list[dict],
@@ -5595,6 +5635,7 @@ def _resolve_csharp_member_calls(
# (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] = {}
method_peers: dict[tuple[str, str], list[dict]] = {}
enclosing_type: dict[str, str] = {}
for e in all_edges:
if e.get("relation") != "method":
@@ -5604,7 +5645,11 @@ def _resolve_csharp_member_calls(
if tnode is None:
continue
enclosing_type.setdefault(tgt, src)
method_index[(src, _key(tnode.get("label", "")))] = tgt
# Overloads share a key: the first declared one stands for the name,
# and the call's argument count picks among them below (#4584).
method_key = (src, _key(tnode.get("label", "")))
method_index.setdefault(method_key, tgt)
method_peers.setdefault(method_key, []).append(tnode)
# Base-class chain from `inherits` edges (C# files only). The type-reference
# pass has already re-pointed each resolvable base to its real definition and
@@ -5834,6 +5879,11 @@ def _resolve_csharp_member_calls(
method_nid = _method_on_type_or_bases(type_nid, _key(callee))
if not method_nid:
continue # receiver typed, but the type has no such method — skip
peers = method_peers.get((enclosing_type.get(method_nid), _key(callee)), [])
if len(peers) > 1:
picked = _csharp_pick_overload(peers, rc.get("arity"))
if picked is not None:
method_nid = picked["id"]
if method_nid == caller or (caller, method_nid) in existing_pairs:
continue
existing_pairs.add((caller, method_nid))
@@ -10507,6 +10557,7 @@ def extract(
_csharp_stub_resolver: CsharpNameResolver | None = None # built on first use
_csharp_method_owner: dict[str, str] | None = None # method nid -> type nid, built on first use
_csharp_outer_type: dict[str, str] | None = None # nested type nid -> outer type label, built on first use
_csharp_overload_cache: dict = {} # filled on first use (#4584)
for rc in all_raw_calls:
if rc.get("_ambiguous_python_import"):
continue
@@ -10560,6 +10611,14 @@ def extract(
candidates = global_label_to_nids_ci.get(callee.lower(), [])
if not candidates:
continue
if rc.get("lang") == "csharp" and not rc.get("csharp_new") and len(candidates) > 1:
if not _csharp_overload_cache:
_csharp_overload_cache.update(_csharp_overload_index(
resolution_nodes, all_edges + list(resolution_context_edges or [])
))
candidates = _csharp_collapse_overloads(
candidates, rc.get("arity"), _csharp_overload_cache
)
if rc.get("csharp_new") or rc.get("lang") == "csharp":
# An enum member or a field/property is never a valid call target
# either — not just never what `new X()` constructs. A bare call
+117 -1
View File
@@ -190,6 +190,85 @@ def _csharp_type_name_for_id(node, class_name: str) -> str:
return f"{class_name}`{arity}" if arity else class_name
def _csharp_method_signature(node, source: bytes) -> tuple[str, dict]:
"""A C# method's parameter signature and the argument counts it accepts.
The signature (``"`1:this Foo,ref int,params int[]"``) tells overloads
apart; the metadata (``arity``, plus ``min_arity`` for optional, ``params``
and extension parameters, and ``variadic`` for ``params``) lets a call site
pick the overload its argument count fits (#4584).
"""
parts: list[str] = []
arity = required = 0
variadic = extension = False
params = node.child_by_field_name("parameters")
children = params.children if params is not None else []
for i, c in enumerate(children):
if c.type == "parameter":
mods = [_read_text(m, source) for m in c.children if m.type == "modifier"]
type_node = c.child_by_field_name("type")
# No whitespace in a type, so `Dictionary<string, int>`, a line
# break inside it, or CRLF never changes the id.
type_text = "".join(_read_text(type_node, source).split()) if type_node is not None else ""
parts.append(" ".join(mods + [type_text]))
arity += 1
if "this" in mods and arity == 1:
extension = True
if not any(x.type == "=" for x in c.children):
required += 1
elif c.type == "params":
type_node = children[i + 1] if i + 1 < len(children) else None
parts.append("params " + ("".join(_read_text(type_node, source).split()) if type_node is not None else ""))
arity += 1
variadic = True
type_params = sum(
1 for c in node.children if c.type == "type_parameter_list"
for _p in c.children if _p.type in ("type_parameter", "identifier")
)
signature = ",".join(parts)
if type_params:
signature = f"`{type_params}:{signature}"
meta: dict = {"arity": arity}
# An extension method is also called as `x.M(rest)`, one argument short.
min_arity = required - 1 if extension else required
if min_arity < arity:
meta["min_arity"] = min_arity
if variadic:
meta["variadic"] = True
return signature, meta
def _csharp_argc(call_node) -> int | None:
"""Argument count of a C# invocation, or None for anything else."""
if call_node.type != "invocation_expression":
return None
args = call_node.child_by_field_name("arguments")
return sum(1 for c in args.children if c.type == "argument") if args is not None else 0
def _csharp_pick_overload(overloads: list[dict], argc: int | None) -> dict | None:
"""The one overload a call with ``argc`` arguments can mean, or None.
``overloads`` are the method nodes of one name on one type. Only the
argument count is known at the call site, so two overloads that both
accept it (``M(int)`` / ``M(string)``, or ``M(int)`` / ``M(params int[])``)
stay undecided; C# would pick by argument types, which are not known here
(#4584).
"""
if argc is None:
return None
fits = []
for n in overloads:
meta = n.get("metadata") or {}
arity = meta.get("arity")
if not isinstance(arity, int):
return None
low = meta.get("min_arity", arity)
if low <= argc and (argc <= arity or meta.get("variadic")):
fits.append(n)
return fits[0] if len(fits) == 1 else None
def _csharp_pre_scan_name_collisions(
root_node, source: bytes, stem: str
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
@@ -6073,6 +6152,9 @@ def _extract_generic(
# class nid -> method name -> one [return type, type-param count] per
# overload, for typing `var v = R.M<A>()` cross-file (#4266).
csharp_method_returns: dict[str, dict[str, list]] = {}
# plain method nid -> {signature: node} for the overloads of one name on
# one type, in declaration order (#4584).
csharp_overloads: dict[str, dict[str, dict]] = {}
csharp_interface_names: set[str] = set()
csharp_type_groups: dict[str, set[str]] = {}
@@ -7754,6 +7836,18 @@ def _extract_generic(
func_nid = _underscore_salted_nid(
func_nid, sanitized_name, csharp_member_groups
)
if t == "method_declaration":
# Overloads share a name, so they minted one id and
# every later overload vanished into the first (#4584).
# The first declaration keeps the plain id; each other
# signature gets its own, salted like #3302/#4415.
csharp_sig, csharp_sig_meta = _csharp_method_signature(node, source)
group = csharp_overloads.setdefault(func_nid, {})
if group and csharp_sig not in group:
salt = hashlib.sha1(
csharp_sig.encode("utf-8"), usedforsecurity=False
).hexdigest()[:6]
func_nid = _make_id(func_nid, salt)
elif (config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript")
and t in _JS_MEMBER_METHOD_TYPES):
func_nid = _js_member_nid(
@@ -7779,12 +7873,18 @@ def _extract_generic(
existing_metadata = dict(existing_method.get("metadata") or {})
existing_metadata.update(ruby_method_metadata)
existing_method["metadata"] = sanitize_metadata(existing_metadata)
method_metadata = ruby_method_metadata
if config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration":
method_metadata = {**csharp_sig_meta, "signature": csharp_sig}
add_node(
func_nid,
f".{func_name}()",
line,
metadata=ruby_method_metadata,
metadata=method_metadata,
)
if (config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration"
and csharp_sig not in group and nodes and nodes[-1]["id"] == func_nid):
group[csharp_sig] = nodes[-1]
add_edge(parent_class_nid, func_nid, "method", line)
if config.ts_module == "tree_sitter_c_sharp" and t == "method_declaration":
csharp_method_returns.setdefault(parent_class_nid, {}).setdefault(
@@ -8602,6 +8702,13 @@ def _extract_generic(
label_to_nid.setdefault(normalised, n["id"])
label_to_nid_ci.setdefault(normalised.lower(), n["id"])
# method nid -> its overloads (declaration order), for the call walk.
csharp_overload_peers: dict[str, list[dict]] = {
peer["id"]: list(group.values())
for group in csharp_overloads.values() if len(group) > 1
for peer in group.values()
}
seen_call_pairs: set[tuple[str, str]] = set()
seen_indirect_pairs: set[tuple[str, str]] = set() # Python indirect_call dedup
seen_dyn_import_pairs: set[tuple[str, str]] = set()
@@ -9704,6 +9811,12 @@ def _extract_generic(
# file (an annotation elsewhere in this file created it), so the
# call still goes to raw_calls for cross-file resolution, which
# skips source-less candidates on its own (#3888).
# C#: the name found one overload; the argument count picks
# the one this call means, else the first declared (#4584).
if tgt_nid in csharp_overload_peers:
peers = csharp_overload_peers[tgt_nid]
picked = _csharp_pick_overload(peers, _csharp_argc(node))
tgt_nid = (picked or peers[0])["id"]
external_stub_target = bool(tgt_nid and not nid_to_sf.get(tgt_nid))
if tgt_nid and not external_stub_target:
pair = (caller_nid, tgt_nid)
@@ -9807,6 +9920,9 @@ def _extract_generic(
# class fields/properties are the base scope.
if config.ts_module == "tree_sitter_c_sharp":
rc_entry["lang"] = "csharp"
argc = _csharp_argc(node)
if argc is not None:
rc_entry["arity"] = argc
# `new X()` can only construct a type, never an enum
# member or property that happens to share X's name.
if node.type == "object_creation_expression":
+53 -24
View File
@@ -18,7 +18,8 @@ implementers the answer is all N, not none. Each implementer is still subject
to the single-owner guard `resolve_pascal_inherited_calls` and
`resolve_ruby_member_calls` already use: an implementer that itself declares
more than one method of the same name is ambiguous and is skipped on its own,
independently of its siblings.
independently of its siblings, unless the overloads pair up one-to-one by
signature or parameter count (C#, #4584).
The join is per method rather than per call site, so one edge per implementer
reconnects every call that reaches the interface method. Confidence is
@@ -168,28 +169,56 @@ def resolve_interface_dispatch(
for impl_nid, impl_node in gated_impls:
impl_methods = methods_of.get(impl_nid, {})
for name, declared in interface_methods.items():
if len(declared) != 1:
continue
# Ambiguity is per implementer: one implementer overloading a
# name does not disqualify its siblings from a clean match.
candidates = impl_methods.get(name, set())
if len(candidates) != 1:
continue
source = next(iter(declared))
target = next(iter(candidates))
if source == target or (source, target) in existing_pairs:
continue
existing_pairs.add((source, target))
new_edges.append({
"source": source,
"target": target,
"relation": DISPATCH_RELATION,
"context": "call",
"confidence": "INFERRED",
"confidence_score": score,
"source_file": str(impl_node.get("source_file", "")),
"source_location": impl_node.get("source_location"),
"weight": 1.0,
})
for source, target in _overload_pairs(
declared, impl_methods.get(name, set()), node_by_id
):
if source == target or (source, target) in existing_pairs:
continue
existing_pairs.add((source, target))
new_edges.append({
"source": source,
"target": target,
"relation": DISPATCH_RELATION,
"context": "call",
"confidence": "INFERRED",
"confidence_score": score,
"source_file": str(impl_node.get("source_file", "")),
"source_location": impl_node.get("source_location"),
"weight": 1.0,
})
all_edges.extend(new_edges)
def _overload_pairs(
declared: set[str], candidates: set[str], node_by_id: dict
) -> list[tuple[str, str]]:
"""Pair an interface's methods of one name with an implementer's.
One of each pairs up as always. Otherwise (C# overloads, #4584) each
interface method pairs with the one implementer method of the same declared
signature, else of the same parameter count among those no signature
claimed; no unique match, no edge. An implementer method takes at most one
interface method, so an overload implemented on a base class is not pinned
on a sibling of the same count.
"""
if len(declared) == 1 and len(candidates) == 1:
return [(next(iter(declared)), next(iter(candidates)))]
def _meta(nid: str) -> dict:
meta = (node_by_id.get(nid) or {}).get("metadata")
return meta if isinstance(meta, dict) else {}
pairs: dict[str, str] = {}
for key in ("signature", "arity"):
taken = set(pairs.values())
for source in sorted(declared):
want = _meta(source).get(key)
if source in pairs or want is None:
continue
hits = [c for c in candidates if c not in taken and _meta(c).get(key) == want]
if len(hits) == 1:
pairs[source] = hits[0]
# Two interface methods on one implementer method: neither is certain.
targets = list(pairs.values())
return [(s, t) for s, t in pairs.items() if targets.count(t) == 1]
+5
View File
@@ -2061,6 +2061,11 @@ def _rebuild_code(
"module",
"name",
"arity",
# C# overloads (#4584): which one a call's
# argument count fits.
"min_arity",
"variadic",
"signature",
)
if key in metadata
}
+6 -9
View File
@@ -127,10 +127,9 @@ def test_three_implementers_floor_at_the_weak_inference_tier(tmp_path):
def test_one_ambiguous_implementer_does_not_suppress_its_siblings(tmp_path):
# B owns two distinct nodes named `.M()` (synthetic: the real extractor
# collapses same-named overloads into one node, so this models a case
# where it would not, e.g. partial classes merged incorrectly). B's
# ambiguity must stay scoped to B and not suppress A's clean match.
# B owns two distinct nodes named `.M()` with nothing (signature or
# arity) to tell them apart. B's ambiguity must stay scoped to B and not
# suppress A's clean match.
from graphify.csharp_dispatch import resolve_csharp_interface_dispatch
nodes = [
@@ -163,11 +162,9 @@ def test_no_matching_member_produces_no_edge(tmp_path):
assert not dispatch
def test_overloads_collapsed_into_one_node_still_link(tmp_path):
# The extractor mints one node per method name, so two overloads share it.
# The guard counts distinct nodes, not `method` edge multiplicity, so this
# is one candidate rather than a tie. Same reasoning as the dedup comment in
# resolve_pascal_inherited_calls.
def test_overloaded_implementer_still_links(tmp_path):
# A's overloads are two nodes since #4584; the interface's M() pairs with
# the one of the same parameter count.
dispatch, r = _extract(tmp_path, {"S.cs": (
"public interface IR { void M(); }\n"
"public class A : IR { public void M() { } public void M(int x) { } }\n"
+269
View File
@@ -0,0 +1,269 @@
"""C# overloads get a node each, and calls pick theirs by argument count (#4584).
Overloads share a name, so they minted one id: ``add_node`` kept the first
declaration, the others vanished, every caller of any overload pointed at the
first one, and a later overload's own calls were credited to it. Now the first
declared overload keeps the plain id (so a type without overloads, and the
first overload, keep the ids they had) and every other signature gets a salted
one. A call binds to the overload its argument count fits; when that is not
unique (``M(int)`` / ``M(string)``) it binds to the first, as before.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from graphify.extract import extract
_MESSAGES = """namespace Demo
{
public static class Messages
{
public static string Banner(int code)
{
return Format(code);
}
public static string Banner(int code, string format)
{
return Pad(format);
}
private static string Format(int code) { return code.ToString(); }
private static string Pad(string text) { return text; }
}
}
"""
_CALLER = """namespace Demo
{
public class Caller
{
public string OneArg() { return Messages.Banner(1); }
public string TwoArgs() { return Messages.Banner(1, "x"); }
}
}
"""
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)
return extract([Path(n) for n in files], cache_root=Path(tempfile.mkdtemp()))
finally:
os.chdir(old)
def _by_line(r, label):
return {
n["source_location"]: n["id"]
for n in r["nodes"] if n["label"] == label and n.get("source_file")
}
def _edges(r, relation="calls"):
return {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == relation}
def _id(r, label):
ids = [n["id"] for n in r["nodes"] if n["label"] == label and n.get("source_file")]
assert len(ids) == 1, (label, ids)
return ids[0]
def test_issue_repro_each_overload_keeps_its_node_callers_and_callees(tmp_path):
r = _extract(tmp_path, {"Messages.cs": _MESSAGES, "Caller.cs": _CALLER})
banner = _by_line(r, ".Banner()")
assert set(banner) == {"L5", "L10"}
one, two = banner["L5"], banner["L10"]
# The first overload keeps the id the single node had.
assert one == "messages_demo_messages_banner"
calls = _edges(r)
assert (_id(r, ".OneArg()"), one) in calls
assert (_id(r, ".TwoArgs()"), two) in calls
assert (_id(r, ".OneArg()"), two) not in calls
assert (_id(r, ".TwoArgs()"), one) not in calls
assert (one, _id(r, ".Format()")) in calls
assert (two, _id(r, ".Pad()")) in calls
assert (one, _id(r, ".Pad()")) not in calls
def test_method_without_overloads_is_unchanged(tmp_path):
r = _extract(tmp_path, {"Messages.cs": _MESSAGES})
fmt = next(n for n in r["nodes"] if n["label"] == ".Format()")
assert fmt["id"] == "messages_demo_messages_format"
def test_in_file_and_instance_calls_pick_by_argument_count(tmp_path):
r = _extract(tmp_path, {
"Svc.cs": (
"public class Svc {\n"
" public void Run() { }\n"
" public void Run(int a) { Run(); }\n"
" public void Twice() { Run(1); }\n"
"}\n"
),
"Use.cs": (
"public class Use {\n"
" public void Go(Svc s) { s.Run(); }\n"
" public void Go1(Svc s) { s.Run(2); }\n"
"}\n"
),
})
run = _by_line(r, ".Run()")
calls = _edges(r)
assert (run["L3"], run["L2"]) in calls
assert (_id(r, ".Twice()"), run["L3"]) in calls
assert (_id(r, ".Twice()"), run["L2"]) not in calls
assert (_id(r, ".Go()"), run["L2"]) in calls
assert (_id(r, ".Go1()"), run["L3"]) in calls
def test_same_count_overloads_bind_to_the_first(tmp_path):
r = _extract(tmp_path, {"S.cs": (
"public static class S {\n"
" public static void Put(int x) { }\n"
" public static void Put(string x) { }\n"
" public static void Call() { Put(1); }\n"
"}\n"
)})
put = _by_line(r, ".Put()")
assert len(put) == 2
calls = _edges(r)
assert (_id(r, ".Call()"), put["L2"]) in calls
assert (_id(r, ".Call()"), put["L3"]) not in calls
def test_optional_and_params_arguments_count(tmp_path):
r = _extract(tmp_path, {"S.cs": (
"public static class S {\n"
" public static void Opt(string s) { }\n"
" public static void Opt(int a, int b, int c = 1) { }\n"
" public static void Many(int a) { }\n"
" public static void Many(int a, int b, params int[] rest) { }\n"
" public static void Call() { Opt(1, 2); Many(1, 2, 3, 4); }\n"
"}\n"
)})
calls = _edges(r)
caller = _id(r, ".Call()")
assert (caller, _by_line(r, ".Opt()")["L3"]) in calls
assert (caller, _by_line(r, ".Many()")["L5"]) in calls
def test_same_file_partial_halves_keep_their_overloads(tmp_path):
r = _extract(tmp_path, {"P.cs": (
"public partial class P { public void M(int a) { } }\n"
"public partial class P { public void M(int a, int b) { M(a); } }\n"
)})
m = _by_line(r, ".M()")
assert set(m) == {"L1", "L2"}
assert (m["L2"], m["L1"]) in _edges(r)
def test_identical_signature_behind_preprocessor_stays_one_node(tmp_path):
r = _extract(tmp_path, {"S.cs": (
"public class S {\n"
"#if A\n"
" public void M(int a) { }\n"
"#else\n"
" public void M(int a) { }\n"
"#endif\n"
"}\n"
)})
assert len(_by_line(r, ".M()")) == 1
def test_interface_overloads_dispatch_to_their_own_implementation(tmp_path):
r = _extract(tmp_path, {"S.cs": (
"public interface IR { void M(int a); void M(int a, string b); }\n"
"public class A : IR {\n"
" public void M(int a) { }\n"
" public void M(int a, string b) { }\n"
"}\n"
)})
iface = _by_line(r, ".M()")
dispatch = _edges(r, "dispatches_to")
ir1 = next(n["id"] for n in r["nodes"] if n["id"].startswith("s_ir_m") and n["source_location"] == "L1"
and n["metadata"].get("arity") == 1)
ir2 = next(n["id"] for n in r["nodes"] if n["id"].startswith("s_ir_m")
and n["metadata"].get("arity") == 2)
assert dispatch == {(ir1, iface["L3"]), (ir2, iface["L4"])}
def test_overload_ids_ignore_whitespace_and_line_endings(tmp_path):
src = (
"public class S {\n"
" public void M(int a) { }\n"
" public void M(System.Collections.Generic.Dictionary<string, int> d) { }\n"
"}\n"
)
a = _extract(tmp_path / "a", {"S.cs": src})
b = _extract(tmp_path / "b", {"S.cs": src.replace(", int>", ",\n int>").replace("\n", "\r\n")})
assert sorted(_by_line(a, ".M()").values()) == sorted(_by_line(b, ".M()").values())
def test_interface_overload_implemented_on_a_base_does_not_dispatch_to_a_sibling(tmp_path):
# Impl declares only M(int); M(string) comes from Base. IR.M(string) must
# not be pinned on Impl.M(int) just because both take one argument.
r = _extract(tmp_path, {"D.cs": (
"public interface IR { void M(int a); void M(string a); }\n"
"public class Base { public void M(string a) { } }\n"
"public class Impl : Base, IR { public void M(int a) { } }\n"
)})
by_sig = {
n["metadata"].get("signature"): n["id"]
for n in r["nodes"] if n["id"].startswith("d_ir_m")
}
impl = next(n["id"] for n in r["nodes"] if n["id"] == "d_impl_m")
assert _edges(r, "dispatches_to") == {(by_sig["int"], impl)}
def test_collapse_keeps_distinct_names_on_one_type_apart(tmp_path):
# Review: overloads are folded per owner type, but only among candidates
# of one name, so `Put` / `_Put` / `put` stay separate targets.
r = _extract(tmp_path, {
"A.cs": (
"public partial class S {\n"
" public void Put(int a) { }\n"
" public void _Put(int a, int b) { }\n"
" public void put(int a, int b, int c) { }\n"
" public void Put(string a, int b) { }\n"
"}\n"
),
"B.cs": (
"public partial class S {\n"
" public void Go() { Put(1); _Put(1, 2); put(1, 2, 3); Put(\"x\", 1); }\n"
"}\n"
),
})
go = _id(r, ".Go()")
calls = {t for s, t in _edges(r) if s == go}
put = _by_line(r, ".Put()")
assert calls == {put["L2"], put["L5"], _id(r, "._Put()"), _id(r, ".put()")}
def test_params_parameter_is_variadic_and_optional(tmp_path):
r = _extract(tmp_path, {"P.cs": (
"public static class P {\n"
" public static void Log(string m) { }\n"
" public static void Log(string m, params object[] args) { }\n"
"}\n"
)})
meta = {n["source_location"]: n["metadata"] for n in r["nodes"] if n["label"] == ".Log()"}
assert meta["L3"]["arity"] == 2 and meta["L3"]["min_arity"] == 1 and meta["L3"]["variadic"]
assert "variadic" not in meta["L2"]
def test_overloads_in_struct_record_and_beside_other_members(tmp_path):
r = _extract(tmp_path, {"L.cs": (
"public struct St { public void M(int a) { } public int M2 { get; } public void M(int a, int b) { } }\n"
"public record R { public void N() { } class Inner { public void N(int x) { } } public void N(int x) { } }\n"
)})
assert len([n for n in r["nodes"] if n["id"].startswith("l_st_m") and n["label"] == ".M()"]) == 2
assert {n["id"] for n in r["nodes"] if n["label"] == ".N()"} == {"l_r_n", "l_r_n_46f8ab", "l_inner_n"}
+7 -2
View File
@@ -116,14 +116,19 @@ def test_case_only_method_pair_keeps_both_methods(tmp_path):
assert _one(r, ".Load()") != _one(r, ".load()")
def test_overloads_stay_one_node(tmp_path):
def test_overloads_are_not_an_underscore_collision(tmp_path):
# Overloads are one name, so this rule leaves them alone; they get a node
# each through the overload rule instead (#4584), the first keeping the
# plain id.
r = _extract(tmp_path, {"S.cs": (
"public class Host {\n"
" public void Save() { }\n"
" public void Save(int x) { }\n"
"}\n"
)})
_one(r, ".Save()")
ids = _ids(r, ".Save()")
assert len(ids) == 2
assert "s_host_save" in ids
def test_member_behind_preprocessor_directive_is_counted(tmp_path):