a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
409 lines
14 KiB
Go
409 lines
14 KiB
Go
package resolver
|
|
|
|
import (
|
|
"strconv"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// Member-level C# interface-dispatch synthesis: the implements-family cascade.
|
|
//
|
|
// Roslyn — the reference C# resolver — treats an interface method and every
|
|
// method that implements it (directly, or through a base class that implements
|
|
// the interface) as ONE linked family, and reports the union of the family's
|
|
// call sites for every member. Two mechanisms feed that union:
|
|
//
|
|
// 1. Through-interface calls: `x.Convert(1)` where `x` is typed as the
|
|
// interface binds to the interface member node. Those calls must surface
|
|
// on every concrete implementation.
|
|
// 2. Sibling implementation calls: a converter's own `Convert(-number)`
|
|
// (a self/recursive or same-class call) binds directly to that class's
|
|
// method node — it never touches the interface node. Roslyn still reports
|
|
// that site for the interface method AND for every sibling implementation.
|
|
//
|
|
// A fan-out anchored only on calls bound to the interface member (mechanism 1)
|
|
// misses the dominant mass of real-corpus usages, which are mechanism 2. This
|
|
// pass therefore builds the full implements-family per (interface, method
|
|
// name) — the interface member plus the same-named method on every type whose
|
|
// implements/extends chain reaches the interface — and, for every call edge
|
|
// bound to ANY family member, synthesizes call edges to ALL other members.
|
|
//
|
|
// Tier: ast_inferred / ConfidenceTyped (non-speculative, type-keyed) — the
|
|
// same tier the sibling one-to-many dispatch passes use (MediatR Publish ->
|
|
// every handler, Spring publishEvent -> every listener), so the cascade rides
|
|
// in the DEFAULT find_usages / get_callers result. Family membership is
|
|
// established strictly through the implements/extends chain — never by name
|
|
// matching alone — so unrelated same-named methods are never linked.
|
|
|
|
// csharpIfaceDispatchCap bounds the family size (every interface-member
|
|
// overload node plus every implementing method node). C# overloads mint one
|
|
// node each, so a broadly-localised interface — one implementation per locale,
|
|
// several overloads per class — legitimately runs to ~70+ member nodes
|
|
// (Humanizer's INumberToWordsConverter.Convert family measures 72) and is
|
|
// exactly the shape this pass exists to cover, so the cap sits above it with
|
|
// headroom; a family wider than the cap is dropped whole as noise
|
|
// (pathological hub interfaces like a monorepo-wide Dispose).
|
|
const csharpIfaceDispatchCap = 128
|
|
|
|
// MetaViaMethodSetInference is the Meta["via"] marker the resolver stamps on
|
|
// EdgeImplements edges minted by structural method-set inference (as opposed
|
|
// to a source-declared base list). Hierarchy-walking passes that must follow
|
|
// only declared subtyping filter on it.
|
|
const MetaViaMethodSetInference = "method-set-inference"
|
|
|
|
// csharpCallSiteKey identifies one attributed call site. Line is part of the
|
|
// key on purpose: ground truth is line-based, so every call-site line of every
|
|
// family member must fan out to every other member, not one edge per
|
|
// (caller, callee) pair.
|
|
func csharpCallSiteKey(from, to, filePath string, line int) string {
|
|
return from + "\x00" + to + "\x00" + filePath + "\x00" + strconv.Itoa(line)
|
|
}
|
|
|
|
// ResolveCSharpInterfaceDispatch fans every call bound to a member of a C#
|
|
// implements-family out to all other members of that family. Returns the
|
|
// number of fan-out edges landed.
|
|
func ResolveCSharpInterfaceDispatch(g graph.Store) int {
|
|
if g == nil {
|
|
return 0
|
|
}
|
|
|
|
// Subtype adjacency over the resolved type hierarchy: super → subs.
|
|
// EdgeImplements and EdgeExtends both count — a class reaches an interface
|
|
// through any chain of base classes / base interfaces (e.g. Afrikaans
|
|
// extends Genderless which implements INumberToWordsConverter).
|
|
//
|
|
// Only SOURCE-DECLARED hierarchy edges qualify. The method-set inference
|
|
// pass mints EdgeImplements from every type whose bare method names cover
|
|
// an interface — with a single-method interface like IOrdinalizer.Convert
|
|
// that "links" every Convert-bearing class in the repo, and a family built
|
|
// over it would union unrelated hierarchies (NumberToWords converters into
|
|
// the Ordinalizer family). Those edges carry the inference marker; skip
|
|
// them. Origin cannot discriminate here: it is stamped/backfilled at
|
|
// different pipeline stages, so declared and inferred edges converge.
|
|
// This pass can run BEFORE the resolver has bound base-list targets (the
|
|
// pipeline settles hierarchy targets across several later passes), so an
|
|
// `unresolved::Name` target is resolved here by an exact, same-repo,
|
|
// unique type/interface name lookup — ambiguity means skip, never guess.
|
|
children := map[string][]string{}
|
|
for _, kind := range []graph.EdgeKind{graph.EdgeImplements, graph.EdgeExtends} {
|
|
for e := range g.EdgesByKind(kind) {
|
|
if e == nil || e.From == "" || e.To == "" {
|
|
continue
|
|
}
|
|
if e.Meta != nil && e.Meta["via"] == MetaViaMethodSetInference {
|
|
continue
|
|
}
|
|
toID := e.To
|
|
if graph.IsUnresolvedTarget(toID) {
|
|
toID = csharpResolveHierarchyTarget(g, e.From, toID)
|
|
if toID == "" {
|
|
continue
|
|
}
|
|
}
|
|
children[toID] = append(children[toID], e.From)
|
|
}
|
|
}
|
|
if len(children) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// implementation/interface type node id → member name → method nodes.
|
|
// Every overload matters: C# overloads mint one node each (Convert,
|
|
// Convert_L39, ...) sharing the same Name, and real call sites bind to any
|
|
// of them — a single-node-per-name projection would silently drop the
|
|
// overload the corpus actually calls through.
|
|
membersByType := csharpMemberMethodsAllByType(g)
|
|
if len(membersByType) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// Anchor discovery: every C# interface member method node, via its
|
|
// EdgeMemberOf owner, grouped by (interface, name) so the interface's own
|
|
// overload nodes land in ONE family rather than seeding duplicates.
|
|
type anchorGroup struct {
|
|
ifaceID string
|
|
name string
|
|
repoPrefix string
|
|
nodeIDs []string
|
|
}
|
|
anchorGroups := map[string]*anchorGroup{}
|
|
var anchorOrder []string
|
|
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
|
if e == nil || graph.IsUnresolvedTarget(e.To) {
|
|
continue
|
|
}
|
|
m := g.GetNode(e.From)
|
|
if m == nil || m.Kind != graph.KindMethod || m.Language != "csharp" || !csharpIsIfaceMember(m) {
|
|
continue
|
|
}
|
|
key := e.To + "\x00" + m.Name
|
|
ag := anchorGroups[key]
|
|
if ag == nil {
|
|
ag = &anchorGroup{ifaceID: e.To, name: m.Name, repoPrefix: m.RepoPrefix}
|
|
anchorGroups[key] = ag
|
|
anchorOrder = append(anchorOrder, key)
|
|
}
|
|
ag.nodeIDs = append(ag.nodeIDs, m.ID)
|
|
}
|
|
if len(anchorGroups) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// Descendant closure per interface, computed once and shared across that
|
|
// interface's anchors (one per member name).
|
|
descCache := map[string][]string{}
|
|
descendants := func(ifaceID string) []string {
|
|
if d, ok := descCache[ifaceID]; ok {
|
|
return d
|
|
}
|
|
var out []string
|
|
visited := map[string]bool{ifaceID: true}
|
|
queue := append([]string(nil), children[ifaceID]...)
|
|
for len(queue) > 0 {
|
|
t := queue[0]
|
|
queue = queue[1:]
|
|
if visited[t] {
|
|
continue
|
|
}
|
|
visited[t] = true
|
|
out = append(out, t)
|
|
queue = append(queue, children[t]...)
|
|
}
|
|
descCache[ifaceID] = out
|
|
return out
|
|
}
|
|
|
|
// Build families and the member → families index.
|
|
type family struct {
|
|
ifaceID string
|
|
members []string
|
|
}
|
|
var families []family
|
|
famsOfMember := map[string][]int{}
|
|
for _, key := range anchorOrder {
|
|
ag := anchorGroups[key]
|
|
memberIDs := append([]string(nil), ag.nodeIDs...)
|
|
anchorSet := map[string]bool{}
|
|
for _, id := range ag.nodeIDs {
|
|
anchorSet[id] = true
|
|
}
|
|
implCount := 0
|
|
for _, sub := range descendants(ag.ifaceID) {
|
|
byName := membersByType[sub]
|
|
if byName == nil {
|
|
continue
|
|
}
|
|
for _, m := range byName[ag.name] {
|
|
if m == nil || anchorSet[m.ID] {
|
|
continue
|
|
}
|
|
// In-repo only: cross-repo dispatch is CrossRepoResolver's domain.
|
|
if m.RepoPrefix != ag.repoPrefix {
|
|
continue
|
|
}
|
|
memberIDs = append(memberIDs, m.ID)
|
|
implCount++
|
|
}
|
|
}
|
|
// A family needs an interface member plus at least one implementation
|
|
// to cascade; one wider than the cap is dropped whole as noise.
|
|
if implCount == 0 || len(memberIDs) > csharpIfaceDispatchCap {
|
|
continue
|
|
}
|
|
idx := len(families)
|
|
families = append(families, family{ifaceID: ag.ifaceID, members: memberIDs})
|
|
for _, id := range memberIDs {
|
|
famsOfMember[id] = append(famsOfMember[id], idx)
|
|
}
|
|
}
|
|
if len(families) == 0 {
|
|
return 0
|
|
}
|
|
|
|
// Existing resolved call sites, keyed per line, so a fan-out edge never
|
|
// duplicates a real call at the same site (a caller that already reaches
|
|
// the member directly, or a prior run of this pass).
|
|
existing := map[string]bool{}
|
|
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
|
if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) {
|
|
continue
|
|
}
|
|
existing[csharpCallSiteKey(e.From, e.To, e.FilePath, e.Line)] = true
|
|
}
|
|
|
|
var batch []*graph.Edge
|
|
seen := map[string]bool{}
|
|
for e := range g.EdgesByKind(graph.EdgeCalls) {
|
|
if e == nil || e.IsSpeculative() || graph.IsUnresolvedTarget(e.To) {
|
|
continue
|
|
}
|
|
// Never re-fan from this pass's own output — real call sites only.
|
|
if e.Meta != nil && e.Meta[MetaSynthesizedBy] == SynthCSharpIfaceDispatch {
|
|
continue
|
|
}
|
|
fams := famsOfMember[e.To]
|
|
if len(fams) == 0 {
|
|
continue
|
|
}
|
|
// Tier-gate the SOURCE: a typed or scope-resolved binding (and an
|
|
// untagged legacy edge, which carries unknown — not low — confidence,
|
|
// mirroring SuppressRedundantTextMatches) fans from any caller. A
|
|
// text_matched binding is a name-only guess that can land on a family
|
|
// member from a completely unrelated same-named method (an
|
|
// IOrdinalizer.Convert self-call text-matched into the
|
|
// INumberToWordsConverter family); those fan ONLY when the caller is
|
|
// itself a member of the same family — the intra-family self/sibling-
|
|
// call shape the weak tier legitimately carries (overload self-calls
|
|
// bind text_matched).
|
|
weakSource := e.Origin == graph.OriginTextMatched
|
|
var fromFams []int
|
|
if weakSource {
|
|
fromFams = famsOfMember[e.From]
|
|
if len(fromFams) == 0 {
|
|
continue
|
|
}
|
|
}
|
|
for _, fi := range fams {
|
|
if weakSource && !containsInt(fromFams, fi) {
|
|
continue
|
|
}
|
|
f := families[fi]
|
|
for _, member := range f.members {
|
|
if member == e.To {
|
|
continue
|
|
}
|
|
k := csharpCallSiteKey(e.From, member, e.FilePath, e.Line)
|
|
if existing[k] || seen[k] {
|
|
continue
|
|
}
|
|
seen[k] = true
|
|
batch = append(batch, csharpIfaceDispatchEdge(e, member, f.ifaceID, len(f.members)-1))
|
|
}
|
|
}
|
|
}
|
|
for _, ne := range batch {
|
|
g.AddEdge(ne)
|
|
}
|
|
return len(batch)
|
|
}
|
|
|
|
// csharpIsIfaceMember reports whether n is a bodyless (or default) interface
|
|
// member declaration emitted by the C# extractor.
|
|
func csharpIsIfaceMember(n *graph.Node) bool {
|
|
if n == nil || n.Meta == nil {
|
|
return false
|
|
}
|
|
v, _ := n.Meta["iface_member"].(bool)
|
|
return v
|
|
}
|
|
|
|
// csharpIfaceDispatchEdge builds one fan-out call edge from the call site e to
|
|
// another family member, at the non-speculative ast_inferred tier so it
|
|
// survives the default speculative filter on find_usages / get_callers. The
|
|
// fan-out width rides in candidate_count for auditing; only one implementation
|
|
// runs at a site, but Roslyn reports the reference on every family member and
|
|
// this pass mirrors that.
|
|
func csharpIfaceDispatchEdge(e *graph.Edge, to, ifaceTypeID string, fanout int) *graph.Edge {
|
|
ne := &graph.Edge{
|
|
From: e.From, To: to, Kind: graph.EdgeCalls,
|
|
FilePath: e.FilePath, Line: e.Line,
|
|
Origin: graph.OriginASTInferred,
|
|
Confidence: ConfidenceTyped,
|
|
ConfidenceLabel: graph.ConfidenceLabelFor(graph.EdgeCalls, ConfidenceTyped),
|
|
Meta: map[string]any{
|
|
"via": "csharp-iface-dispatch",
|
|
"iface_type": ifaceTypeID,
|
|
"candidate_count": fanout,
|
|
},
|
|
}
|
|
StampSynthesized(ne, SynthCSharpIfaceDispatch)
|
|
return ne
|
|
}
|
|
|
|
// csharpMemberMethodsAllByType is the overload-preserving variant of
|
|
// memberMethodNodesByType: type node id → member name → EVERY method node with
|
|
// that name (C# overloads mint one node per declaration, so a name maps to
|
|
// several nodes). Uses the backend's MemberMethodsByType projection when
|
|
// available, else walks EdgeMemberOf.
|
|
func csharpMemberMethodsAllByType(g graph.Store) map[string]map[string][]*graph.Node {
|
|
if cap, ok := g.(graph.MemberMethodsByType); ok {
|
|
raw := cap.MemberMethodsByType()
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]map[string][]*graph.Node, len(raw))
|
|
for typeID, methods := range raw {
|
|
set := make(map[string][]*graph.Node, len(methods))
|
|
for _, m := range methods {
|
|
set[m.Name] = append(set[m.Name], &graph.Node{
|
|
ID: m.MethodID,
|
|
Kind: graph.KindMethod,
|
|
Name: m.Name,
|
|
FilePath: m.FilePath,
|
|
StartLine: m.StartLine,
|
|
RepoPrefix: m.RepoPrefix,
|
|
})
|
|
}
|
|
out[typeID] = set
|
|
}
|
|
return out
|
|
}
|
|
out := map[string]map[string][]*graph.Node{}
|
|
for e := range g.EdgesByKind(graph.EdgeMemberOf) {
|
|
method := g.GetNode(e.From)
|
|
if method == nil || method.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
set := out[e.To]
|
|
if set == nil {
|
|
set = make(map[string][]*graph.Node)
|
|
out[e.To] = set
|
|
}
|
|
set[method.Name] = append(set[method.Name], method)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// containsInt reports whether xs contains v. Family lists are tiny (a method
|
|
// belongs to one or two families), so a linear scan beats a map.
|
|
func containsInt(xs []int, v int) bool {
|
|
for _, x := range xs {
|
|
if x == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// csharpResolveHierarchyTarget binds an `unresolved::Name` base-list target to
|
|
// the unique same-repo C# type/interface node with that exact name, or ""
|
|
// when the caller is not C#, the name is unknown, or the name is ambiguous —
|
|
// a wrong hierarchy link unions unrelated families, so no guess is ever made.
|
|
func csharpResolveHierarchyTarget(g graph.Store, fromID, unresolvedTo string) string {
|
|
name := graph.UnresolvedName(unresolvedTo)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
from := g.GetNode(fromID)
|
|
if from == nil || from.Language != "csharp" {
|
|
return ""
|
|
}
|
|
var cand *graph.Node
|
|
for _, n := range g.FindNodesByNameInRepo(name, from.RepoPrefix) {
|
|
if n == nil || (n.Kind != graph.KindType && n.Kind != graph.KindInterface) {
|
|
continue
|
|
}
|
|
if n.Language != "csharp" {
|
|
continue
|
|
}
|
|
if cand != nil {
|
|
return "" // ambiguous — do not guess
|
|
}
|
|
cand = n
|
|
}
|
|
if cand == nil {
|
|
return ""
|
|
}
|
|
return cand.ID
|
|
}
|