feat(csharp): link interface methods to their single implementation (#3003)

A call through an injected interface dependency lands on the interface method, leaving the
implementation unreachable from the call site. When an interface has exactly one
implementing class, add a dispatches_to edge from each interface method to the matching
implementation method. Guarded to avoid false links: requires a real implements edge, a
single implementer, and a single case-sensitively same-named method; both ends must be C#;
0 or 2+ implementers emit nothing. Lives in graphify.csharp_dispatch as a registered
resolver.
This commit is contained in:
durmazoguzhan
2026-08-24 13:22:09 +01:00
committed by safishamsi
parent 7f476e1447
commit 7f928bc1d0
3 changed files with 398 additions and 0 deletions
+154
View File
@@ -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)
+10
View File
@@ -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
+234
View File
@@ -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"