package mcp import ( "context" "fmt" "sort" "strings" mcp "github.com/mark3labs/mcp-go/mcp" "github.com/zzet/gortex/internal/astquery" ) // --------------------------------------------------------------------------- // analyze kind=unsafe_patterns // --------------------------------------------------------------------------- // // Bundled scan for unsafe / panic-prone primitives across every // supported language. Wraps the per-language astquery detectors // listed in astquery.UnsafePatternDetectors, fans them out across // the indexed file set, and aggregates the matches into one // per-site row plus a per-detector summary. // // Why a bundled kind: the underlying detectors are already // invocable individually via `search_ast detector=…` — the bundle // exists so an agent can ask "show me every panic-prone site in // this repo" with one call instead of N calls, while still being // able to narrow by `language`, `detector`, `path_prefix`, and // `severity`. type unsafePatternRow struct { Detector string `json:"detector"` Severity string `json:"severity"` Language string `json:"language"` File string `json:"file"` Line int `json:"line"` Symbol string `json:"symbol,omitempty"` Text string `json:"text,omitempty"` } type unsafePatternSummary struct { Detector string `json:"detector"` Severity string `json:"severity"` Count int `json:"count"` } // handleAnalyzeUnsafePatterns runs the bundled unsafe-pattern // detectors and returns aggregated rows + per-detector counts. // // Filters (all optional): // - language — comma-separated subset (rust,python,javascript,typescript,go). // - detector — comma-separated subset (must be a member of // astquery.UnsafePatternDetectors). Lets the agent // narrow the bundle without falling back to // individual search_ast calls. // - severity — comma-separated subset (error,warning,info). // - path_prefix — keep matches whose file path starts with this. // - limit — cap rows (default 200; matches error_surface's UX). // - exclude_tests — override per-detector default (defaults to true). func (s *Server) handleAnalyzeUnsafePatterns(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { args := req.GetArguments() languageFilter := parseCSVSet(stringArg(args, "language")) detectorFilter := parseCSVSet(stringArg(args, "detector")) severityFilter := parseCSVSet(stringArg(args, "severity")) pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix")) limit := intArg(args, "limit", 200) excludeTests, excludeTestsSet := boolArg(args, "exclude_tests") allowedRepos, err := s.resolveRepoFilter(ctx, req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } // Build target list once; reused across every detector to avoid // re-walking the KindFile node set per rule. targets, err := s.buildASTTargets("", pathPrefix, allowedRepos) if err != nil { return mcp.NewToolResultError(err.Error()), nil } // Per-file enclosing-symbol index — shared across detectors so // every row gets symbol enrichment without re-building. fileSymbols := s.buildFileSymbolIndex(targets) lookup := func(graphPath string, line int) (string, string) { idx := fileSymbols[graphPath] if idx == nil { return "", "" } return idx.find(line) } // Reject unknown detector names early so the agent gets a // pointed error instead of an empty result. if len(detectorFilter) > 0 { known := make(map[string]struct{}, len(astquery.UnsafePatternDetectors)) for _, n := range astquery.UnsafePatternDetectors { known[n] = struct{}{} } var unknown []string for n := range detectorFilter { if _, ok := known[n]; !ok { unknown = append(unknown, n) } } if len(unknown) > 0 { sort.Strings(unknown) return mcp.NewToolResultError(fmt.Sprintf( "analyze unsafe_patterns: unknown detector(s) %s (valid: %s)", strings.Join(unknown, ","), strings.Join(astquery.UnsafePatternDetectors, ","), )), nil } } rows := make([]unsafePatternRow, 0, 64) summary := make(map[string]*unsafePatternSummary, len(astquery.UnsafePatternDetectors)) var errsAcc []string for _, name := range astquery.UnsafePatternDetectors { if len(detectorFilter) > 0 { if _, ok := detectorFilter[name]; !ok { continue } } opts := astquery.Options{ Detector: name, Targets: targets, SymbolLookup: lookup, Resolver: astquery.DefaultLanguageResolver, // Generous per-detector cap; the outer `limit` is the // agent-facing budget. Picking 5000 protects against a // pathological repo where one detector returns tens of // thousands of rows and starves the rest of the bundle. Limit: 5000, } if excludeTestsSet { opts.ExcludeTests = excludeTests } else { // Default to true — every bundled detector already // opts in, but we force the engine flag too so a // caller that wires in a detector with ExcludeTests= // false doesn't accidentally bleed test noise into // the bundle. opts.ExcludeTests = true } res, runErr := astquery.Run(ctx, opts) if runErr != nil { errsAcc = append(errsAcc, fmt.Sprintf("%s: %v", name, runErr)) continue } errsAcc = append(errsAcc, res.Errors...) entry := summary[name] if entry == nil { sev := "" if len(res.Matches) > 0 { sev = res.Matches[0].Severity } entry = &unsafePatternSummary{Detector: name, Severity: sev} summary[name] = entry } for _, m := range res.Matches { if entry.Severity == "" { entry.Severity = m.Severity } if len(languageFilter) > 0 { if _, ok := languageFilter[strings.ToLower(m.Language)]; !ok { continue } } if len(severityFilter) > 0 { if _, ok := severityFilter[strings.ToLower(m.Severity)]; !ok { continue } } rows = append(rows, unsafePatternRow{ Detector: m.Detector, Severity: m.Severity, Language: m.Language, File: m.File, Line: m.Line, Symbol: m.SymbolID, Text: m.Text, }) entry.Count++ } } // Stable order: severity-rank (error > warning > info), then // detector, then file, then line. Severity-first keeps the // most impactful rows at the top when the agent truncates by // `limit`. sort.Slice(rows, func(i, j int) bool { ri, rj := severityRank(rows[i].Severity), severityRank(rows[j].Severity) if ri != rj { return ri > rj } if rows[i].Detector != rows[j].Detector { return rows[i].Detector < rows[j].Detector } if rows[i].File != rows[j].File { return rows[i].File < rows[j].File } return rows[i].Line < rows[j].Line }) totalRows := len(rows) truncated := false if limit > 0 && len(rows) > limit { rows = rows[:limit] truncated = true } summaries := make([]unsafePatternSummary, 0, len(summary)) for _, name := range astquery.UnsafePatternDetectors { if len(detectorFilter) > 0 { if _, ok := detectorFilter[name]; !ok { continue } } entry := summary[name] if entry == nil { continue } summaries = append(summaries, *entry) } sort.Slice(summaries, func(i, j int) bool { ri, rj := severityRank(summaries[i].Severity), severityRank(summaries[j].Severity) if ri != rj { return ri > rj } if summaries[i].Count != summaries[j].Count { return summaries[i].Count > summaries[j].Count } return summaries[i].Detector < summaries[j].Detector }) if s.isGCX(ctx, req) { items := make([]unsafePatternItem, 0, len(rows)) for _, r := range rows { items = append(items, unsafePatternItem(r)) } return s.gcxResponseWithBudget(req)(encodeAnalyze("unsafe_patterns", items)) } if isCompact(req) { var b strings.Builder for _, r := range rows { sym := r.Symbol if sym == "" { sym = "-" } fmt.Fprintf(&b, "%-7s %-26s %s:%d %s\n", r.Severity, r.Detector, r.File, r.Line, sym) } if len(rows) == 0 { b.WriteString("no unsafe patterns\n") } return mcp.NewToolResultText(b.String()), nil } resp := map[string]any{ "matches": rows, "summary": summaries, "total": totalRows, "truncated": truncated, } if truncated { resp["limit"] = limit } if len(errsAcc) > 0 { resp["errors"] = errsAcc } return s.respondJSONOrTOON(ctx, req, resp) } // parseCSVSet splits "rust,python" into {rust:{}, python:{}}. // Empty input returns nil so callers can `len(m) == 0` to short- // circuit the filter. func parseCSVSet(in string) map[string]struct{} { in = strings.TrimSpace(in) if in == "" { return nil } parts := strings.Split(in, ",") out := make(map[string]struct{}, len(parts)) for _, p := range parts { p = strings.TrimSpace(strings.ToLower(p)) if p == "" { continue } out[p] = struct{}{} } if len(out) == 0 { return nil } return out } // severityRank maps a severity label to a sort key so "error" beats // "warning" beats "info" without a fragile string comparison. func severityRank(sev string) int { switch strings.ToLower(sev) { case "error": return 3 case "warning": return 2 case "info": return 1 default: return 0 } }