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

316 lines
9.0 KiB
Go

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
}
}