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

213 lines
7.3 KiB
Go

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 newCouplingTestServer(t *testing.T) *Server {
t.Helper()
g := graph.New()
// Three packages: api/, service/, repo/.
// api → service → repo (one-way dep chain).
g.AddNode(&graph.Node{ID: "api/h.go::Handle", Name: "Handle", Kind: graph.KindFunction, FilePath: "api/h.go", Language: "go"})
g.AddNode(&graph.Node{ID: "service/s.go::Process", Name: "Process", Kind: graph.KindFunction, FilePath: "service/s.go", Language: "go"})
g.AddNode(&graph.Node{ID: "repo/r.go::Fetch", Name: "Fetch", Kind: graph.KindFunction, FilePath: "repo/r.go", Language: "go"})
g.AddEdge(&graph.Edge{From: "api/h.go::Handle", To: "service/s.go::Process", Kind: graph.EdgeCalls})
g.AddEdge(&graph.Edge{From: "service/s.go::Process", To: "repo/r.go::Fetch", Kind: graph.EdgeCalls})
return &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
}
func callCouplingHandler(t *testing.T, s *Server, args map[string]any) map[string]any {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := s.handleGetCouplingMetrics(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 findCouplingRow(out map[string]any, unit string) map[string]any {
rows, _ := out["units"].([]any)
for _, r := range rows {
m := r.(map[string]any)
if m["unit"] == unit {
return m
}
}
return nil
}
func TestCoupling_PackageMetricsCorrect(t *testing.T) {
s := newCouplingTestServer(t)
out := callCouplingHandler(t, s, map[string]any{"package_depth": 1})
api := findCouplingRow(out, "api")
service := findCouplingRow(out, "service")
repo := findCouplingRow(out, "repo")
require.NotNil(t, api)
require.NotNil(t, service)
require.NotNil(t, repo)
// api: no incoming, depends on service. ca=0, ce=1, instability=1.
assert.EqualValues(t, 0, api["ca"].(float64))
assert.EqualValues(t, 1, api["ce"].(float64))
assert.InDelta(t, 1.0, api["instability"].(float64), 1e-6)
// service: depended on by api, depends on repo. ca=1, ce=1, instability=0.5.
assert.EqualValues(t, 1, service["ca"].(float64))
assert.EqualValues(t, 1, service["ce"].(float64))
assert.InDelta(t, 0.5, service["instability"].(float64), 1e-6)
// repo: depended on by service, depends on nothing. ca=1, ce=0, instability=0.
assert.EqualValues(t, 1, repo["ca"].(float64))
assert.EqualValues(t, 0, repo["ce"].(float64))
assert.InDelta(t, 0.0, repo["instability"].(float64), 1e-6)
}
func TestCoupling_InternalEdgesNotInCaCe(t *testing.T) {
s := newCouplingTestServer(t)
// Add an intra-package call inside service/.
s.graph.AddNode(&graph.Node{ID: "service/u.go::Util", Name: "Util", Kind: graph.KindFunction, FilePath: "service/u.go", Language: "go"})
s.graph.AddEdge(&graph.Edge{From: "service/s.go::Process", To: "service/u.go::Util", Kind: graph.EdgeCalls})
out := callCouplingHandler(t, s, map[string]any{"package_depth": 1})
svc := findCouplingRow(out, "service")
require.NotNil(t, svc)
// Internal-edge count went up; ca/ce should NOT have changed.
assert.EqualValues(t, 1, svc["internal_edges"].(float64), "intra-service call counted as internal")
assert.EqualValues(t, 1, svc["ca"].(float64))
assert.EqualValues(t, 1, svc["ce"].(float64))
}
func TestCoupling_CommunityRollupHonored(t *testing.T) {
s := newCouplingTestServer(t)
s.analysisMu.Lock()
s.communities = &analysis.CommunityResult{
NodeToComm: map[string]string{
"api/h.go::Handle": "c-edge",
"service/s.go::Process": "c-core",
"repo/r.go::Fetch": "c-core",
},
Communities: []analysis.Community{
{ID: "c-edge", Members: []string{"api/h.go::Handle"}},
{ID: "c-core", Members: []string{"service/s.go::Process", "repo/r.go::Fetch"}},
},
}
s.analysisMu.Unlock()
out := callCouplingHandler(t, s, map[string]any{"unit": "community"})
edge := findCouplingRow(out, "c-edge")
core := findCouplingRow(out, "c-core")
require.NotNil(t, edge)
require.NotNil(t, core)
// c-edge depends on c-core; ca=0, ce=1.
assert.EqualValues(t, 1, edge["ce"].(float64))
assert.EqualValues(t, 0, edge["ca"].(float64))
// c-core: 1 incoming (api → service), internal service → repo.
assert.EqualValues(t, 1, core["ca"].(float64))
assert.EqualValues(t, 0, core["ce"].(float64))
assert.EqualValues(t, 1, core["internal_edges"].(float64))
}
func TestCoupling_PackageDepthControlsGrouping(t *testing.T) {
s := newCouplingTestServer(t)
// At depth=2 we should see the same packages (only 1 segment each).
// Add a deeper file to test depth=2 grouping.
s.graph.AddNode(&graph.Node{ID: "service/internal/x.go::X", Name: "X", Kind: graph.KindFunction, FilePath: "service/internal/x.go"})
s.graph.AddEdge(&graph.Edge{From: "service/internal/x.go::X", To: "repo/r.go::Fetch", Kind: graph.EdgeCalls})
out := callCouplingHandler(t, s, map[string]any{"package_depth": 2})
deep := findCouplingRow(out, "service/internal")
require.NotNil(t, deep, "package_depth=2 should produce service/internal as a distinct unit")
assert.EqualValues(t, 1, deep["ce"].(float64))
}
func TestCoupling_SortByOptions(t *testing.T) {
s := newCouplingTestServer(t)
for _, sortBy := range []string{"ca", "ce", "instability", "members", "ca_and_instability"} {
out := callCouplingHandler(t, s, map[string]any{"sort_by": sortBy, "package_depth": 1})
assert.Equal(t, sortBy, out["sort_by"])
rows, _ := out["units"].([]any)
assert.NotEmpty(t, rows)
}
}
func TestCoupling_MinMembersFilter(t *testing.T) {
s := newCouplingTestServer(t)
out := callCouplingHandler(t, s, map[string]any{
"package_depth": 1,
"min_members": 5, // every unit has 1 member — all dropped.
})
rows, _ := out["units"].([]any)
assert.Empty(t, rows)
}
func TestCoupling_PathPrefixScope(t *testing.T) {
s := newCouplingTestServer(t)
out := callCouplingHandler(t, s, map[string]any{
"package_depth": 1,
"path_prefix": "service/",
})
rows, _ := out["units"].([]any)
// Only service in scope; the cross-unit edges to api / repo are
// dropped because their endpoints are out of scope.
require.Len(t, rows, 1)
assert.Equal(t, "service", rows[0].(map[string]any)["unit"])
}
func TestPackageOfPath(t *testing.T) {
cases := []struct {
path string
depth int
want string
}{
{"a/b/c.go", 2, "a/b"},
{"a/b/c.go", 1, "a"},
{"a/b/c/d.go", 2, "a/b"},
{"/abs/a/b/c.go", 2, "abs/a"},
{"justfile.go", 1, ""}, // no directory parts
{"", 2, ""},
{"a/b", 2, "a/b"}, // no .go extension — keep as-is
}
for _, c := range cases {
got := packageOfPath(c.path, c.depth)
assert.Equal(t, c.want, got, "packageOfPath(%q, %d)", c.path, c.depth)
}
}
func TestCoupling_LimitTruncates(t *testing.T) {
s := newCouplingTestServer(t)
out := callCouplingHandler(t, s, map[string]any{
"package_depth": 1,
"limit": 1,
})
rows, _ := out["units"].([]any)
assert.Len(t, rows, 1)
assert.Equal(t, true, out["truncated"])
}