package mcp import ( "context" "encoding/json" "os" "path/filepath" "testing" mcplib "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" ) // callAnalyzeUnsafe is the shared harness: build a request with the // given args, invoke the analyze handler, decode the JSON. Any tool- // level error fails the test. func callAnalyzeUnsafe(t *testing.T, srv *Server, extra map[string]any) map[string]any { t.Helper() args := map[string]any{"kind": "unsafe_patterns"} for k, v := range extra { args[k] = v } req := mcplib.CallToolRequest{} req.Params.Name = "analyze" req.Params.Arguments = args res, err := srv.handleAnalyze(context.Background(), req) require.NoError(t, err) require.False(t, res.IsError, "analyze unsafe_patterns must not error: %+v", res.Content) text := res.Content[0].(mcplib.TextContent).Text var out map[string]any require.NoError(t, json.Unmarshal([]byte(text), &out), "json: %s", text) return out } // writeUnsafeFixture drops a fixture file into a temp dir and // registers a KindFile node for it so the AST engine can discover // and parse the file via buildASTTargets. func writeUnsafeFixture(t *testing.T, srv *Server, name, lang, src string) string { t.Helper() dir := t.TempDir() abs := filepath.Join(dir, name) require.NoError(t, os.WriteFile(abs, []byte(src), 0o644)) srv.graph.AddNode(&graph.Node{ ID: abs, Kind: graph.KindFile, Name: abs, FilePath: abs, Language: lang, StartLine: 1, EndLine: 100, }) return abs } // TestAnalyzeDispatcher_RoutesUnsafePatterns regression-protects // the dispatcher wiring: passing kind=unsafe_patterns must not // return an unknown-kind error. func TestAnalyzeDispatcher_RoutesUnsafePatterns(t *testing.T) { srv, _ := setupTestServer(t) req := mcplib.CallToolRequest{} req.Params.Name = "analyze" req.Params.Arguments = map[string]any{"kind": "unsafe_patterns"} res, err := srv.handleAnalyze(context.Background(), req) require.NoError(t, err) require.False(t, res.IsError, "dispatcher must route kind=unsafe_patterns without error; got %v", res) } func TestAnalyzeUnsafePatterns_RustUnwrapAndPanic(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run(s: &str) { let _n: i32 = s.parse().unwrap(); panic!("boom"); todo!("later"); } `) out := callAnalyzeUnsafe(t, srv, nil) total, _ := out["total"].(float64) require.GreaterOrEqual(t, total, float64(3), "expected at least 3 matches (unwrap + panic! + todo!) — got %v\n%v", total, out) matches := out["matches"].([]any) detectors := map[string]int{} for _, m := range matches { row := m.(map[string]any) detectors[row["detector"].(string)]++ assert.Equal(t, "rust", row["language"]) } assert.Equal(t, 1, detectors["unsafe-rust-unwrap"]) assert.Equal(t, 2, detectors["unsafe-rust-panic-macro"]) // Severity is set per detector. summary := out["summary"].([]any) require.NotEmpty(t, summary) } func TestAnalyzeUnsafePatterns_FilterByDetector(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run(s: &str) { let _ = s.parse::().unwrap(); panic!("boom"); } `) out := callAnalyzeUnsafe(t, srv, map[string]any{"detector": "unsafe-rust-unwrap"}) total, _ := out["total"].(float64) assert.Equal(t, float64(1), total, "detector filter must restrict to unsafe-rust-unwrap only") matches := out["matches"].([]any) for _, m := range matches { row := m.(map[string]any) assert.Equal(t, "unsafe-rust-unwrap", row["detector"]) } } func TestAnalyzeUnsafePatterns_FilterByLanguage(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run() { panic!("x"); } `) writeUnsafeFixture(t, srv, "lib.py", "python", `def run(x): assert x > 0 `) out := callAnalyzeUnsafe(t, srv, map[string]any{"language": "python"}) total, _ := out["total"].(float64) require.Equal(t, float64(1), total, "language=python must drop the Rust match") matches := out["matches"].([]any) row := matches[0].(map[string]any) assert.Equal(t, "python", row["language"]) assert.Equal(t, "unsafe-python-assert", row["detector"]) } func TestAnalyzeUnsafePatterns_FilterBySeverity(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run() { panic!("warn"); assert!(1 == 1); } `) // severity=warning keeps panic!; severity=info keeps assert!. outWarn := callAnalyzeUnsafe(t, srv, map[string]any{"severity": "warning"}) for _, m := range outWarn["matches"].([]any) { assert.Equal(t, "warning", m.(map[string]any)["severity"]) } outInfo := callAnalyzeUnsafe(t, srv, map[string]any{"severity": "info"}) for _, m := range outInfo["matches"].([]any) { assert.Equal(t, "info", m.(map[string]any)["severity"]) } } func TestAnalyzeUnsafePatterns_JSThrow(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.ts", "typescript", `function run(x: number): number { if (x < 0) { throw new Error("neg"); } return x; } `) out := callAnalyzeUnsafe(t, srv, map[string]any{"language": "typescript"}) total, _ := out["total"].(float64) require.Equal(t, float64(1), total) row := out["matches"].([]any)[0].(map[string]any) assert.Equal(t, "unsafe-js-throw", row["detector"]) assert.Equal(t, "typescript", row["language"]) } func TestAnalyzeUnsafePatterns_UnknownDetectorErrors(t *testing.T) { srv, _ := setupTestServer(t) req := mcplib.CallToolRequest{} req.Params.Name = "analyze" req.Params.Arguments = map[string]any{ "kind": "unsafe_patterns", "detector": "no-such-detector", } res, err := srv.handleAnalyze(context.Background(), req) require.NoError(t, err) require.True(t, res.IsError, "unknown detector name must surface as an error result") } func TestAnalyzeUnsafePatterns_PathPrefixScopes(t *testing.T) { srv, _ := setupTestServer(t) keep := writeUnsafeFixture(t, srv, "in.rs", "rust", `fn run() { panic!("x"); }`) writeUnsafeFixture(t, srv, "out.rs", "rust", `fn run() { panic!("y"); }`) out := callAnalyzeUnsafe(t, srv, map[string]any{"path_prefix": keep}) total, _ := out["total"].(float64) require.Equal(t, float64(1), total, "path_prefix must scope to the named file") row := out["matches"].([]any)[0].(map[string]any) assert.Equal(t, keep, row["file"]) } func TestAnalyzeUnsafePatterns_LimitTruncates(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run() { panic!("a"); panic!("b"); panic!("c"); } `) out := callAnalyzeUnsafe(t, srv, map[string]any{"limit": 2.0}) total, _ := out["total"].(float64) assert.Equal(t, float64(3), total, "total must report the pre-truncation count") truncated, _ := out["truncated"].(bool) assert.True(t, truncated) matches := out["matches"].([]any) assert.Equal(t, 2, len(matches)) } func TestAnalyzeUnsafePatterns_GCXEncodesHeader(t *testing.T) { srv, _ := setupTestServer(t) writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run() { panic!("x"); }`) req := mcplib.CallToolRequest{} req.Params.Name = "analyze" req.Params.Arguments = map[string]any{ "kind": "unsafe_patterns", "format": "gcx", } res, err := srv.handleAnalyze(context.Background(), req) require.NoError(t, err) require.False(t, res.IsError) text := res.Content[0].(mcplib.TextContent).Text assert.Contains(t, text, "analyze.unsafe_patterns") for _, col := range []string{"detector", "severity", "language", "file", "line", "symbol", "text"} { assert.Contains(t, text, col, "GCX header must list column %q", col) } } func TestAnalyzeUnsafePatterns_CompactOutput(t *testing.T) { srv, _ := setupTestServer(t) abs := writeUnsafeFixture(t, srv, "lib.rs", "rust", `fn run() { panic!("x"); }`) req := mcplib.CallToolRequest{} req.Params.Name = "analyze" req.Params.Arguments = map[string]any{ "kind": "unsafe_patterns", "compact": true, } res, err := srv.handleAnalyze(context.Background(), req) require.NoError(t, err) require.False(t, res.IsError) text := res.Content[0].(mcplib.TextContent).Text assert.Contains(t, text, "unsafe-rust-panic-macro") assert.Contains(t, text, abs) }