package mcp import ( "context" "encoding/json" "strings" "testing" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap" wire "github.com/gortexhq/gcx-go" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/query" ) // buildWinnowGraph creates a tiny deterministic graph with four functions // across two files and language tags. Edges are shaped so fan-in / // fan-out differ per node, which lets tests assert ordering by the // structural axes. func buildWinnowGraph(t *testing.T) *Server { t.Helper() g := graph.New() addFn := func(id, name, file, lang string) { g.AddNode(&graph.Node{ ID: id, Kind: graph.KindFunction, Name: name, FilePath: file, Language: lang, StartLine: 1, EndLine: 10, }) } addFn("svc/auth.go::Login", "Login", "svc/auth.go", "go") addFn("svc/auth.go::Logout", "Logout", "svc/auth.go", "go") addFn("svc/auth.go::validateToken", "validateToken", "svc/auth.go", "go") addFn("handlers/user.go::HandleUser", "HandleUser", "handlers/user.go", "go") // A TS node to exercise the language filter. g.AddNode(&graph.Node{ ID: "web/api.ts::fetchUser", Kind: graph.KindFunction, Name: "fetchUser", FilePath: "web/api.ts", Language: "typescript", StartLine: 1, EndLine: 5, }) // Call graph: HandleUser -> Login -> validateToken (x2 refs) g.AddEdge(&graph.Edge{From: "handlers/user.go::HandleUser", To: "svc/auth.go::Login", Kind: graph.EdgeCalls}) g.AddEdge(&graph.Edge{From: "svc/auth.go::Login", To: "svc/auth.go::validateToken", Kind: graph.EdgeCalls}) g.AddEdge(&graph.Edge{From: "svc/auth.go::Logout", To: "svc/auth.go::validateToken", Kind: graph.EdgeCalls}) g.AddEdge(&graph.Edge{From: "svc/auth.go::HandleUser", To: "svc/auth.go::validateToken", Kind: graph.EdgeReferences}) eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil) // Seed community membership so community-filter tests have data. srv.communities = &analysis.CommunityResult{ NodeToComm: map[string]string{ "svc/auth.go::Login": "community-0", "svc/auth.go::Logout": "community-0", "svc/auth.go::validateToken": "community-0", "handlers/user.go::HandleUser": "community-1", }, Communities: []analysis.Community{ {ID: "community-0", Label: "authz"}, {ID: "community-1", Label: "handlers"}, }, } return srv } func TestWinnowSymbols_FilterByKindAndPath(t *testing.T) { srv := buildWinnowGraph(t) c := winnowConstraints{ Kinds: []graph.NodeKind{graph.KindFunction}, PathPrefix: []string{"svc/"}, Limit: 10, } rows := srv.winnowSymbols(context.Background(), c, nil) require.NotEmpty(t, rows) for _, r := range rows { assert.Equal(t, graph.KindFunction, r.Node.Kind) assert.True(t, strings.HasPrefix(r.Node.FilePath, "svc/"), "path %q violates prefix filter", r.Node.FilePath) } } func TestWinnowSymbols_LanguageFilterExcludesOthers(t *testing.T) { srv := buildWinnowGraph(t) c := winnowConstraints{Language: "typescript", Limit: 10} rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "web/api.ts::fetchUser", rows[0].Node.ID) } func TestWinnowSymbols_MinFanInDropsLeaves(t *testing.T) { srv := buildWinnowGraph(t) // validateToken has fan_in=3 (2 calls + 1 reference); Login has 1; // Logout + HandleUser + fetchUser have 0. c := winnowConstraints{MinFanIn: 2, Limit: 10} rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "svc/auth.go::validateToken", rows[0].Node.ID) assert.Equal(t, 3, rows[0].FanIn) } func TestWinnowSymbols_RankingByFanIn(t *testing.T) { srv := buildWinnowGraph(t) c := winnowConstraints{Kinds: []graph.NodeKind{graph.KindFunction}, Language: "go", Limit: 10} rows := srv.winnowSymbols(context.Background(), c, nil) require.NotEmpty(t, rows) // validateToken should top the list because fan_in dominates when no // text_match is provided. assert.Equal(t, "svc/auth.go::validateToken", rows[0].Node.ID) assert.Greater(t, rows[0].Score, 0.0) assert.NotEmpty(t, rows[0].Contributions) assert.Contains(t, rows[0].Contributions, "fan_in") } func TestWinnowSymbols_CommunityFilterByIDAndLabel(t *testing.T) { srv := buildWinnowGraph(t) byID := srv.winnowSymbols(context.Background(), winnowConstraints{Community: "community-0", Limit: 10}, nil) byLabel := srv.winnowSymbols(context.Background(), winnowConstraints{Community: "authz", Limit: 10}, nil) require.Equal(t, len(byID), len(byLabel)) require.Greater(t, len(byID), 0) for _, r := range byID { assert.Equal(t, "community-0", r.Community) } for _, r := range byLabel { assert.Equal(t, "community-0", r.Community) } } func TestWinnowSymbols_ChurnFilter(t *testing.T) { srv := buildWinnowGraph(t) srv.symHistory.Record("svc/auth.go::Login", false) srv.symHistory.Record("svc/auth.go::Login", true) srv.symHistory.Record("svc/auth.go::Logout", false) c := winnowConstraints{MinChurn: 2, Limit: 10} rows := srv.winnowSymbols(context.Background(), c, nil) require.Len(t, rows, 1) assert.Equal(t, "svc/auth.go::Login", rows[0].Node.ID) assert.Equal(t, 2, rows[0].Churn) } func TestWinnowSymbols_RequiresConstraint(t *testing.T) { srv := buildWinnowGraph(t) req := mcplib.CallToolRequest{} req.Params.Name = "winnow_symbols" req.Params.Arguments = map[string]any{} result, err := srv.handleWinnowSymbols(context.Background(), req) require.NoError(t, err) require.True(t, result.IsError) msg := result.Content[0].(mcplib.TextContent).Text assert.Contains(t, msg, "at least one constraint") } func TestWinnowSymbols_InvalidKindRejected(t *testing.T) { srv := buildWinnowGraph(t) req := mcplib.CallToolRequest{} req.Params.Arguments = map[string]any{"kind": "bogus"} result, err := srv.handleWinnowSymbols(context.Background(), req) require.NoError(t, err) require.True(t, result.IsError) assert.Contains(t, result.Content[0].(mcplib.TextContent).Text, "invalid kind") } func TestWinnowSymbols_JSONResponseShape(t *testing.T) { srv := buildWinnowGraph(t) req := mcplib.CallToolRequest{} req.Params.Arguments = map[string]any{ "kind": "function", "min_fan_in": float64(1), } result, err := srv.handleWinnowSymbols(context.Background(), req) require.NoError(t, err) require.False(t, result.IsError) var payload map[string]any raw := result.Content[0].(mcplib.TextContent).Text require.NoError(t, json.Unmarshal([]byte(raw), &payload)) assert.Contains(t, payload, "results") assert.Contains(t, payload, "total") assert.Contains(t, payload, "weights") results, _ := payload["results"].([]any) require.NotEmpty(t, results) first, _ := results[0].(map[string]any) assert.Contains(t, first, "score") assert.Contains(t, first, "fan_in") assert.Contains(t, first, "contributions") } func TestWinnowSymbols_GCXRoundTrip(t *testing.T) { srv := buildWinnowGraph(t) req := mcplib.CallToolRequest{} req.Params.Arguments = map[string]any{ "kind": "function", "format": "gcx", } result, err := srv.handleWinnowSymbols(context.Background(), req) require.NoError(t, err) require.False(t, result.IsError) payload := result.Content[0].(mcplib.TextContent).Text dec := wire.NewDecoder(strings.NewReader(payload)) h, err := dec.Header() require.NoError(t, err) assert.Equal(t, "winnow_symbols", h.Tool) assert.Contains(t, h.Fields, "score") assert.Contains(t, h.Fields, "contributions") assert.Contains(t, h.Meta, "weights") rows, err := dec.All() require.NoError(t, err) require.NotEmpty(t, rows) // contributions is a sorted pipe-joined list: "fan_in=0.700|fan_out=0.300" for _, row := range rows { if s := row["contributions"]; s != "" { parts := strings.Split(s, "|") for _, p := range parts { assert.Contains(t, p, "=") } } } } func TestEncodeWinnowSymbols_EmptyContributionsOk(t *testing.T) { rows := []winnowResult{ { Node: &graph.Node{ ID: "a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "a.go", StartLine: 3, Meta: map[string]any{"signature": "func Foo()"}, }, }, } payload, err := encodeWinnowSymbols(rows, 1, 10, nil) require.NoError(t, err) dec := wire.NewDecoder(strings.NewReader(string(payload))) h, err := dec.Header() require.NoError(t, err) assert.Equal(t, "winnow_symbols", h.Tool) all, err := dec.All() require.NoError(t, err) require.Len(t, all, 1) assert.Equal(t, "", all[0]["contributions"]) } func TestFormatContributions_DeterministicOrdering(t *testing.T) { m := map[string]float64{ "fan_in": 0.5, "text_match": 0.9, "churn": 0.2, } got := formatContributions(m) // Sorted alphabetically. assert.Equal(t, "churn=0.200|fan_in=0.500|text_match=0.900", got) }