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
250 lines
7.0 KiB
Go
250 lines
7.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// callSearchAST is the test harness: build a request with the given
|
|
// args, invoke the MCP handler, decode the JSON result. Any tool
|
|
// error fails the test.
|
|
func callSearchAST(t *testing.T, srv *Server, args map[string]any) map[string]any {
|
|
t.Helper()
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_ast"
|
|
req.Params.Arguments = args
|
|
res, err := srv.handleSearchAST(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("handleSearchAST: %v", err)
|
|
}
|
|
if res.IsError {
|
|
t.Fatalf("error result: %+v", res.Content)
|
|
}
|
|
textBlock := res.Content[0].(mcplib.TextContent)
|
|
var out map[string]any
|
|
if err := json.Unmarshal([]byte(textBlock.Text), &out); err != nil {
|
|
t.Fatalf("json: %v\n%s", err, textBlock.Text)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// writeTempGoFile drops fixture content into a temp dir and returns
|
|
// the absolute path. Tests register a KindFile node pointing at the
|
|
// returned path so the engine can find and parse it.
|
|
func writeTempGoFile(t *testing.T, name, src string) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
abs := filepath.Join(dir, name)
|
|
if err := os.WriteFile(abs, []byte(src), 0o644); err != nil {
|
|
t.Fatalf("write fixture: %v", err)
|
|
}
|
|
return abs
|
|
}
|
|
|
|
func TestSearchAST_RawPattern_GoPanic(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
abs := writeTempGoFile(t, "lib.go", `package x
|
|
|
|
func F() {
|
|
panic("boom")
|
|
}
|
|
`)
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: abs, Kind: graph.KindFile, Name: abs,
|
|
FilePath: abs, Language: "go", StartLine: 1, EndLine: 5,
|
|
})
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: abs + "::F",
|
|
Kind: graph.KindFunction,
|
|
Name: "F",
|
|
FilePath: abs,
|
|
StartLine: 3, EndLine: 5,
|
|
})
|
|
|
|
out := callSearchAST(t, srv, map[string]any{
|
|
"pattern": `((call_expression function: (identifier) @fn) @match (#eq? @fn "panic"))`,
|
|
"language": "go",
|
|
})
|
|
if got, _ := out["total"].(float64); got != 1 {
|
|
t.Fatalf("expected 1 match, got %v\n%v", got, out)
|
|
}
|
|
matches := out["matches"].([]any)
|
|
first := matches[0].(map[string]any)
|
|
if first["symbol_id"] != abs+"::F" {
|
|
t.Errorf("expected enclosing symbol_id %q, got %q", abs+"::F", first["symbol_id"])
|
|
}
|
|
if first["symbol_name"] != "F" {
|
|
t.Errorf("expected symbol_name F, got %v", first["symbol_name"])
|
|
}
|
|
}
|
|
|
|
func TestSearchAST_BundledDetector_HardcodedSecret(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
abs := writeTempGoFile(t, "creds.go", `package x
|
|
|
|
func F() {
|
|
password := "hunter2hunter2hunter"
|
|
emptyDefault := ""
|
|
_ = password
|
|
_ = emptyDefault
|
|
}
|
|
`)
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: abs, Kind: graph.KindFile, Name: abs,
|
|
FilePath: abs, Language: "go", StartLine: 1, EndLine: 8,
|
|
})
|
|
|
|
out := callSearchAST(t, srv, map[string]any{
|
|
"detector": "hardcoded-secret",
|
|
})
|
|
total, _ := out["total"].(float64)
|
|
if total != 1 {
|
|
t.Fatalf("expected 1 match, got %v\n%v", total, out)
|
|
}
|
|
first := out["matches"].([]any)[0].(map[string]any)
|
|
if first["detector"] != "hardcoded-secret" {
|
|
t.Errorf("expected detector enrichment, got %v", first["detector"])
|
|
}
|
|
}
|
|
|
|
func TestSearchAST_PathPrefixFilter(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
matched := writeTempGoFile(t, "match.go", `package x
|
|
func F() { panic("hit") }
|
|
`)
|
|
excluded := writeTempGoFile(t, "skip.go", `package x
|
|
func G() { panic("skip") }
|
|
`)
|
|
for _, p := range []string{matched, excluded} {
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: p, Kind: graph.KindFile, Name: p,
|
|
FilePath: p, Language: "go", StartLine: 1, EndLine: 2,
|
|
})
|
|
}
|
|
|
|
out := callSearchAST(t, srv, map[string]any{
|
|
"pattern": `((call_expression function: (identifier) @fn) @match (#eq? @fn "panic"))`,
|
|
"language": "go",
|
|
"path_prefix": filepath.Dir(matched),
|
|
})
|
|
total, _ := out["total"].(float64)
|
|
if total != 1 {
|
|
t.Fatalf("expected 1 match (path_prefix scopes the file set), got %v", total)
|
|
}
|
|
first := out["matches"].([]any)[0].(map[string]any)
|
|
if first["file"] != matched {
|
|
t.Errorf("expected match in %q, got %q", matched, first["file"])
|
|
}
|
|
}
|
|
|
|
func TestSearchAST_RejectsBadInputs(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
|
|
// No pattern, no detector.
|
|
{
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_ast"
|
|
req.Params.Arguments = map[string]any{}
|
|
res, err := srv.handleSearchAST(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("handleSearchAST: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected error when neither pattern nor detector is set")
|
|
}
|
|
}
|
|
// Pattern without language.
|
|
{
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_ast"
|
|
req.Params.Arguments = map[string]any{"pattern": "(identifier) @match"}
|
|
res, err := srv.handleSearchAST(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("handleSearchAST: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected error for pattern without language")
|
|
}
|
|
}
|
|
// Both pattern and detector.
|
|
{
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_ast"
|
|
req.Params.Arguments = map[string]any{
|
|
"pattern": "(identifier) @match",
|
|
"detector": "panic-in-library",
|
|
"language": "go",
|
|
}
|
|
res, err := srv.handleSearchAST(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("handleSearchAST: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected error for mutually-exclusive args")
|
|
}
|
|
}
|
|
// Unknown detector.
|
|
{
|
|
req := mcplib.CallToolRequest{}
|
|
req.Params.Name = "search_ast"
|
|
req.Params.Arguments = map[string]any{"detector": "no-such-rule"}
|
|
res, err := srv.handleSearchAST(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("handleSearchAST: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Fatal("expected error for unknown detector")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSearchAST_MinFanInOfEnclosingFunc(t *testing.T) {
|
|
srv, _ := setupTestServer(t)
|
|
abs := writeTempGoFile(t, "two.go", `package x
|
|
|
|
func Hot() { panic("a") }
|
|
func Cold() { panic("b") }
|
|
`)
|
|
srv.graph.AddNode(&graph.Node{
|
|
ID: abs, Kind: graph.KindFile, Name: abs,
|
|
FilePath: abs, Language: "go", StartLine: 1, EndLine: 4,
|
|
})
|
|
hot := &graph.Node{ID: abs + "::Hot", Kind: graph.KindFunction, Name: "Hot",
|
|
FilePath: abs, StartLine: 3, EndLine: 3}
|
|
cold := &graph.Node{ID: abs + "::Cold", Kind: graph.KindFunction, Name: "Cold",
|
|
FilePath: abs, StartLine: 4, EndLine: 4}
|
|
srv.graph.AddNode(hot)
|
|
srv.graph.AddNode(cold)
|
|
// Three callers on Hot, zero on Cold.
|
|
for i := 0; i < 3; i++ {
|
|
caller := &graph.Node{
|
|
ID: abs + "::caller" + string(rune('A'+i)),
|
|
Kind: graph.KindFunction,
|
|
Name: "caller",
|
|
FilePath: abs, StartLine: 10 + i, EndLine: 10 + i,
|
|
}
|
|
srv.graph.AddNode(caller)
|
|
srv.graph.AddEdge(&graph.Edge{From: caller.ID, To: hot.ID, Kind: graph.EdgeCalls})
|
|
}
|
|
|
|
out := callSearchAST(t, srv, map[string]any{
|
|
"detector": "panic-in-library",
|
|
"min_fan_in_of_enclosing_func": float64(2),
|
|
})
|
|
total, _ := out["total"].(float64)
|
|
if total != 1 {
|
|
t.Fatalf("expected only Hot's panic to survive min_fan_in=2 filter, got total=%v", total)
|
|
}
|
|
first := out["matches"].([]any)[0].(map[string]any)
|
|
if first["symbol_name"] != "Hot" {
|
|
t.Errorf("expected enclosing symbol Hot, got %v", first["symbol_name"])
|
|
}
|
|
}
|