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
184 lines
6.9 KiB
Go
184 lines
6.9 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/query"
|
|
"github.com/zzet/gortex/internal/search"
|
|
)
|
|
|
|
func TestApplyPathFilter_AnchoredPrefix(t *testing.T) {
|
|
mk := func(path string) *graph.Node {
|
|
return &graph.Node{ID: path + "::S", Kind: graph.KindFunction, Name: "S", FilePath: path}
|
|
}
|
|
nodes := []*graph.Node{
|
|
mk("services/billing/invoice.go"), // under services/billing
|
|
mk("services/billing/v2/refund.go"), // under services/billing (deeper)
|
|
mk("services/billingX/other.go"), // NOT under -- the prefix must align on a boundary
|
|
mk("other/services/billing/x.go"), // NOT under -- prefix is anchored at the start
|
|
mk("libs/auth/token.go"), // under libs/auth
|
|
}
|
|
|
|
got := applyPathFilter(nodes, []string{"services/billing"})
|
|
gotPaths := map[string]bool{}
|
|
for _, n := range got {
|
|
gotPaths[n.FilePath] = true
|
|
}
|
|
require.True(t, gotPaths["services/billing/invoice.go"])
|
|
require.True(t, gotPaths["services/billing/v2/refund.go"])
|
|
require.False(t, gotPaths["services/billingX/other.go"], "billingX must not match the billing prefix")
|
|
require.False(t, gotPaths["other/services/billing/x.go"], "the prefix is anchored at the path start")
|
|
require.False(t, gotPaths["libs/auth/token.go"])
|
|
|
|
// Multi-path: union of two prefixes.
|
|
multi := applyPathFilter(nodes, []string{"services/billing", "libs/auth"})
|
|
require.Len(t, multi, 3)
|
|
|
|
// Slash normalisation: leading "./", trailing slash, back-slashes.
|
|
norm := applyPathFilter(nodes, []string{"./services/billing/"})
|
|
require.Len(t, norm, 2)
|
|
|
|
// Empty filter is a no-op.
|
|
require.Len(t, applyPathFilter(nodes, nil), len(nodes))
|
|
}
|
|
|
|
func TestApplyPathFilter_StripsRepoPrefix(t *testing.T) {
|
|
// In multi-repo mode FilePath is repo-prefixed; the filter is
|
|
// expressed relative to the repo root and must still match.
|
|
n := &graph.Node{
|
|
ID: "x", Kind: graph.KindFunction, Name: "S",
|
|
FilePath: "billing-repo/services/billing/invoice.go",
|
|
RepoPrefix: "billing-repo",
|
|
}
|
|
got := applyPathFilter([]*graph.Node{n}, []string{"services/billing"})
|
|
require.Len(t, got, 1, "repo prefix should be stripped before the anchored match")
|
|
}
|
|
|
|
func TestSavedScope_PathsJSONRoundTrip(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "scopes.json")
|
|
st := newScopeStore(path)
|
|
require.NoError(t, st.put(SavedScope{
|
|
Name: "billing",
|
|
Repos: []string{"monorepo"},
|
|
Paths: []string{"services/billing", "libs/money"},
|
|
}))
|
|
got, ok := newScopeStore(path).get("billing")
|
|
require.True(t, ok)
|
|
require.Equal(t, []string{"services/billing", "libs/money"}, got.Paths)
|
|
|
|
// A scope with no Paths round-trips with the field omitted (the
|
|
// back-compat shape) -- decoding an old scopes.json still works.
|
|
require.NoError(t, st.put(SavedScope{Name: "repo-only", Repos: []string{"r"}}))
|
|
plain, ok := newScopeStore(path).get("repo-only")
|
|
require.True(t, ok)
|
|
require.Nil(t, plain.Paths)
|
|
}
|
|
|
|
func TestResolvePathFilter_Sources(t *testing.T) {
|
|
t.Setenv("GORTEX_SCOPES_PATH", filepath.Join(t.TempDir(), "scopes.json"))
|
|
g := graph.New()
|
|
eng := query.NewEngine(g)
|
|
eng.SetSearch(search.NewBM25())
|
|
srv := NewServer(eng, g, nil, nil, zap.NewNop(), nil)
|
|
require.NoError(t, srv.scopeStoreOrInit().put(SavedScope{
|
|
Name: "billing", Repos: []string{"r"}, Paths: []string{"services/billing"},
|
|
}))
|
|
|
|
// From the explicit path argument.
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{"path": "libs/auth, libs/money"}
|
|
got := srv.resolvePathFilter(req, fieldQuery{})
|
|
require.ElementsMatch(t, []string{"libs/auth", "libs/money"}, got)
|
|
|
|
// From an inline path: clause.
|
|
got = srv.resolvePathFilter(mcplib.CallToolRequest{}, fieldQuery{Path: "internal/auth"})
|
|
require.Equal(t, []string{"internal/auth"}, got)
|
|
|
|
// From a path-bearing saved scope.
|
|
req2 := mcplib.CallToolRequest{}
|
|
req2.Params.Arguments = map[string]any{"scope": "billing"}
|
|
got = srv.resolvePathFilter(req2, fieldQuery{})
|
|
require.Equal(t, []string{"services/billing"}, got)
|
|
|
|
// All three union together.
|
|
req3 := mcplib.CallToolRequest{}
|
|
req3.Params.Arguments = map[string]any{"path": "a/b", "scope": "billing"}
|
|
got = srv.resolvePathFilter(req3, fieldQuery{Path: "c/d"})
|
|
require.ElementsMatch(t, []string{"a/b", "c/d", "services/billing"}, got)
|
|
}
|
|
|
|
// pathScopeServer builds a single-repo server with symbols spread
|
|
// across distinct sub-paths so a path filter is observable.
|
|
func pathScopeServer(t *testing.T) *Server {
|
|
t.Helper()
|
|
g := graph.New()
|
|
bm := search.NewBM25()
|
|
files := map[string]string{
|
|
"services/billing/Invoice.go": "BillingInvoice",
|
|
"services/auth/Login.go": "AuthLogin",
|
|
"libs/money/Amount.go": "MoneyAmount",
|
|
}
|
|
for path, name := range files {
|
|
id := path + "::" + name
|
|
g.AddNode(&graph.Node{
|
|
ID: id, Kind: graph.KindFunction, Name: name,
|
|
FilePath: path, StartLine: 1, EndLine: 5, Language: "go",
|
|
})
|
|
bm.Add(id, name, path, "")
|
|
}
|
|
eng := query.NewEngine(g)
|
|
eng.SetSearch(bm)
|
|
return NewServer(eng, g, nil, nil, zap.NewNop(), nil)
|
|
}
|
|
|
|
// TestSearchSymbols_PathScoping confirms the path argument confines
|
|
// search_symbols results to the named sub-path.
|
|
func TestSearchSymbols_PathScoping(t *testing.T) {
|
|
srv := pathScopeServer(t)
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_symbols"
|
|
// Query a token every symbol's path shares -- only the path
|
|
// filter should confine the result.
|
|
req.Params.Arguments = map[string]any{"query": "BillingInvoice OR AuthLogin OR MoneyAmount", "path": "services/billing"}
|
|
res, err := srv.handleSearchSymbols(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.False(t, res.IsError)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
|
|
for _, r := range resp["results"].([]any) {
|
|
fp := r.(map[string]any)["file_path"].(string)
|
|
require.Contains(t, fp, "services/billing", "path scoping leaked a result outside services/billing: %s", fp)
|
|
}
|
|
require.NotEmpty(t, resp["results"], "the in-scope billing symbol should still be found")
|
|
}
|
|
|
|
// TestSmartContext_PathScoping confirms smart_context honours the
|
|
// path argument.
|
|
func TestSmartContext_PathScoping(t *testing.T) {
|
|
srv := pathScopeServer(t)
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "smart_context"
|
|
req.Params.Arguments = map[string]any{"task": "work on billing invoice login amount", "path": "services/billing"}
|
|
res, err := srv.handleSmartContext(context.Background(), req)
|
|
require.NoError(t, err)
|
|
require.False(t, res.IsError)
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp))
|
|
if syms, ok := resp["relevant_symbols"].([]any); ok {
|
|
for _, s := range syms {
|
|
if fp, ok := s.(map[string]any)["file_path"].(string); ok && fp != "" {
|
|
require.Contains(t, fp, "services/billing",
|
|
"smart_context path scoping leaked %s", fp)
|
|
}
|
|
}
|
|
}
|
|
}
|