diff --git a/graphify/csharp_dispatch.py b/graphify/csharp_dispatch.py new file mode 100644 index 00000000..4982e677 --- /dev/null +++ b/graphify/csharp_dispatch.py @@ -0,0 +1,154 @@ +"""Member-level interface dispatch for C# (#3003). + +A C# call through a constructor-injected dependency lands on the interface's +method node, because that is what the call site names: `_report.Build()` where +`_report` is an `IReport` resolves to `IReport.Build()`. The implementing +`Report.Build()` is a separate node, and nothing joins the two, so a directed +walk stops at the interface and every chain through an injected dependency is +cut at that point. On a Scrutor-scanned .NET service where every dependency is +an interface, that is most chains. + +This resolver runs after all files are extracted, with the merged corpus +available, and links the interface's method to the implementing method: + + ireport_ireport_build --dispatches_to--> report_report_build + +It only fires when the interface has exactly one implementer and that +implementer owns exactly one method of the same name, the single-owner guard +`resolve_pascal_inherited_calls` and `resolve_ruby_member_calls` already use. +Walking `implements` to the one type that can serve the call mirrors what the +runtime does; guessing among several implementers would not, so anything +ambiguous is left alone. + +The join is per method rather than per call site, so one edge reconnects every +call that reaches the interface method. Confidence is `INFERRED`: the target is +forced once there is a single implementer, but the source text never names it. +""" +from __future__ import annotations + +_CSHARP_SUFFIXES = (".cs",) + +DISPATCH_RELATION = "dispatches_to" + + +def _is_csharp(node: dict | None) -> bool: + """True when the node is a declaration that lives in a C# file. + + Every end of a dispatch pair has to pass this. `implements` is resolved by + name, so in a mixed corpus a Java class declaring `implements IReport` binds + to a C# `IReport` when that is the only node with the name, and linking a C# + interface member to a Java method would be a wrong edge rather than a missing + one. A non-C# implementation of a C# interface, from VB or a Razor component, + is a real thing, but claiming it needs its own extractor evidence. + """ + if not node: + return False + source_file = node.get("source_file") + return bool(source_file) and str(source_file).endswith(_CSHARP_SUFFIXES) + + +def _method_label(node: dict) -> str: + """Return a method node's bare name, for matching. + + Case is kept: C# is case sensitive, and an implementing member must spell the + interface member exactly, so folding case could only pair a declaration with + a member that does not implement it. + + The name is cut at the first parenthesis rather than by stripping a trailing + `()`. Today the C# extractor labels every method `.Name()`, so the two are the + same, but the pair match is keyed on this string and a label that ever carried + a signature would silently stop matching instead of failing visibly. + """ + label = str(node.get("label", "")).strip().removeprefix(".") + return label.split("(", 1)[0] + + +def resolve_csharp_interface_dispatch( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], +) -> None: + """Link each single-implementer interface method to its implementation. + + Purely additive: the existing call edge to the interface method is left in + place, since the call site really does name the interface. + """ + if not any( + str(result.get("source_file", "")).endswith(_CSHARP_SUFFIXES) + for result in per_file + if isinstance(result, dict) + ) and not any( + str(n.get("source_file", "")).endswith(_CSHARP_SUFFIXES) for n in all_nodes + ): + return + + node_by_id = {n.get("id"): n for n in all_nodes} + + implementers: dict[str, set[str]] = {} + methods_of: dict[str, dict[str, set[str]]] = {} + for e in all_edges: + rel = e.get("relation") + if rel == "implements": + implementers.setdefault(e.get("target"), set()).add(e.get("source")) + elif rel == "method": + owner, method_nid = e.get("source"), e.get("target") + mnode = node_by_id.get(method_nid) + if mnode is None or not _is_csharp(mnode): + continue + name = _method_label(mnode) + if name: + # A set, so the same method arriving on two `method` edges cannot + # look like two same-named methods and trip the guard below. + methods_of.setdefault(owner, {}).setdefault(name, set()).add(method_nid) + + if not implementers or not methods_of: + return + + # Scoped to this relation: another edge between the two members, whatever it + # is, says nothing about whether the dispatch link is already there. + existing_pairs = { + (e.get("source"), e.get("target")) + for e in all_edges + if e.get("relation") == DISPATCH_RELATION + } + new_edges: list[dict] = [] + + for interface_nid, impls in implementers.items(): + if len(impls) != 1: + continue + impl_nid = next(iter(impls)) + interface_node = node_by_id.get(interface_nid) + impl_node = node_by_id.get(impl_nid) + if interface_node is None or impl_node is None: + continue + # Both ends must be C# declarations. A sourceless stub minted for a + # dangling reference carries no members worth dispatching to, and a + # cross-language pair is a name collision rather than an implementation. + if not _is_csharp(interface_node) or not _is_csharp(impl_node): + continue + + impl_methods = methods_of.get(impl_nid, {}) + for name, declared in methods_of.get(interface_nid, {}).items(): + if len(declared) != 1: + continue + 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": 0.9, + "source_file": str(impl_node.get("source_file", "")), + "source_location": impl_node.get("source_location"), + "weight": 1.0, + }) + + all_edges.extend(new_edges) diff --git a/graphify/extract.py b/graphify/extract.py index 36117582..89082af8 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -22,6 +22,7 @@ from .resolver_registry import ( run_language_resolvers, ) from .ruby_resolution import resolve_ruby_member_calls +from .csharp_dispatch import resolve_csharp_interface_dispatch from .pascal_resolution import resolve_pascal_inherited_calls # --- migrated to graphify/extractors/ (see graphify/extractors/MIGRATION.md) --- @@ -4125,6 +4126,15 @@ register_language_resolver( "csharp_qualified_calls", frozenset({".cs"}), _resolve_csharp_qualified_calls ) ) +# C# member-level interface dispatch (#3003): a call through an injected +# dependency lands on the interface's method, so the implementation sits in the +# graph unreachable from the call site. Lives in graphify.csharp_dispatch; +# registered here as a consumer of the framework, like the Pascal resolver. +register_language_resolver( + LanguageResolver( + "csharp_interface_dispatch", frozenset({".cs"}), resolve_csharp_interface_dispatch + ) +) # Inline markdown link: [text](target "optional title"). The negative lookbehind diff --git a/tests/test_csharp_interface_dispatch.py b/tests/test_csharp_interface_dispatch.py new file mode 100644 index 00000000..0735ffeb --- /dev/null +++ b/tests/test_csharp_interface_dispatch.py @@ -0,0 +1,234 @@ +"""C# member-level interface dispatch (#3003). + +`_report.Build()` on an injected `IReport` resolves to `IReport.Build()`, which +is what the call site names. The implementing `Report.Build()` is a separate +node with nothing joining the two, so a directed walk stops at the interface +and every chain through an injected dependency is cut there. + +`resolve_csharp_interface_dispatch` links the interface's method to the +implementing method when the interface has exactly one implementer and that +implementer owns exactly one method of the same name. Ambiguity at either step +leaves the pair alone, the same single-owner guard the Pascal and Ruby +resolvers use. +""" +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) + dispatch = {(e["source"], e["target"]) for e in r["edges"] + if e["relation"] == "dispatches_to"} + return dispatch, 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 _reachable(r, start: str) -> set[str]: + adjacency: dict[str, list[str]] = {} + for e in r["edges"]: + adjacency.setdefault(e["source"], []).append(e["target"]) + seen = {start} + queue = [start] + while queue: + for nxt in adjacency.get(queue.pop(0), []): + if nxt not in seen: + seen.add(nxt) + queue.append(nxt) + return seen + + +_INJECTED = { + "IReport.cs": "public interface IReport { void Build(); }\n", + "Report.cs": ( + "public class Report : IReport {\n" + " public void Build() { Format(); }\n" + " public void Format() { }\n" + "}\n" + ), + "Runner.cs": ( + "public class Runner {\n" + " private readonly IReport _report;\n" + " public Runner(IReport report) { _report = report; }\n" + " public void Go() { _report.Build(); }\n" + "}\n" + ), +} + + +def test_single_implementer_links_the_interface_method(tmp_path): + dispatch, r = _extract(tmp_path, _INJECTED) + assert (_find(r, ".Build()", "ireport"), _find(r, ".Build()", "report_report")) in dispatch + + +def test_chain_through_an_injected_dependency_becomes_reachable(tmp_path): + dispatch, r = _extract(tmp_path, _INJECTED) + assert dispatch + # Go -> IReport.Build -> Report.Build -> Format + assert _find(r, ".Format()", "report") in _reachable(r, _find(r, ".Go()", "runner")) + + +def test_the_call_to_the_interface_method_is_kept(tmp_path): + # Additive: the call site really does name the interface. + _, r = _extract(tmp_path, _INJECTED) + calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"} + assert (_find(r, ".Go()", "runner"), _find(r, ".Build()", "ireport")) in calls + + +def test_two_implementers_produce_no_edge(tmp_path): + dispatch, _ = _extract(tmp_path, {"S.cs": ( + "public interface IR { void M(); }\n" + "public class A : IR { public void M() { } }\n" + "public class B : IR { public void M() { } }\n" + )}) + assert not dispatch + + +def test_no_matching_member_produces_no_edge(tmp_path): + dispatch, _ = _extract(tmp_path, {"S.cs": ( + "public interface IR { void Build(); }\n" + "public class A : IR { public void Other() { } }\n" + )}) + 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. + 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" + )}) + assert (_find(r, ".M()", "ir"), _find(r, ".M()", "s_a")) in dispatch + + +def test_abstract_base_class_is_out_of_scope(tmp_path): + # `class Impl : Base` is an `inherits` edge, and a base method may well be + # the real target, so walking inheritance is a different bet from dispatch. + dispatch, _ = _extract(tmp_path, {"S.cs": ( + "public abstract class Base { public abstract void M(); }\n" + "public class Impl : Base { public override void M() { } }\n" + )}) + assert not dispatch + + +def test_interface_hierarchy_is_not_walked_transitively(tmp_path): + # Known limit: IBase's only implementer is IChild, which declares nothing, + # so IBase.M never reaches Impl.M. Walking the chain is follow-up work. + dispatch, _ = _extract(tmp_path, {"S.cs": ( + "public interface IBase { void M(); }\n" + "public interface IChild : IBase { }\n" + "public class Impl : IChild { public void M() { } }\n" + )}) + assert not dispatch + + +def test_java_interface_and_implementation_are_untouched(tmp_path): + # The resolver is registered for .cs; a Java corpus must not gain the edge. + dispatch, _ = _extract(tmp_path, { + "IReport.java": "public interface IReport { void build(); }\n", + "Report.java": "public class Report implements IReport { public void build() { } }\n", + }) + assert not dispatch + +def test_case_only_member_difference_is_not_a_match(tmp_path): + # C# is case sensitive: an implementation must spell the member exactly, so + # `build` does not implement `Build` and must not be linked to it. + dispatch, _ = _extract(tmp_path, {"S.cs": ( + "public interface IR { void Build(); }\n" + "public class A : IR { public void build() { } }\n" + )}) + assert not dispatch + + +def test_an_unrelated_edge_between_the_members_does_not_suppress_the_link(tmp_path): + # The dedup is scoped to dispatches_to. Another relation between the two + # member nodes says nothing about whether the dispatch link is present. + from graphify.csharp_dispatch import resolve_csharp_interface_dispatch + + nodes = [ + {"id": "iface", "label": "IR", "source_file": "a.cs", "_callable_class": True}, + {"id": "impl", "label": "A", "source_file": "a.cs", "_callable_class": True}, + {"id": "iface_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, + {"id": "impl_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, + ] + edges = [ + {"source": "impl", "target": "iface", "relation": "implements"}, + {"source": "iface", "target": "iface_m", "relation": "method"}, + {"source": "impl", "target": "impl_m", "relation": "method"}, + {"source": "iface_m", "target": "impl_m", "relation": "references"}, + ] + resolve_csharp_interface_dispatch([], nodes, edges) + assert ("iface_m", "impl_m", "dispatches_to") in { + (e["source"], e["target"], e["relation"]) for e in edges + } + + +def test_an_existing_dispatch_edge_is_not_duplicated(tmp_path): + from graphify.csharp_dispatch import resolve_csharp_interface_dispatch + + nodes = [ + {"id": "iface", "label": "IR", "source_file": "a.cs", "_callable_class": True}, + {"id": "impl", "label": "A", "source_file": "a.cs", "_callable_class": True}, + {"id": "iface_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, + {"id": "impl_m", "label": ".M()", "source_file": "a.cs", "_callable": True}, + ] + edges = [ + {"source": "impl", "target": "iface", "relation": "implements"}, + {"source": "iface", "target": "iface_m", "relation": "method"}, + {"source": "impl", "target": "impl_m", "relation": "method"}, + {"source": "iface_m", "target": "impl_m", "relation": "dispatches_to"}, + ] + resolve_csharp_interface_dispatch([], nodes, edges) + assert sum(1 for e in edges if e["relation"] == "dispatches_to") == 1 + +def test_a_non_csharp_implementer_is_not_dispatched_to(tmp_path): + # `implements` resolves by name, so in a mixed corpus a Java class declaring + # `implements IReport` binds to the C# IReport when that is the only node + # with the name. Linking a C# member to a Java method would be a wrong edge. + dispatch, _ = _extract(tmp_path, { + "IReport.cs": "public interface IReport { void Build(); }\n", + "Report.java": "public class Report implements IReport { public void Build() { } }\n", + }) + assert not dispatch + + +def test_mixed_corpus_links_only_the_csharp_pair(tmp_path): + dispatch, r = _extract(tmp_path, { + "IReport.cs": "public interface IReport { void Build(); }\n", + "Report.cs": "public class Report : IReport { public void Build() { } }\n", + "IJob.java": "public interface IJob { void run(); }\n", + "Job.java": "public class Job implements IJob { public void run() { } }\n", + }) + by_id = {n["id"]: n for n in r["nodes"]} + linked = {(by_id[s].get("source_file"), by_id[t].get("source_file")) for s, t in dispatch} + assert linked == {("IReport.cs", "Report.cs")} + +def test_member_name_is_read_up_to_the_first_parenthesis(tmp_path): + # The pair match is keyed on this string. C# labels are `.Name()` today, so + # this pins the reduction itself rather than a shape the extractor emits. + from graphify.csharp_dispatch import _method_label + + assert _method_label({"label": ".Build()"}) == "Build" + assert _method_label({"label": "Build"}) == "Build" + assert _method_label({"label": ".Build(int, string)"}) == "Build" + assert _method_label({"label": ".build()"}) == "build"