Files
zzet--gortex/internal/resolver/csharp_iface_dispatch_test.go
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

405 lines
16 KiB
Go

package resolver
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser/languages"
)
// isIfaceDispatchEdge reports whether e is a fan-out edge minted by the C#
// interface-dispatch synthesizer.
func isIfaceDispatchEdge(e *graph.Edge) bool {
return e != nil && e.Kind == graph.EdgeCalls && e.Meta != nil &&
e.Meta[MetaSynthesizedBy] == SynthCSharpIfaceDispatch
}
// buildCSharpResolverGraph extracts each C# fixture with the real extractor and
// loads its nodes/edges into a fresh graph — the same unresolved shape a live
// index produces, ready for New(g).ResolveAll().
func buildCSharpResolverGraph(t *testing.T, files map[string]string) graph.Store {
t.Helper()
g := graph.New()
e := languages.NewCSharpExtractor()
for path, src := range files {
r, err := e.Extract(path, []byte(src))
require.NoError(t, err, "csharp extract %s", path)
for _, n := range r.Nodes {
g.AddNode(n)
}
for _, ed := range r.Edges {
g.AddEdge(ed)
}
}
return g
}
// TestResolveCSharpInterfaceDispatch_EndToEnd drives the full path: the
// extractor emits the interface member + the through-interface call, ResolveAll
// binds the call to the interface member, and the synthesizer fans it out to
// both concrete implementations at the speculative tier.
func TestResolveCSharpInterfaceDispatch_EndToEnd(t *testing.T) {
g := buildCSharpResolverGraph(t, map[string]string{
"IConverter.cs": `namespace App {
public interface IConverter {
string Convert(int n);
}
}`,
"English.cs": `namespace App {
public class EnglishConverter : IConverter {
public string Convert(int n) { return "en"; }
}
}`,
"Ukrainian.cs": `namespace App {
public class UkrainianConverter : IConverter {
public string Convert(int n) { return "uk"; }
}
}`,
"Runner.cs": `namespace App {
public class Runner {
public void Run(IConverter c) {
IConverter conv = c;
conv.Convert(1);
}
}
}`,
})
New(g).ResolveAll()
callerID := "Runner.cs::Runner.Run"
ifaceMember := "IConverter.cs::IConverter.Convert"
// (a) the through-interface call binds to the interface member.
require.Contains(t, callTargetsFrom(g, callerID), ifaceMember,
"through-interface call should bind to the interface member node")
// (b) fan-out to both implementations at the ast_inferred tier.
n := ResolveCSharpInterfaceDispatch(g)
require.Equal(t, 2, n, "one fan-out edge per implementation")
fanout := map[string]*graph.Edge{}
for _, e := range g.GetOutEdges(callerID) {
if isIfaceDispatchEdge(e) {
fanout[e.To] = e
}
}
for _, id := range []string{"English.cs::EnglishConverter.Convert", "Ukrainian.cs::UkrainianConverter.Convert"} {
e := fanout[id]
require.NotNil(t, e, "expected fan-out edge to %s", id)
assert.Equal(t, graph.OriginASTInferred, e.Origin)
assert.False(t, e.IsSpeculative(),
"fan-out must NOT be speculative or the default find_usages filter drops it")
assert.Equal(t, SynthCSharpIfaceDispatch, e.Meta[MetaSynthesizedBy])
}
// (c) find_usages-equivalent: the through-interface call site surfaces as an
// in-edge on each concrete implementation AND survives the default
// speculative filter, so find_usages(<Impl>.Convert) returns it.
for _, id := range []string{"English.cs::EnglishConverter.Convert", "Ukrainian.cs::UkrainianConverter.Convert"} {
found := false
for _, e := range g.GetInEdges(id) {
if e.From == callerID && e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
found = true
break
}
}
assert.True(t, found, "through-interface usage of %s must be a default-visible in-edge", id)
}
}
// TestResolveCSharpInterfaceDispatch_FamilyCascade drives the sibling
// self-call mechanism end-to-end: subclasses reach the interface through an
// abstract base class (extends -> implements), and a subclass's own recursive
// Convert call — which binds to its OWN method node, never the interface —
// must still surface as a usage of the interface member and of every sibling
// implementation, mirroring the reference resolver's family union.
func TestResolveCSharpInterfaceDispatch_FamilyCascade(t *testing.T) {
g := buildCSharpResolverGraph(t, map[string]string{
"IConverter.cs": `namespace App {
public interface IConverter {
string Convert(long n);
}
}`,
"BaseConverter.cs": `namespace App {
public abstract class BaseConverter : IConverter {
public abstract string Convert(long n);
}
}`,
"Afrikaans.cs": `namespace App {
public class AfrikaansConverter : BaseConverter {
public override string Convert(long n) {
if (n < 0) {
return "minus " + Convert(-n);
}
return "af";
}
}
}`,
"Serbian.cs": `namespace App {
public class SerbianConverter : BaseConverter {
public override string Convert(long n) { return "sr"; }
}
}`,
"Danish.cs": `namespace App {
public class DanishConverter : BaseConverter {
public override string Convert(long n) { return Convert(n, false); }
public string Convert(long n, bool suffix) {
if (n < 0) {
return "minus " + Convert(-n, suffix);
}
return "da";
}
}
}`,
"Unrelated.cs": `namespace App {
public class Codec {
public string Convert(long n) { return "x"; }
}
}`,
})
New(g).ResolveAll()
selfCaller := "Afrikaans.cs::AfrikaansConverter.Convert"
// Precondition: the recursive call binds to the caller's own method node
// (same-class resolution) — NOT the interface — which is exactly why an
// interface-anchored fan-out misses it.
require.Contains(t, callTargetsFrom(g, selfCaller), selfCaller,
"the recursive Convert call should bind to the class's own method")
n := ResolveCSharpInterfaceDispatch(g)
require.Greater(t, n, 0, "the family cascade must land fan-out edges")
// The self-call site must surface on the sibling implementation and on the
// interface member — the find_usages-equivalent in-edge walk, default tier.
for _, id := range []string{"Serbian.cs::SerbianConverter.Convert", "IConverter.cs::IConverter.Convert"} {
found := false
for _, e := range g.GetInEdges(id) {
if e.From == selfCaller && e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
found = true
break
}
}
assert.True(t, found, "the sibling self-call must be a default-visible usage of %s", id)
}
// Overload split: C# overloads mint one node per declaration (Convert,
// Convert_L<line>, ...) and the recursive call inside Danish's second
// overload binds to one of Danish's own nodes — the cascade must still
// carry it to the sibling implementation, whichever overload node it
// landed on.
danishAttributed := false
for _, e := range g.GetInEdges("Serbian.cs::SerbianConverter.Convert") {
if strings.HasPrefix(e.From, "Danish.cs::DanishConverter.Convert") &&
e.Kind == graph.EdgeCalls && !e.IsSpeculative() {
danishAttributed = true
break
}
}
assert.True(t, danishAttributed,
"a self-call bound to an overload node must still cascade to sibling implementations")
// Precision: the unrelated same-named method is outside the
// implements-family and must receive nothing.
for _, e := range g.GetInEdges("Unrelated.cs::Codec.Convert") {
assert.NotEqual(t, SynthCSharpIfaceDispatch, edgeMetaValue(e, MetaSynthesizedBy),
"a same-named method with no implements/extends link must not join the family")
}
}
// edgeMetaValue reads one Meta key off an edge, tolerating nil Meta.
func edgeMetaValue(e *graph.Edge, key string) any {
if e == nil || e.Meta == nil {
return nil
}
return e.Meta[key]
}
// TestResolveCSharpInterfaceDispatch_UnresolvedHierarchy models the pipeline
// state the synthesizer actually runs in: call edges already bound, base-list
// targets still `unresolved::Name` (hierarchy settles in later passes). The
// pass must bind those names itself — exact, same-repo, unique — and cascade.
func TestResolveCSharpInterfaceDispatch_UnresolvedHierarchy(t *testing.T) {
g := buildCSharpResolverGraph(t, map[string]string{
"IConverter.cs": `namespace App {
public interface IConverter {
string Convert(long n);
}
}`,
"BaseConverter.cs": `namespace App {
public abstract class BaseConverter : IConverter {
public abstract string Convert(long n);
}
}`,
"Afrikaans.cs": `namespace App {
public class AfrikaansConverter : BaseConverter {
public override string Convert(long n) { return "af"; }
}
}`,
"Serbian.cs": `namespace App {
public class SerbianConverter : BaseConverter {
public override string Convert(long n) { return "sr"; }
}
}`,
})
// NO ResolveAll: base-list edges keep their unresolved:: targets. Bind one
// call edge by hand — the state the resolver leaves calls in by synth time.
selfCaller := "Afrikaans.cs::AfrikaansConverter.Convert"
g.AddEdge(&graph.Edge{From: selfCaller, To: selfCaller, Kind: graph.EdgeCalls,
FilePath: "Afrikaans.cs", Line: 3, Origin: graph.OriginASTResolved})
n := ResolveCSharpInterfaceDispatch(g)
require.Greater(t, n, 0, "the cascade must work over unresolved base-list targets")
for _, id := range []string{"Serbian.cs::SerbianConverter.Convert", "IConverter.cs::IConverter.Convert"} {
found := false
for _, e := range g.GetInEdges(id) {
if e.From == selfCaller && isIfaceDispatchEdge(e) {
found = true
break
}
}
assert.True(t, found, "self-call must cascade to %s through unresolved hierarchy names", id)
}
}
// TestResolveCSharpInterfaceDispatch_WeakSourceGate pins the precision rule
// for text_matched sources: a name-only binding that lands on a family member
// from an UNRELATED same-named method (a different interface's Convert) must
// not fan into the family, while an intra-family text_matched self-call (the
// shape overload self-calls bind at) must.
func TestResolveCSharpInterfaceDispatch_WeakSourceGate(t *testing.T) {
g := graph.New()
addType := func(file, name string) string {
id := file + "::" + name
g.AddNode(&graph.Node{ID: id, Kind: graph.KindType, Name: name, FilePath: file, Language: "csharp"})
return id
}
addMethod := func(file, typ, name string, iface bool) string {
id := file + "::" + typ + "." + name
meta := map[string]any{"receiver": typ}
if iface {
meta["iface_member"] = true
}
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: name, FilePath: file, Language: "csharp", Meta: meta})
g.AddEdge(&graph.Edge{From: id, To: file + "::" + typ, Kind: graph.EdgeMemberOf, FilePath: file})
return id
}
// Family 1: IConv.Do <- ConvA.Do, ConvB.Do
iconv := addType("IConv.cs", "IConv")
iconvDo := addMethod("IConv.cs", "IConv", "Do", true)
_ = iconvDo
convA := addType("A.cs", "ConvA")
aDo := addMethod("A.cs", "ConvA", "Do", false)
convB := addType("B.cs", "ConvB")
bDo := addMethod("B.cs", "ConvB", "Do", false)
g.AddEdge(&graph.Edge{From: convA, To: iconv, Kind: graph.EdgeImplements, FilePath: "A.cs", Origin: graph.OriginASTInferred})
g.AddEdge(&graph.Edge{From: convB, To: iconv, Kind: graph.EdgeImplements, FilePath: "B.cs", Origin: graph.OriginASTInferred})
// Unrelated type with a same-named method, outside any family hierarchy.
ord := addType("Ord.cs", "Ordinalizer")
ordDo := addMethod("Ord.cs", "Ordinalizer", "Do", false)
// Cross-family pollution: the Ordinalizer's own Do call was text-matched
// onto a family member. It must NOT fan into the family.
g.AddEdge(&graph.Edge{From: ordDo, To: aDo, Kind: graph.EdgeCalls, FilePath: "Ord.cs", Line: 7,
Origin: graph.OriginTextMatched})
// Intra-family text_matched self-call (the overload self-call shape): the
// caller is a family member, so it fans to the sibling and the interface.
g.AddEdge(&graph.Edge{From: aDo, To: aDo, Kind: graph.EdgeCalls, FilePath: "A.cs", Line: 9,
Origin: graph.OriginTextMatched})
// Method-set inference pollution: an inference-marked implements edge
// (the shape the structural inference pass mints — every Convert-bearing
// class "implements" a single-method interface) must NOT pull the
// unrelated type into the family.
g.AddEdge(&graph.Edge{From: ord, To: iconv, Kind: graph.EdgeImplements, FilePath: "Ord.cs",
Meta: map[string]any{"via": MetaViaMethodSetInference}})
n := ResolveCSharpInterfaceDispatch(g)
require.Equal(t, 2, n, "only the intra-family self-call fans (to sibling + interface member)")
for _, e := range g.GetInEdges(ordDo) {
assert.False(t, isIfaceDispatchEdge(e),
"a method-set-inferred implements edge must not admit a type into the family")
}
var fromCallers []string
for _, e := range g.GetInEdges(bDo) {
if isIfaceDispatchEdge(e) {
fromCallers = append(fromCallers, e.From)
}
}
assert.Equal(t, []string{aDo}, fromCallers,
"the sibling receives the family self-call but never the unrelated text-matched caller")
}
// TestResolveCSharpInterfaceDispatch_FanoutTierAndCap uses a hand-built graph to
// pin the fan-out shape, provenance/tier, dedup, and the fan-out cap.
func TestResolveCSharpInterfaceDispatch_FanoutTierAndCap(t *testing.T) {
newGraph := func(nImpls int) graph.Store {
g := graph.New()
g.AddNode(&graph.Node{ID: "I.cs::IC", Kind: graph.KindInterface, Name: "IC", FilePath: "I.cs", Language: "csharp"})
g.AddNode(&graph.Node{ID: "I.cs::IC.Do", Kind: graph.KindMethod, Name: "Do", FilePath: "I.cs", Language: "csharp",
Meta: map[string]any{"receiver": "IC", "iface_member": true}})
g.AddEdge(&graph.Edge{From: "I.cs::IC.Do", To: "I.cs::IC", Kind: graph.EdgeMemberOf, FilePath: "I.cs"})
for i := 0; i < nImpls; i++ {
typ := fmt.Sprintf("Impl%d", i)
file := typ + ".cs"
g.AddNode(&graph.Node{ID: file + "::" + typ, Kind: graph.KindType, Name: typ, FilePath: file, Language: "csharp"})
g.AddNode(&graph.Node{ID: file + "::" + typ + ".Do", Kind: graph.KindMethod, Name: "Do", FilePath: file, Language: "csharp",
Meta: map[string]any{"receiver": typ}})
g.AddEdge(&graph.Edge{From: file + "::" + typ + ".Do", To: file + "::" + typ, Kind: graph.EdgeMemberOf, FilePath: file})
g.AddEdge(&graph.Edge{From: file + "::" + typ, To: "I.cs::IC", Kind: graph.EdgeImplements, FilePath: file, Origin: graph.OriginASTInferred})
}
g.AddNode(&graph.Node{ID: "C.cs::App.Run", Kind: graph.KindMethod, Name: "Run", FilePath: "C.cs", Language: "csharp",
Meta: map[string]any{"receiver": "App"}})
g.AddEdge(&graph.Edge{From: "C.cs::App.Run", To: "I.cs::IC.Do", Kind: graph.EdgeCalls, FilePath: "C.cs", Line: 3})
return g
}
t.Run("fan-out tier + dedup", func(t *testing.T) {
g := newGraph(2)
require.Equal(t, 2, ResolveCSharpInterfaceDispatch(g))
fanout := map[string]*graph.Edge{}
for _, e := range g.GetOutEdges("C.cs::App.Run") {
if isIfaceDispatchEdge(e) {
fanout[e.To] = e
}
}
require.Len(t, fanout, 2)
for _, id := range []string{"Impl0.cs::Impl0.Do", "Impl1.cs::Impl1.Do"} {
e := fanout[id]
require.NotNil(t, e, id)
assert.Equal(t, graph.OriginASTInferred, e.Origin)
assert.False(t, e.IsSpeculative())
assert.Equal(t, SynthCSharpIfaceDispatch, e.Meta[MetaSynthesizedBy])
assert.Equal(t, "C.cs", e.FilePath)
assert.Equal(t, 3, e.Line)
}
// Idempotent: a second run must not duplicate fan-out edges.
ResolveCSharpInterfaceDispatch(g)
got := 0
for _, e := range g.GetOutEdges("C.cs::App.Run") {
if isIfaceDispatchEdge(e) {
got++
}
}
assert.Equal(t, 2, got, "re-run must not duplicate fan-out edges")
})
t.Run("fan-out cap drops noise", func(t *testing.T) {
g := newGraph(csharpIfaceDispatchCap + 1)
assert.Equal(t, 0, ResolveCSharpInterfaceDispatch(g),
"a fan-out wider than the cap is dropped as noise")
})
}