diff --git a/graphify/csharp_dispatch.py b/graphify/csharp_dispatch.py index 4982e6772..5b5a1a899 100644 --- a/graphify/csharp_dispatch.py +++ b/graphify/csharp_dispatch.py @@ -8,147 +8,32 @@ 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. +The mechanism — single-implementer, single-owner, per-method `dispatches_to` +edges at INFERRED — lives in `graphify.interface_dispatch` so that another +language can add a gate rather than a copy; this module fixes the C# gate. +Both ends of a pair must be declared in a `.cs` file: `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. """ from __future__ import annotations +from graphify.interface_dispatch import ( # noqa: F401 (re-exported for callers) + DISPATCH_RELATION, + method_label as _method_label, + resolve_interface_dispatch, +) + _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) + """Link each single-implementer C# interface method to its implementation.""" + resolve_interface_dispatch(per_file, all_nodes, all_edges, suffixes=_CSHARP_SUFFIXES) diff --git a/graphify/interface_dispatch.py b/graphify/interface_dispatch.py new file mode 100644 index 000000000..ddb53f815 --- /dev/null +++ b/graphify/interface_dispatch.py @@ -0,0 +1,159 @@ +"""Member-level dispatch from an interface requirement to its single implementer. + +A call through an 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 method 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. + +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. + +The mechanism knows no language. Each language registers a thin wrapper +(`graphify.csharp_dispatch` for C# interfaces, #3003) that fixes the file +suffixes both ends of a pair must be declared in: `implements` is resolved by +name, so a same-named type from another language is a collision, not an +implementation. A language whose interfaces have the same shape (Swift +protocols, for one) adds a wrapper, not a second copy of the algorithm. +""" +from __future__ import annotations + +DISPATCH_RELATION = "dispatches_to" + + +def declared_in(node: dict | None, suffixes: tuple[str, ...]) -> bool: + """True when the node is a declaration that lives in a file with one of ``suffixes``. + + Every end of a dispatch pair has to pass this. 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 node: + return False + source_file = node.get("source_file") + return bool(source_file) and str(source_file).endswith(suffixes) + + +def method_label(node: dict) -> str: + """Return a method node's bare name, for matching. + + Case is kept: C# and Swift are 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 extractors label 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_interface_dispatch( + per_file: list[dict], + all_nodes: list[dict], + all_edges: list[dict], + *, + suffixes: tuple[str, ...], +) -> 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. ``suffixes`` is + the language gate: the interface and the implementer must both be declared + in a file with one of these extensions. + """ + if not any( + str(result.get("source_file", "")).endswith(suffixes) + for result in per_file + if isinstance(result, dict) + ) and not any( + str(n.get("source_file", "")).endswith(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 declared_in(mnode, suffixes): + 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 declarations in this language's files. 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 declared_in(interface_node, suffixes) or not declared_in(impl_node, suffixes): + 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)