Files
graphify/graphify/interface_dispatch.py
T
Deepanshu PalandClaude Opus 4.8 b8fcd29cb1 fix: snap cross-repo resolver confidence scores to the INFERRED rubric (#4045)
Snap the off-rubric INFERRED confidence scores emitted by the cross-repo
call/type resolvers and interface dispatch (0.8 and 0.9) to the nearest
canonical rubric tier (0.85), so every INFERRED edge carries one of the
documented discrete scores. Label-only change: no edge flips
EXTRACTED<->INFERRED and no edge is produced or dropped. Replaces the
substring-matching guard test with an AST-based one that scans every
module for off-rubric scores.

Squashed from #4045 (authorship preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-04 20:23:44 +01:00

160 lines
6.7 KiB
Python

"""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.85,
"source_file": str(impl_node.get("source_file", "")),
"source_location": impl_node.get("source_location"),
"weight": 1.0,
})
all_edges.extend(new_edges)