Files
zzet--gortex/internal/mcp/tools_analyze_clusters_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

337 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"encoding/json"
"testing"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
func newClustersTestServer(t *testing.T) *Server {
t.Helper()
g := graph.New()
// Five members wired into a ring — a cohesive auth cluster that
// real Leiden detection collapses into one community of 5.
for _, id := range []string{"a", "b", "c", "d", "e"} {
g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: "auth/" + id + ".go", Language: "go"})
}
g.AddEdge(&graph.Edge{From: "a", To: "b", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "b", To: "c", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "c", To: "d", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "d", To: "e", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "e", To: "a", Kind: graph.EdgeCalls})
// Two-member cluster — mutually calling, so detection forms a
// real community of 2 that the default min_size=3 filters out.
for _, id := range []string{"x", "y"} {
g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: "utils/" + id + ".go", Language: "python"})
}
g.AddEdge(&graph.Edge{From: "x", To: "y", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "y", To: "x", Kind: graph.EdgeCalls})
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
// Seed s.communities so handlers that read it directly
// (handleAnalyzeConcepts) have data; the clusters handler
// recomputes from the graph through the incremental path.
s.analysisMu.Lock()
s.communities = analysis.DetectCommunities(g)
s.analysisMu.Unlock()
return s
}
func callAnalyzeClusters(t *testing.T, s *Server, args map[string]any) map[string]any {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := s.handleAnalyzeClusters(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
require.False(t, res.IsError)
tc, ok := res.Content[0].(mcp.TextContent)
require.True(t, ok)
var m map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
return m
}
func callAnalyzeConcepts(t *testing.T, s *Server, args map[string]any) map[string]any {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := s.handleAnalyzeConcepts(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
require.False(t, res.IsError)
tc, ok := res.Content[0].(mcp.TextContent)
require.True(t, ok)
var m map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.Text), &m))
return m
}
// clusters ----------------------------------------------------------
func TestClusters_DefaultMinSize(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{})
clusters, _ := out["clusters"].([]any)
require.Len(t, clusters, 1, "min_size default 3 drops the 2-member cluster")
row := clusters[0].(map[string]any)
assert.Equal(t, "community-0", row["id"])
assert.EqualValues(t, 5, row["size"].(float64))
}
func TestClusters_AlgorithmEchoed(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{})
assert.Equal(t, "leiden", out["algorithm"])
// The algorithm argument selects the detector and is echoed back.
for _, algo := range []string{"louvain", "spectral"} {
out := callAnalyzeClusters(t, s, map[string]any{"algorithm": algo})
assert.Equal(t, algo, out["algorithm"], "algorithm %q must be echoed", algo)
}
// An unknown algorithm is a clean error.
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{"algorithm": "bogus"}
res, err := s.handleAnalyzeClusters(context.Background(), req)
require.NoError(t, err)
require.True(t, res.IsError, "unknown algorithm must return an error")
}
func TestClusters_DensityCorrect(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{})
clusters, _ := out["clusters"].([]any)
row := clusters[0].(map[string]any)
// 5 intra edges (the a→b→c→d→e→a ring). Possible-directed-pairs
// = 5*4 = 20.
assert.InDelta(t, 0.25, row["density"].(float64), 1e-6, "5/20 = 0.25")
}
func TestClusters_FileSpread(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{})
clusters, _ := out["clusters"].([]any)
row := clusters[0].(map[string]any)
// 5 files / 5 members = 1.0.
assert.InDelta(t, 1.0, row["file_spread"].(float64), 1e-6)
}
func TestClusters_LanguageMix(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{})
clusters, _ := out["clusters"].([]any)
row := clusters[0].(map[string]any)
langs := row["languages"].(map[string]any)
assert.EqualValues(t, 5, langs["go"].(float64))
}
func TestClusters_LowerMinSizeReturnsSmaller(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{"min_size": 1})
clusters, _ := out["clusters"].([]any)
assert.Len(t, clusters, 2)
}
func TestClusters_PathPrefix(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeClusters(t, s, map[string]any{"path_prefix": "utils/", "min_size": 1})
clusters, _ := out["clusters"].([]any)
require.Len(t, clusters, 1)
assert.Equal(t, "community-1", clusters[0].(map[string]any)["id"])
}
func TestClusters_EmptyCommunities(t *testing.T) {
g := graph.New()
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callAnalyzeClusters(t, s, map[string]any{})
clusters, _ := out["clusters"].([]any)
assert.Empty(t, clusters)
assert.Equal(t, "leiden", out["algorithm"])
}
// newTieredClustersServer builds a server over a two-level hierarchy
// (modules of tight cliques) so the Leiden resolution knob visibly
// changes the partition through the handler.
func newTieredClustersServer(t *testing.T) *Server {
t.Helper()
g := graph.New()
node := func(m, c, i int) string {
return "m" + string(rune('0'+m)) + "_c" + string(rune('0'+c)) + "_n" + string(rune('0'+i))
}
const nm, cpm, cs = 4, 3, 4
for m := 0; m < nm; m++ {
for c := 0; c < cpm; c++ {
for i := 0; i < cs; i++ {
g.AddNode(&graph.Node{ID: node(m, c, i), Name: node(m, c, i), Kind: graph.KindFunction, FilePath: "pkg/" + node(m, c, i) + ".go", Language: "go"})
}
for i := 0; i < cs; i++ {
for j := i + 1; j < cs; j++ {
g.AddEdge(&graph.Edge{From: node(m, c, i), To: node(m, c, j), Kind: graph.EdgeCalls})
}
}
}
// within-module bridges between clique hubs.
g.AddEdge(&graph.Edge{From: node(m, 0, 0), To: node(m, 1, 0), Kind: graph.EdgeReferences})
g.AddEdge(&graph.Edge{From: node(m, 0, 0), To: node(m, 2, 0), Kind: graph.EdgeReferences})
g.AddEdge(&graph.Edge{From: node(m, 1, 0), To: node(m, 2, 0), Kind: graph.EdgeReferences})
}
// inter-module ring (weakest scale).
for m := 0; m < nm; m++ {
next := (m + 1) % nm
g.AddEdge(&graph.Edge{From: node(m, 0, 0), To: node(next, 0, 0), Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: node(m, 0, 1), To: node(next, 0, 1), Kind: graph.EdgeCalls})
}
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
return s
}
func TestClusters_ResolutionEchoedAndHonored(t *testing.T) {
s := newTieredClustersServer(t)
// Default: resolution 1.0 is echoed and the cached/default path runs.
def := callAnalyzeClusters(t, s, map[string]any{})
defDet := def["detection"].(map[string]any)
assert.EqualValues(t, 1.0, defDet["resolution"].(float64))
// A higher resolution must yield MORE clusters than a lower one — the
// granularity knob is honored end-to-end through the handler.
hi := callAnalyzeClusters(t, s, map[string]any{"resolution": 2.0})
lo := callAnalyzeClusters(t, s, map[string]any{"resolution": 0.5})
hiDet := hi["detection"].(map[string]any)
assert.EqualValues(t, 2.0, hiDet["resolution"].(float64))
// Non-default resolution recomputes fully (the incremental cache is
// keyed to the default γ).
assert.Equal(t, "full", hiDet["recompute"])
hiN := len(hi["clusters"].([]any))
loN := len(lo["clusters"].([]any))
defN := len(def["clusters"].([]any))
t.Logf("clusters: gamma=0.5 -> %d, gamma=1.0 -> %d, gamma=2.0 -> %d", loN, defN, hiN)
assert.Greater(t, hiN, loN, "resolution=2.0 must produce more clusters than resolution=0.5")
}
func TestClusters_IntegrationViaDispatch(t *testing.T) {
s := newClustersTestServer(t)
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{"kind": "clusters"}
res, err := s.handleAnalyze(context.Background(), req)
require.NoError(t, err)
assert.False(t, res.IsError)
}
// concepts ----------------------------------------------------------
func TestConcepts_HeuristicLabelWhenNoLLM(t *testing.T) {
s := newClustersTestServer(t)
// LLM is nil — concepts should produce heuristic labels.
out := callAnalyzeConcepts(t, s, map[string]any{})
concepts, _ := out["concepts"].([]any)
require.NotEmpty(t, concepts)
for _, c := range concepts {
row := c.(map[string]any)
assert.Equal(t, "heuristic", row["source"])
assert.NotEmpty(t, row["theme"])
}
}
func TestConcepts_LabelFallsBackToHub(t *testing.T) {
s := newClustersTestServer(t)
// Strip the cached Label so the heuristic has to derive one.
s.analysisMu.Lock()
s.communities.Communities[0].Label = ""
s.analysisMu.Unlock()
out := callAnalyzeConcepts(t, s, map[string]any{})
concepts, _ := out["concepts"].([]any)
require.NotEmpty(t, concepts)
theme := concepts[0].(map[string]any)["theme"].(string)
assert.Contains(t, theme, "auth", "auth/ common prefix should land in the label")
}
func TestConcepts_MinSizeFilter(t *testing.T) {
s := newClustersTestServer(t)
out := callAnalyzeConcepts(t, s, map[string]any{"min_size": 4})
concepts, _ := out["concepts"].([]any)
for _, c := range concepts {
assert.GreaterOrEqual(t, int(c.(map[string]any)["member_size"].(float64)), 4)
}
}
func TestConcepts_IntegrationViaDispatch(t *testing.T) {
s := newClustersTestServer(t)
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]any{"kind": "concepts"}
res, err := s.handleAnalyze(context.Background(), req)
require.NoError(t, err)
assert.False(t, res.IsError)
}
// helpers -----------------------------------------------------------
func TestHeuristicConceptLabel(t *testing.T) {
c := analysis.Community{ID: "c1", Label: "explicit"}
assert.Equal(t, "explicit", heuristicConceptLabel(c))
c2 := analysis.Community{ID: "c2", Hub: "Foo", Files: []string{"auth/x.go", "auth/y.go"}}
assert.Contains(t, heuristicConceptLabel(c2), "Foo")
c3 := analysis.Community{ID: "c3", Files: []string{"a/b.go", "x/y.go"}}
assert.Equal(t, "cluster-c3", heuristicConceptLabel(c3), "no common prefix + no hub falls back to cluster-id")
}
func TestCommonFilePrefix(t *testing.T) {
assert.Equal(t, "auth", commonFilePrefix([]string{"auth/x.go", "auth/y.go"}))
assert.Equal(t, "auth/jwt", commonFilePrefix([]string{"auth/jwt/a.go", "auth/jwt/b.go"}))
assert.Equal(t, "", commonFilePrefix([]string{"auth/a.go", "main.go"}))
assert.Equal(t, "", commonFilePrefix(nil))
}
func TestShortenLabel(t *testing.T) {
assert.Equal(t, "Authentication flow", shortenLabel("Authentication flow\nlonger explanation"))
assert.Equal(t, "label", shortenLabel(`"label."`))
}
func TestTopN(t *testing.T) {
got := topN(map[string]int{"a": 3, "b": 5, "c": 1}, 2)
assert.Equal(t, []string{"b", "a"}, got)
}
func TestSliceFirstN(t *testing.T) {
assert.Equal(t, []string{"a", "b"}, sliceFirstN([]string{"a", "b", "c"}, 2))
assert.Equal(t, []string{"a"}, sliceFirstN([]string{"a"}, 5))
assert.Nil(t, sliceFirstN(nil, 3))
}