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
220 lines
7.5 KiB
Go
220 lines
7.5 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
func newExtractTestServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
g := graph.New()
|
|
|
|
// orchestrator: 50 lines, 5 callers, 10 distinct callees — prime candidate.
|
|
g.AddNode(&graph.Node{
|
|
ID: "p/big.go::Orchestrator", Name: "Orchestrator", Kind: graph.KindFunction,
|
|
FilePath: "p/big.go", StartLine: 1, EndLine: 50,
|
|
})
|
|
// 5 callers of Orchestrator.
|
|
for i := 1; i <= 5; i++ {
|
|
callerID := fmt.Sprintf("p/caller%d.go::Call%d", i, i)
|
|
g.AddNode(&graph.Node{ID: callerID, Name: fmt.Sprintf("Call%d", i), Kind: graph.KindFunction, FilePath: fmt.Sprintf("p/caller%d.go", i)})
|
|
g.AddEdge(&graph.Edge{From: callerID, To: "p/big.go::Orchestrator", Kind: graph.EdgeCalls})
|
|
}
|
|
// 10 distinct callees from Orchestrator.
|
|
for i := 1; i <= 10; i++ {
|
|
calleeID := fmt.Sprintf("p/util%d.go::U%d", i, i)
|
|
g.AddNode(&graph.Node{ID: calleeID, Name: fmt.Sprintf("U%d", i), Kind: graph.KindFunction, FilePath: fmt.Sprintf("p/util%d.go", i)})
|
|
g.AddEdge(&graph.Edge{From: "p/big.go::Orchestrator", To: calleeID, Kind: graph.EdgeCalls})
|
|
}
|
|
|
|
// short: 10 lines, 5 callers, 2 callees — fails min_lines.
|
|
g.AddNode(&graph.Node{
|
|
ID: "p/small.go::Short", Name: "Short", Kind: graph.KindFunction,
|
|
FilePath: "p/small.go", StartLine: 1, EndLine: 10,
|
|
})
|
|
for i := 1; i <= 5; i++ {
|
|
callerID := fmt.Sprintf("p/sc%d.go::SC%d", i, i)
|
|
g.AddNode(&graph.Node{ID: callerID, Name: fmt.Sprintf("SC%d", i), Kind: graph.KindFunction, FilePath: fmt.Sprintf("p/sc%d.go", i)})
|
|
g.AddEdge(&graph.Edge{From: callerID, To: "p/small.go::Short", Kind: graph.EdgeCalls})
|
|
}
|
|
|
|
// solo: 50 lines, 1 caller, 10 callees — fails min_callers.
|
|
g.AddNode(&graph.Node{
|
|
ID: "p/solo.go::Solo", Name: "Solo", Kind: graph.KindFunction,
|
|
FilePath: "p/solo.go", StartLine: 1, EndLine: 50,
|
|
})
|
|
g.AddNode(&graph.Node{ID: "p/solocaller.go::SoloCaller", Name: "SoloCaller", Kind: graph.KindFunction, FilePath: "p/solocaller.go"})
|
|
g.AddEdge(&graph.Edge{From: "p/solocaller.go::SoloCaller", To: "p/solo.go::Solo", Kind: graph.EdgeCalls})
|
|
|
|
// flat: 50 lines, 5 callers, 1 callee — fails min_fan_out.
|
|
g.AddNode(&graph.Node{
|
|
ID: "p/flat.go::Flat", Name: "Flat", Kind: graph.KindFunction,
|
|
FilePath: "p/flat.go", StartLine: 1, EndLine: 50,
|
|
})
|
|
for i := 1; i <= 5; i++ {
|
|
callerID := fmt.Sprintf("p/fc%d.go::FC%d", i, i)
|
|
g.AddNode(&graph.Node{ID: callerID, Name: fmt.Sprintf("FC%d", i), Kind: graph.KindFunction, FilePath: fmt.Sprintf("p/fc%d.go", i)})
|
|
g.AddEdge(&graph.Edge{From: callerID, To: "p/flat.go::Flat", Kind: graph.EdgeCalls})
|
|
}
|
|
g.AddNode(&graph.Node{ID: "p/flatcallee.go::FCU", Name: "FCU", Kind: graph.KindFunction, FilePath: "p/flatcallee.go"})
|
|
g.AddEdge(&graph.Edge{From: "p/flat.go::Flat", To: "p/flatcallee.go::FCU", Kind: graph.EdgeCalls})
|
|
|
|
return &Server{
|
|
graph: g,
|
|
session: newSessionState(),
|
|
tokenStats: &tokenStats{},
|
|
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
|
|
sessions: newSessionMap(),
|
|
toolScopes: newScopeRegistry(),
|
|
}
|
|
}
|
|
|
|
func callExtractHandler(t *testing.T, s *Server, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
req := mcp.CallToolRequest{}
|
|
req.Params.Arguments = args
|
|
res, err := s.handleGetExtractionCandidates(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 TestExtractCandidates_OrchestratorSurfaces(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
require.NotEmpty(t, cands)
|
|
first := cands[0].(map[string]any)
|
|
assert.Equal(t, "p/big.go::Orchestrator", first["symbol_id"],
|
|
"the 50-line / 5-caller / 10-callee function should rank highest")
|
|
assert.EqualValues(t, 50, first["line_count"].(float64))
|
|
assert.EqualValues(t, 5, first["caller_count"].(float64))
|
|
assert.EqualValues(t, 10, first["fan_out"].(float64))
|
|
}
|
|
|
|
func TestExtractCandidates_FailsMinLines(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
for _, c := range cands {
|
|
assert.NotEqual(t, "p/small.go::Short", c.(map[string]any)["symbol_id"],
|
|
"Short fails min_lines=20")
|
|
}
|
|
}
|
|
|
|
func TestExtractCandidates_FailsMinCallers(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
for _, c := range cands {
|
|
assert.NotEqual(t, "p/solo.go::Solo", c.(map[string]any)["symbol_id"],
|
|
"Solo fails min_callers=2")
|
|
}
|
|
}
|
|
|
|
func TestExtractCandidates_FailsMinFanOut(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
for _, c := range cands {
|
|
assert.NotEqual(t, "p/flat.go::Flat", c.(map[string]any)["symbol_id"],
|
|
"Flat fails min_fan_out=5")
|
|
}
|
|
}
|
|
|
|
func TestExtractCandidates_RelaxedThresholdsSurface(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
// Drop all thresholds to 0 — Solo and Flat should now appear.
|
|
out := callExtractHandler(t, s, map[string]any{
|
|
"min_lines": 1,
|
|
"min_callers": 0,
|
|
"min_fan_out": 0,
|
|
})
|
|
cands, _ := out["candidates"].([]any)
|
|
ids := map[string]bool{}
|
|
for _, c := range cands {
|
|
ids[c.(map[string]any)["symbol_id"].(string)] = true
|
|
}
|
|
assert.True(t, ids["p/solo.go::Solo"])
|
|
assert.True(t, ids["p/flat.go::Flat"])
|
|
}
|
|
|
|
func TestExtractCandidates_RationaleMentionsSignals(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
require.NotEmpty(t, cands)
|
|
rationale := cands[0].(map[string]any)["rationale"].(string)
|
|
assert.Contains(t, rationale, "lines", "rationale names line count signal")
|
|
}
|
|
|
|
func TestExtractCandidates_PathPrefixScope(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{"path_prefix": "p/big"})
|
|
|
|
cands, _ := out["candidates"].([]any)
|
|
require.Len(t, cands, 1)
|
|
assert.Equal(t, "p/big.go::Orchestrator", cands[0].(map[string]any)["symbol_id"])
|
|
}
|
|
|
|
func TestExtractCandidates_LimitTruncates(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{
|
|
"min_lines": 1,
|
|
"min_callers": 0,
|
|
"min_fan_out": 0,
|
|
"limit": 1,
|
|
})
|
|
cands, _ := out["candidates"].([]any)
|
|
assert.Len(t, cands, 1)
|
|
assert.Equal(t, true, out["truncated"])
|
|
}
|
|
|
|
func TestExtractCandidates_ThresholdsEchoed(t *testing.T) {
|
|
s := newExtractTestServer(t)
|
|
out := callExtractHandler(t, s, map[string]any{
|
|
"min_lines": 30,
|
|
"min_callers": 3,
|
|
"min_fan_out": 7,
|
|
})
|
|
th := out["thresholds"].(map[string]any)
|
|
assert.EqualValues(t, 30, th["min_lines"].(float64))
|
|
assert.EqualValues(t, 3, th["min_callers"].(float64))
|
|
assert.EqualValues(t, 7, th["min_fan_out"].(float64))
|
|
}
|
|
|
|
func TestExtractCandidates_RationaleSignalNaming(t *testing.T) {
|
|
cases := map[string]struct {
|
|
line, caller, fanOut int
|
|
mustContain []string
|
|
}{
|
|
"very-long": {60, 2, 5, []string{"very long"}},
|
|
"widely-called": {30, 15, 5, []string{"widely called"}},
|
|
"orchestration": {30, 5, 20, []string{"orchestration shape"}},
|
|
"baseline": {21, 2, 5, []string{"long", "multi-caller", "complex body"}},
|
|
}
|
|
for name, c := range cases {
|
|
got := buildExtractRationale(c.line, c.caller, c.fanOut)
|
|
for _, sub := range c.mustContain {
|
|
assert.Contains(t, got, sub, "%s: rationale=%q", name, got)
|
|
}
|
|
}
|
|
}
|