mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-28 17:26:48 +00:00
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:
committed by
safishamsi
parent
7f476e1447
commit
7f928bc1d0
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user