Files
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

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)
}
}
}
}