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

245 lines
9.4 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/graph"
)
func newCheckRefsTestServer(t *testing.T) *Server {
t.Helper()
g := graph.New()
g.AddNode(&graph.Node{ID: "p/handler.go::Handle", Name: "Handle", Kind: graph.KindFunction, FilePath: "p/handler.go", StartLine: 5})
g.AddNode(&graph.Node{ID: "p/router.go::Route", Name: "Route", Kind: graph.KindFunction, FilePath: "p/router.go", StartLine: 10})
g.AddNode(&graph.Node{ID: "p/router_test.go::TestRoute", Name: "TestRoute", Kind: graph.KindFunction, FilePath: "p/router_test.go", StartLine: 5})
// Route calls Handle (production caller).
g.AddEdge(&graph.Edge{From: "p/router.go::Route", To: "p/handler.go::Handle", Kind: graph.EdgeCalls})
// TestRoute references Handle (test caller).
g.AddEdge(&graph.Edge{From: "p/router_test.go::TestRoute", To: "p/handler.go::Handle", Kind: graph.EdgeReferences})
// A different file defines a symbol with the same Name as Handle.
g.AddNode(&graph.Node{ID: "other/util.go::Handle", Name: "Handle", Kind: graph.KindFunction, FilePath: "other/util.go", StartLine: 20})
// File-import edges so importing_files works: importer.go imports handler.go.
g.AddNode(&graph.Node{ID: "p/importer.go", Name: "importer.go", Kind: graph.KindFile, FilePath: "p/importer.go"})
g.AddNode(&graph.Node{ID: "p/handler.go", Name: "handler.go", Kind: graph.KindFile, FilePath: "p/handler.go"})
g.AddEdge(&graph.Edge{From: "p/importer.go", To: "p/handler.go", Kind: graph.EdgeImports})
return &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
}
func callCheckRefs(t *testing.T, s *Server, args map[string]any) map[string]any {
t.Helper()
req := mcp.CallToolRequest{}
req.Params.Arguments = args
res, err := s.handleCheckReferences(context.Background(), req)
require.NoError(t, err)
require.NotNil(t, res)
if res.IsError {
return map[string]any{"is_error": true}
}
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 TestCheckReferences_Referenced(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{
"symbol_id": "p/handler.go::Handle",
})
assert.Equal(t, true, out["referenced"])
assert.EqualValues(t, 2, out["total_references"].(float64))
byKind := out["by_kind"].(map[string]any)
assert.EqualValues(t, 1, byKind["calls"].(float64))
assert.EqualValues(t, 1, byKind["references"].(float64))
assert.EqualValues(t, 1, out["caller_count"].(float64))
}
func TestCheckReferences_NoReferences(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "x::Lonely", Name: "Lonely", Kind: graph.KindFunction, FilePath: "x.go"})
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callCheckRefs(t, s, map[string]any{"symbol_id": "x::Lonely"})
assert.Equal(t, false, out["referenced"])
assert.EqualValues(t, 0, out["total_references"].(float64))
}
func TestCheckReferences_SameNameElsewhere(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{
"symbol_id": "p/handler.go::Handle",
})
sn, _ := out["same_name_elsewhere"].([]any)
require.Len(t, sn, 1, "other/util.go::Handle should surface as same-name elsewhere")
row := sn[0].(map[string]any)
assert.Equal(t, "other/util.go::Handle", row["id"])
}
// TestCheckReferences_EvidencePrefersCallSiteLine pins the bug where
// two distinct calls from the same caller collapsed into evidence
// rows pinned to the caller's start line. The fix prefers Edge.Line
// / Edge.FilePath when populated.
func TestCheckReferences_EvidencePrefersCallSiteLine(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "p/target.go::Target", Name: "Target", Kind: graph.KindFunction, FilePath: "p/target.go", StartLine: 5})
g.AddNode(&graph.Node{ID: "p/caller.go::Caller", Name: "Caller", Kind: graph.KindFunction, FilePath: "p/caller.go", StartLine: 10})
// Two distinct call sites from Caller, each carrying its own Line.
g.AddEdge(&graph.Edge{From: "p/caller.go::Caller", To: "p/target.go::Target", Kind: graph.EdgeCalls, FilePath: "p/caller.go", Line: 27})
g.AddEdge(&graph.Edge{From: "p/caller.go::Caller", To: "p/target.go::Target", Kind: graph.EdgeCalls, FilePath: "p/caller.go", Line: 42})
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callCheckRefs(t, s, map[string]any{"symbol_id": "p/target.go::Target"})
ev, _ := out["evidence"].([]any)
require.Len(t, ev, 2, "two distinct call sites must produce two evidence rows")
row0 := ev[0].(map[string]any)
row1 := ev[1].(map[string]any)
// Lines must reflect the edges, not the caller's StartLine (10).
got := []float64{row0["line"].(float64), row1["line"].(float64)}
require.ElementsMatch(t, []float64{27, 42}, got,
"evidence line must surface the call-site, got %v", got)
}
func TestCheckReferences_ImportingFilesFound(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{"symbol_id": "p/handler.go::Handle"})
imps, _ := out["importing_files"].([]any)
require.Len(t, imps, 1)
assert.Equal(t, "p/importer.go", imps[0].(string))
}
func TestCheckReferences_ExcludeTests(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{
"symbol_id": "p/handler.go::Handle",
"exclude_tests": true,
})
byKind := out["by_kind"].(map[string]any)
// The TestRoute reference (in router_test.go) is dropped.
_, hasRefs := byKind["references"]
assert.False(t, hasRefs, "test-file references dropped under exclude_tests")
assert.EqualValues(t, 1, out["total_references"].(float64))
}
func TestCheckReferences_NameOnly(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{"name": "Handle"})
// No symbol_id → no in-edge walk; only same-name + (no) importing files.
assert.EqualValues(t, 0, out["total_references"].(float64))
sn, _ := out["same_name_elsewhere"].([]any)
assert.GreaterOrEqual(t, len(sn), 2, "name-only scan finds both Handle nodes")
}
func TestCheckReferences_RejectsEmptyArgs(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{})
assert.True(t, out["is_error"] == true)
}
func TestCheckReferences_UnknownSymbolGracefullyHandled(t *testing.T) {
s := newCheckRefsTestServer(t)
out := callCheckRefs(t, s, map[string]any{"symbol_id": "does/not/exist::Z"})
// Unknown symbol id: total_references=0, same_name lookup runs
// only if name was supplied. Verdict: not referenced.
assert.Equal(t, false, out["referenced"])
}
func TestCheckReferences_EvidenceLimit(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "t::T", Name: "T", Kind: graph.KindFunction, FilePath: "t.go"})
// 20 callers.
for i := range 20 {
id := "c" + string(rune('A'+i))
g.AddNode(&graph.Node{ID: id, Name: id, Kind: graph.KindFunction, FilePath: id + ".go"})
g.AddEdge(&graph.Edge{From: id, To: "t::T", Kind: graph.EdgeCalls})
}
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callCheckRefs(t, s, map[string]any{
"symbol_id": "t::T",
"evidence_limit": 5,
})
ev, _ := out["evidence"].([]any)
assert.Len(t, ev, 5)
assert.EqualValues(t, 20, out["total_references"].(float64), "total_references counts every edge, not just evidence rows")
}
func TestCheckReferences_MinTierFilter(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "t::T", Name: "T", Kind: graph.KindFunction, FilePath: "t.go"})
g.AddNode(&graph.Node{ID: "c1::C", Name: "C", Kind: graph.KindFunction, FilePath: "c.go"})
g.AddEdge(&graph.Edge{From: "c1::C", To: "t::T", Kind: graph.EdgeCalls, Origin: graph.OriginTextMatched})
g.AddNode(&graph.Node{ID: "c2::D", Name: "D", Kind: graph.KindFunction, FilePath: "d.go"})
g.AddEdge(&graph.Edge{From: "c2::D", To: "t::T", Kind: graph.EdgeCalls, Origin: graph.OriginLSPResolved})
s := &Server{
graph: g,
session: newSessionState(),
tokenStats: &tokenStats{},
symHistory: &symbolHistory{entries: make(map[string][]SymbolModification)},
sessions: newSessionMap(),
toolScopes: newScopeRegistry(),
}
out := callCheckRefs(t, s, map[string]any{
"symbol_id": "t::T",
"min_tier": "lsp_resolved",
})
// Only the lsp_resolved edge survives the tier filter.
assert.EqualValues(t, 1, out["total_references"].(float64))
}
func TestAtOrAboveTier(t *testing.T) {
assert.True(t, atOrAboveTier("lsp_resolved", "ast_resolved"))
assert.True(t, atOrAboveTier("lsp_resolved", "lsp_resolved"))
assert.False(t, atOrAboveTier("text_matched", "ast_resolved"))
assert.False(t, atOrAboveTier("ast_inferred", "lsp_resolved"))
// Empty actual treated as text_matched.
assert.False(t, atOrAboveTier("", "lsp_resolved"))
assert.True(t, atOrAboveTier("", "text_matched"))
}