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

356 lines
12 KiB
Go

package mcp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/search"
)
// TestReciprocalRankFusion_HandComputed pins the fusion math to a
// hand-worked example with k=60:
//
// signal a ranks: x(1), y(2), z(3)
// signal b ranks: y(1), x(2)
// signal c ranks: z(1)
//
// fused(x) = 1/61 + 1/62
// fused(y) = 1/62 + 1/61
// fused(z) = 1/63 + 1/61
func TestReciprocalRankFusion_HandComputed(t *testing.T) {
rankings := map[string][]string{
"a": {"x", "y", "z"},
"b": {"y", "x"},
"c": {"z"},
}
fused, perSignal := reciprocalRankFusion(rankings, 60)
wantX := 1.0/61 + 1.0/62
wantY := 1.0/62 + 1.0/61
wantZ := 1.0/63 + 1.0/61
assert.InDelta(t, wantX, fused["x"], 1e-12)
assert.InDelta(t, wantY, fused["y"], 1e-12)
assert.InDelta(t, wantZ, fused["z"], 1e-12)
// x and y tie exactly; z trails because its second-best rank is 3.
assert.Equal(t, fused["x"], fused["y"])
assert.Less(t, fused["z"], fused["x"])
assert.Equal(t, map[string]int{"a": 1, "b": 2}, perSignal["x"])
assert.Equal(t, map[string]int{"a": 2, "b": 1}, perSignal["y"])
assert.Equal(t, map[string]int{"a": 3, "c": 1}, perSignal["z"])
}
func TestReciprocalRankFusion_Empty(t *testing.T) {
fused, perSignal := reciprocalRankFusion(nil, 60)
assert.Empty(t, fused)
assert.Empty(t, perSignal)
}
// setupBridgeWorkspaceRepo writes a repo dir with a shared-workspace
// .gortex.yaml so the two repos' contracts pair across the boundary.
func setupBridgeWorkspaceRepo(t *testing.T, root, name string, files map[string]string) string {
t.Helper()
dir := filepath.Join(root, name)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gortex.yaml"),
[]byte("workspace: acme\nproject: acme\n"), 0o644))
for rel, content := range files {
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
return dir
}
// newBridgeTestServer indexes a provider repo (Gin routes) and a
// consumer repo (http.Get of the same path) into one multi-repo graph
// and returns an MCP server whose reconcile pass has materialized the
// bridge subgraph.
func newBridgeTestServer(t *testing.T) *Server {
t.Helper()
root := t.TempDir()
providerRepo := setupBridgeWorkspaceRepo(t, root, "provider-svc", map[string]string{
"go.mod": "module example.com/provider-svc\n\ngo 1.21\n",
"main.go": `package main
import "github.com/gin-gonic/gin"
func setupRoutes(r *gin.Engine) {
r.GET("/api/users", listUsers)
}
func listUsers() {}
`,
})
consumerRepo := setupBridgeWorkspaceRepo(t, root, "consumer-svc", map[string]string{
"go.mod": "module example.com/consumer-svc\n\ngo 1.21\n",
"client.go": `package main
import "net/http"
func fetchUsers() {
http.Get("http://api.example.com/api/users")
}
`,
})
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: providerRepo, Name: "provider-svc"},
{Path: consumerRepo, Name: "consumer-svc"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
preg := testRegistry()
g := graph.New()
mi := indexer.NewMultiIndexer(g, preg, search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
eng := query.NewEngine(g)
return NewServer(eng, g, nil, nil, zap.NewNop(), nil, MultiRepoOptions{
ConfigManager: cm,
MultiIndexer: mi,
})
}
type bridgeTestPayload struct {
Mode string `json:"mode"`
Total int `json:"total"`
Symbol string `json:"symbol"`
Groups []struct {
BridgeID string `json:"bridge_id"`
CanonicalKey string `json:"canonical_key"`
ContractType string `json:"contract_type"`
Repos []string `json:"repos"`
CrossRepo bool `json:"cross_repo"`
ProviderCount int `json:"provider_count"`
ConsumerCount int `json:"consumer_count"`
FusedScore float64 `json:"fused_score"`
SignalRanks map[string]int `json:"signal_ranks"`
MatchedVia []string `json:"matched_via"`
Providers []struct {
ContractID string `json:"contract_id"`
Repo string `json:"repo"`
SymbolID string `json:"symbol_id"`
FilePath string `json:"file_path"`
} `json:"providers"`
Consumers []struct {
ContractID string `json:"contract_id"`
Repo string `json:"repo"`
SymbolID string `json:"symbol_id"`
} `json:"consumers"`
} `json:"groups"`
}
func callBridge(t *testing.T, srv *Server, args map[string]any) bridgeTestPayload {
t.Helper()
req := mcplib.CallToolRequest{}
if args == nil {
args = map[string]any{}
}
args["action"] = "bridge"
req.Params.Arguments = args
res, err := srv.handleContracts(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
require.False(t, res.IsError, "bridge call failed: %+v", res.Content)
var payload bridgeTestPayload
require.NoError(t, json.Unmarshal([]byte(extractTextFromContent(t, res.Content)), &payload))
return payload
}
// TestHandleContractBridges_RankMode runs the fused ranking over a
// real two-repo index and asserts the grouped response shape: bridge
// identity, both sides resolved with repo + symbol + location, and
// per-signal ranks riding on the fused score.
func TestHandleContractBridges_RankMode(t *testing.T) {
srv := newBridgeTestServer(t)
payload := callBridge(t, srv, map[string]any{"query": "users api"})
require.Equal(t, "rank", payload.Mode)
require.GreaterOrEqual(t, payload.Total, 1)
require.NotEmpty(t, payload.Groups)
top := payload.Groups[0]
assert.Equal(t, "bridge::acme::acme::http::GET::/api/users", top.BridgeID)
assert.Equal(t, "GET /api/users", top.CanonicalKey)
assert.Equal(t, "http", top.ContractType)
assert.Equal(t, []string{"consumer-svc", "provider-svc"}, top.Repos)
assert.True(t, top.CrossRepo)
assert.Greater(t, top.FusedScore, 0.0)
assert.Contains(t, top.SignalRanks, "text")
assert.Contains(t, top.SignalRanks, "degree")
require.NotEmpty(t, top.Providers, "provider side must be resolved")
prov := top.Providers[0]
assert.Equal(t, "provider-svc", prov.Repo)
assert.Equal(t, "provider-svc/main.go::listUsers", prov.SymbolID)
assert.Equal(t, "provider-svc/main.go", prov.FilePath)
require.NotEmpty(t, top.Consumers, "consumer side must be resolved")
cons := top.Consumers[0]
assert.Equal(t, "consumer-svc", cons.Repo)
assert.Equal(t, "consumer-svc/client.go::fetchUsers", cons.SymbolID)
}
// TestHandleContractBridges_RankMode_SymbolAdjacency: passing a symbol
// adds the graph-adjacency signal to the fusion.
func TestHandleContractBridges_RankMode_SymbolAdjacency(t *testing.T) {
srv := newBridgeTestServer(t)
payload := callBridge(t, srv, map[string]any{
"query": "users",
"symbol": "consumer-svc/client.go::fetchUsers",
})
require.NotEmpty(t, payload.Groups)
top := payload.Groups[0]
assert.Equal(t, "bridge::acme::acme::http::GET::/api/users", top.BridgeID)
assert.Contains(t, top.SignalRanks, "adjacency",
"symbol-anchored call must rank the adjacency signal: %v", top.SignalRanks)
assert.Equal(t, 1, top.SignalRanks["adjacency"])
}
// TestHandleContractBridges_ImpactMode: from the consumer symbol, the
// bridge it participates in must surface as cross-service blast
// radius, with the anchoring contract recorded.
func TestHandleContractBridges_ImpactMode(t *testing.T) {
srv := newBridgeTestServer(t)
payload := callBridge(t, srv, map[string]any{
"mode": "impact",
"symbol": "consumer-svc/client.go::fetchUsers",
})
require.Equal(t, "impact", payload.Mode)
require.Equal(t, 1, payload.Total)
top := payload.Groups[0]
assert.Equal(t, "bridge::acme::acme::http::GET::/api/users", top.BridgeID)
assert.Equal(t, []string{"http::GET::/api/users"}, top.MatchedVia)
// The provider symbol works symmetrically — its route's bridge is
// its blast radius too.
payload = callBridge(t, srv, map[string]any{
"mode": "impact",
"symbol": "provider-svc/main.go::listUsers",
})
require.Equal(t, 1, payload.Total)
assert.Equal(t, "bridge::acme::acme::http::GET::/api/users", payload.Groups[0].BridgeID)
}
// TestHandleContractBridges_Errors covers the argument-validation
// paths: impact without symbol, unknown mode, unknown symbol.
func TestHandleContractBridges_Errors(t *testing.T) {
srv := newBridgeTestServer(t)
for name, args := range map[string]map[string]any{
"impact without symbol": {"action": "bridge", "mode": "impact"},
"unknown mode": {"action": "bridge", "mode": "sideways"},
"unknown symbol": {"action": "bridge", "mode": "impact", "symbol": "nope.go::missing"},
} {
req := mcplib.CallToolRequest{}
req.Params.Arguments = args
res, err := srv.handleContracts(context.Background(), req)
require.NoError(t, err, name)
assert.True(t, res.IsError, "%s should return a tool error", name)
}
}
// TestHandleContractBridges_RepoScope: the repo filter keeps bridges
// touching the named repo and drops the rest.
func TestHandleContractBridges_RepoScope(t *testing.T) {
srv := newBridgeTestServer(t)
payload := callBridge(t, srv, map[string]any{"query": "users", "repo": "provider-svc"})
require.NotEmpty(t, payload.Groups, "bridge touches provider-svc, must stay in scope")
req := mcplib.CallToolRequest{}
req.Params.Arguments = map[string]any{"action": "bridge", "query": "users", "repo": "unrelated-repo"}
res, err := srv.handleContracts(context.Background(), req)
require.NoError(t, err)
assert.True(t, res.IsError, "no bridges touch unrelated-repo — expect the empty-scope error")
}
// TestCollectBridgeGroups_BoundaryScopedParticipants guards the read-
// side half of the workspace-isolation fix: registry.ByID returns every
// same-ID record across all workspaces, so the participant resolver must
// filter to the bridge's own (workspace, project) boundary. Without the
// filter, a bridge for workspace "acme" would list "globex"'s provider
// as a participant.
func TestCollectBridgeGroups_BoundaryScopedParticipants(t *testing.T) {
g := graph.New()
// One contract node ID, but two registry records in different
// workspaces — exactly the shape registry.ByID returns.
contractID := "http::GET::/api/users"
g.AddNode(&graph.Node{ID: contractID, Kind: graph.KindContract, Name: contractID, Language: "contract"})
reg := contracts.NewRegistry()
reg.Add(contracts.Contract{
ID: contractID, Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "acme-api/routes.go::list", FilePath: "acme-api/routes.go", Line: 10,
RepoPrefix: "acme-api", WorkspaceID: "acme", ProjectID: "acme",
})
reg.Add(contracts.Contract{
ID: contractID, Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "globex-api/routes.go::list", FilePath: "globex-api/routes.go", Line: 22,
RepoPrefix: "globex-api", WorkspaceID: "globex", ProjectID: "globex",
})
// A bridge node pinned to the acme boundary, linked to the shared
// contract node — the materialiser's output for the acme group.
g.AddNode(&graph.Node{
ID: "bridge::acme::acme::" + contractID,
Kind: graph.KindContractBridge,
Name: "GET /api/users",
FilePath: indexer.ContractBridgeFilePath,
Language: "contract",
RepoPrefix: "acme-api",
WorkspaceID: "acme",
Meta: map[string]any{
"contract_type": "http", "canonical_key": "GET /api/users",
"workspace": "acme", "project": "acme",
"repos": []string{"acme-api"}, "provider_count": 1, "consumer_count": 0,
},
})
g.AddEdge(&graph.Edge{
From: "bridge::acme::acme::" + contractID, To: contractID,
Kind: graph.EdgeBridges, Meta: map[string]any{"side": "provider"},
})
eng := query.NewEngine(g)
srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil)
srv.SetContractRegistry(reg)
groups := srv.collectBridgeGroups(nil)
require.Len(t, groups, 1)
grp := groups[0]
require.Len(t, grp.Providers, 1,
"only the acme-boundary provider must be listed, not globex's same-ID record: %+v", grp.Providers)
assert.Equal(t, "acme-api", grp.Providers[0].Repo)
assert.Equal(t, "acme-api/routes.go::list", grp.Providers[0].SymbolID)
}