zzet--gortex
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
387 行
12 KiB
Go
387 行
12 KiB
Go
package mcp
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
func TestParseAssistMode(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want assistMode
|
|
}{
|
|
{"", assistAuto},
|
|
{"auto", assistAuto},
|
|
{"AUTO", assistAuto},
|
|
{" auto ", assistAuto},
|
|
{"on", assistOn},
|
|
{"ON", assistOn},
|
|
{"yes", assistOn},
|
|
{"true", assistOn},
|
|
{"force", assistOn},
|
|
{"off", assistOff},
|
|
{"OFF", assistOff},
|
|
{"no", assistOff},
|
|
{"false", assistOff},
|
|
{"skip", assistOff},
|
|
{"deep", assistDeep},
|
|
{"DEEP", assistDeep},
|
|
{"verify", assistDeep},
|
|
{"body", assistDeep},
|
|
{"garbage", assistAuto},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.in, func(t *testing.T) {
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Arguments = map[string]any{"assist": tc.in}
|
|
got := parseAssistMode(req)
|
|
assert.Equal(t, tc.want, got, "input=%q", tc.in)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLooksNaturalLanguage(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
q string
|
|
want bool
|
|
}{
|
|
{"empty", "", false},
|
|
{"blanks", " ", false},
|
|
{"single token", "handler", false},
|
|
{"two tokens", "handle user", false},
|
|
|
|
{"qualified identifier", "pkg/foo bar baz", false},
|
|
{"camelCase token", "handleSomething for fun", false},
|
|
{"PascalCase token", "MyHandler tests pass", false},
|
|
{"dotted identifier", "foo.Bar baz qux", false},
|
|
{"snake_case identifier", "do_thing in cluster", false},
|
|
{"scoped identifier", "ns::Type does stuff", false},
|
|
|
|
{"NL with stop word", "where do we hash passwords", true},
|
|
{"NL plain 4 tokens", "validate token auth flow", true},
|
|
{"NL plain 3 tokens no stop word", "validate token auth", false},
|
|
{"NL with the", "the user login flow", true},
|
|
|
|
{"mixed identifier short-circuits stop word", "the handleAsk token", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := looksNaturalLanguage(tc.q)
|
|
assert.Equal(t, tc.want, got, "q=%q", tc.q)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShouldEngageAssist(t *testing.T) {
|
|
// `on` always engages, regardless of shape.
|
|
assert.True(t, shouldEngageAssist(assistOn, "Foo"))
|
|
assert.True(t, shouldEngageAssist(assistOn, ""))
|
|
|
|
// `off` never engages.
|
|
assert.False(t, shouldEngageAssist(assistOff, "where do we hash"))
|
|
assert.False(t, shouldEngageAssist(assistOff, ""))
|
|
|
|
// `auto` defers to the heuristic.
|
|
assert.False(t, shouldEngageAssist(assistAuto, "handleAsk"))
|
|
assert.True(t, shouldEngageAssist(assistAuto, "where do we hash"))
|
|
|
|
// `deep` always engages — its whole purpose is opt-in verification
|
|
// for cases the caller knows are NL queries.
|
|
assert.True(t, shouldEngageAssist(assistDeep, "Foo"))
|
|
assert.True(t, shouldEngageAssist(assistDeep, "where do we hash"))
|
|
}
|
|
|
|
// TestDeferColdLoadForAssist covers the enrichment-aware first-load
|
|
// gate: an implicit assist request defers its local-model cold load
|
|
// only when the model is not yet loaded AND enrichment is in flight.
|
|
func TestDeferColdLoadForAssist(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
engage bool
|
|
modelLoaded bool
|
|
enrichBusy bool
|
|
wantEngage bool
|
|
wantDeferred bool
|
|
}{
|
|
{"not engaging stays off", false, false, true, false, false},
|
|
{"loaded model ignores busy", true, true, true, true, false},
|
|
{"not busy proceeds", true, false, false, true, false},
|
|
{"unloaded + busy defers", true, false, true, false, true},
|
|
{"loaded + not busy proceeds", true, true, false, true, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
engage, deferred := deferColdLoadForAssist(tc.engage, tc.modelLoaded, tc.enrichBusy)
|
|
assert.Equal(t, tc.wantEngage, engage, "engage")
|
|
assert.Equal(t, tc.wantDeferred, deferred, "deferred")
|
|
// Deferral and engagement are mutually exclusive.
|
|
assert.False(t, engage && deferred, "engage and deferred must never both be true")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTruncateBody(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
src string
|
|
maxLines int
|
|
maxChars int
|
|
want string
|
|
}{
|
|
{"empty", "", 8, 600, ""},
|
|
{
|
|
"under both caps",
|
|
"a()\nb()\nc()",
|
|
8, 600,
|
|
"a()\nb()\nc()\n",
|
|
},
|
|
{
|
|
"blank lines skipped from line count",
|
|
"a()\n\nb()\n\nc()\n",
|
|
3, 600,
|
|
"a()\n\nb()\n\nc()\n…\n",
|
|
},
|
|
{
|
|
"line cap fires",
|
|
"l1\nl2\nl3\nl4\nl5",
|
|
3, 600,
|
|
"l1\nl2\nl3\n…\n",
|
|
},
|
|
{
|
|
"char cap fires after line cap",
|
|
strings.Repeat("X", 700),
|
|
8, 100,
|
|
strings.Repeat("X", 100) + "…\n",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := truncateBody(tc.src, tc.maxLines, tc.maxChars)
|
|
assert.Equal(t, tc.want, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHasMixedCase(t *testing.T) {
|
|
assert.False(t, hasMixedCase("lower"))
|
|
assert.False(t, hasMixedCase("UPPER"))
|
|
assert.False(t, hasMixedCase(""))
|
|
assert.False(t, hasMixedCase("123"))
|
|
assert.True(t, hasMixedCase("camelCase"))
|
|
assert.True(t, hasMixedCase("PascalCase"))
|
|
}
|
|
|
|
func TestHasStopWord(t *testing.T) {
|
|
assert.True(t, hasStopWord([]string{"hello", "where", "world"}))
|
|
assert.True(t, hasStopWord([]string{"WHERE", "is", "x"}))
|
|
assert.False(t, hasStopWord([]string{"validate", "token", "auth"}))
|
|
assert.False(t, hasStopWord(nil))
|
|
}
|
|
|
|
// TestFetchAndMergeBM25_DedupesAcrossTerms verifies that when the
|
|
// same node matches multiple terms, it appears only once and keeps
|
|
// its primary-term position.
|
|
func TestFetchAndMergeBM25_DedupesAcrossTerms(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
scope := query.QueryOptions{}
|
|
|
|
// Primary term that hits "helper".
|
|
primary := srv.engine.SearchSymbolsScoped("helper", 20, scope)
|
|
require.NotEmpty(t, primary)
|
|
|
|
// Merging with the same term as an "expansion" must produce the
|
|
// same list, not duplicates.
|
|
merged, primaryCount := fetchAndMergeBM25(srv.engine, "helper", []string{"helper"}, 20, scope)
|
|
assert.Equal(t, len(primary), primaryCount)
|
|
assert.Equal(t, idsOf(primary), idsOf(merged))
|
|
}
|
|
|
|
// TestFetchAndMergeBM25_CombinedQueryUnionIsSuperset is the load-bearing
|
|
// guard for the "combine expansion terms into one BM25 query"
|
|
// optimisation. The merged result MUST contain at least every node
|
|
// that a per-term fan-out would have returned — otherwise switching
|
|
// from N BM25 calls to (primary + combined) drops candidates the
|
|
// rerank pipeline used to see. Exact-name rescue (the per-fragment
|
|
// FindNodesByNames step) is what makes this hold for tokenisation
|
|
// edge cases like PascalCase concatenated names that BM25 misses.
|
|
func TestFetchAndMergeBM25_CombinedQueryUnionIsSuperset(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
scope := query.QueryOptions{}
|
|
|
|
// Per-term fan-out (the OLD behaviour). For each fragment, run
|
|
// the engine search separately and collect every distinct node ID
|
|
// it surfaces — this is the worst-case "no candidate may be
|
|
// dropped by collapsing into one query" set.
|
|
terms := []string{"helper", "main"}
|
|
unionExpected := map[string]bool{}
|
|
for _, t := range terms {
|
|
for _, n := range srv.engine.SearchSymbolsScoped(t, 20, scope) {
|
|
unionExpected[n.ID] = true
|
|
}
|
|
}
|
|
require.NotEmpty(t, unionExpected, "per-term fan-out produced nothing — test corpus drifted")
|
|
|
|
// New behaviour: primary + combined-OR + per-fragment exact-name
|
|
// rescue, all driven by fetchAndMergeBM25.
|
|
merged, _ := fetchAndMergeBM25(srv.engine, terms[0], terms[1:], 20, scope)
|
|
mergedSet := map[string]bool{}
|
|
for _, n := range merged {
|
|
mergedSet[n.ID] = true
|
|
}
|
|
|
|
for id := range unionExpected {
|
|
require.True(t, mergedSet[id], "merged result missing per-term hit %q", id)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndMergeBM25_ExactNameRescuePreserved is the regression
|
|
// guard for the soup-mode + PascalCase fragment case that per-term
|
|
// fan-out used to handle implicitly. When BM25 tokenisation misses
|
|
// a fragment ("BillingInvoice" tokenises to one term `billinginvoice`
|
|
// which the camelCase-split index doesn't carry), the per-fragment
|
|
// FindNodesByNames rescue MUST still surface its exact-name node.
|
|
// This mirrors the failure mode TestSearchSymbols_PathScoping caught
|
|
// when soup-split fragments first went through the combined query
|
|
// path.
|
|
func TestFetchAndMergeBM25_ExactNameRescuePreserved(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
|
|
// The test corpus carries no PascalCase-concatenated names by
|
|
// default, so add three synthetic ones — these never reach BM25
|
|
// (we don't re-index it for the test) but they are what the
|
|
// rescue step has to surface.
|
|
for path, name := range map[string]string{
|
|
"svc/billing/Invoice.go": "BillingInvoice",
|
|
"svc/auth/Login.go": "AuthLogin",
|
|
"libs/money/Amount.go": "MoneyAmount",
|
|
} {
|
|
id := path + "::" + name
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: id, Kind: graph.KindFunction, Name: name,
|
|
FilePath: path, StartLine: 1, EndLine: 5, Language: "go",
|
|
})
|
|
}
|
|
|
|
terms := []string{"BillingInvoice", "AuthLogin", "MoneyAmount"}
|
|
merged, _ := fetchAndMergeBM25(srv.engine, terms[0], terms[1:], 20, query.QueryOptions{})
|
|
|
|
mergedNames := map[string]bool{}
|
|
for _, n := range merged {
|
|
mergedNames[n.Name] = true
|
|
}
|
|
for _, want := range terms {
|
|
require.True(t, mergedNames[want], "exact-name rescue dropped %q from merged result", want)
|
|
}
|
|
}
|
|
|
|
// TestFetchAndMergeBM25_AppendsNewMatches verifies that expansion
|
|
// terms bring in additional candidates the primary term missed.
|
|
func TestFetchAndMergeBM25_AppendsNewMatches(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
scope := query.QueryOptions{}
|
|
|
|
primary := srv.engine.SearchSymbolsScoped("helper", 20, scope)
|
|
merged, primaryCount := fetchAndMergeBM25(srv.engine, "helper", []string{"main"}, 20, scope)
|
|
assert.Equal(t, len(primary), primaryCount)
|
|
|
|
primaryIDs := idsOf(primary)
|
|
mergedIDs := idsOf(merged)
|
|
|
|
// Every primary ID appears in the merged set, in primary order
|
|
// at the head.
|
|
require.GreaterOrEqual(t, len(mergedIDs), len(primaryIDs))
|
|
for i, id := range primaryIDs {
|
|
assert.Equal(t, id, mergedIDs[i], "primary order broken at index %d", i)
|
|
}
|
|
// The merge brought in at least one "main"-matched node.
|
|
assert.Greater(t, len(mergedIDs), len(primaryIDs))
|
|
}
|
|
|
|
// TestSearchSymbols_AssistArgPassThrough verifies the new assist arg
|
|
// parses and doesn't break the no-LLM path. Without a service the
|
|
// gate always reads as "no engage" regardless of mode, so results
|
|
// match the no-assist baseline exactly.
|
|
func TestSearchSymbols_AssistArgPassThrough(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
|
|
for _, mode := range []string{"", "auto", "on", "off"} {
|
|
t.Run("assist="+mode, func(t *testing.T) {
|
|
args := map[string]any{"query": "helper"}
|
|
if mode != "" {
|
|
args["assist"] = mode
|
|
}
|
|
result := callTool(t, srv, "search_symbols", args)
|
|
require.False(t, result.IsError, "search failed for mode=%q", mode)
|
|
text := result.Content[0].(mcplib.TextContent).Text
|
|
var resp map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(text), &resp))
|
|
results := resp["results"].([]any)
|
|
require.NotEmpty(t, results, "no results for mode=%q", mode)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPrioritizeCallables(t *testing.T) {
|
|
// Mixed input: BM25-ranked, with callable kinds interleaved among
|
|
// param/field/type nodes. Expected output: callables in their
|
|
// original order, then everything else in its original order.
|
|
nodes := []*graph.Node{
|
|
{ID: "p1", Kind: graph.KindParam},
|
|
{ID: "f1", Kind: graph.KindFunction},
|
|
{ID: "fld1", Kind: graph.KindField},
|
|
{ID: "m1", Kind: graph.KindMethod},
|
|
{ID: "t1", Kind: graph.KindType},
|
|
{ID: "f2", Kind: graph.KindFunction},
|
|
}
|
|
got := prioritizeCallables(nodes)
|
|
want := []string{"f1", "m1", "f2", "p1", "fld1", "t1"}
|
|
gotIDs := idsOf(got)
|
|
if len(gotIDs) != len(want) {
|
|
t.Fatalf("length mismatch: got=%v want=%v", gotIDs, want)
|
|
}
|
|
for i := range want {
|
|
if gotIDs[i] != want[i] {
|
|
t.Errorf("position %d: got=%q want=%q", i, gotIDs[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPrioritizeCallables_AllCallable(t *testing.T) {
|
|
nodes := []*graph.Node{
|
|
{ID: "a", Kind: graph.KindFunction},
|
|
{ID: "b", Kind: graph.KindMethod},
|
|
}
|
|
got := prioritizeCallables(nodes)
|
|
if got[0].ID != "a" || got[1].ID != "b" {
|
|
t.Fatalf("order changed when no reordering needed: %v", idsOf(got))
|
|
}
|
|
}
|
|
|
|
func TestPrioritizeCallables_NoCallable(t *testing.T) {
|
|
nodes := []*graph.Node{
|
|
{ID: "a", Kind: graph.KindParam},
|
|
{ID: "b", Kind: graph.KindField},
|
|
}
|
|
got := prioritizeCallables(nodes)
|
|
if got[0].ID != "a" || got[1].ID != "b" {
|
|
t.Fatalf("order changed when no callables present: %v", idsOf(got))
|
|
}
|
|
}
|
|
|
|
func idsOf(nodes []*graph.Node) []string {
|
|
out := make([]string, len(nodes))
|
|
for i, n := range nodes {
|
|
out[i] = n.ID
|
|
}
|
|
return out
|
|
}
|