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

228 lines
6.3 KiB
Go

package mcp
import (
"context"
"fmt"
"sort"
"strings"
mcp "github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// ---------------------------------------------------------------------------
// analyze kind=tests_as_edges
// ---------------------------------------------------------------------------
//
// A first-class view over the EdgeTests layer — the test → code edges
// the indexer stamps for every call from a test function to a
// non-test symbol. get_untested_symbols answers the negative ("what
// has no tests"); this answers the positive: which symbols ARE
// exercised, and by which tests.
//
// group_by=symbol (default) lists each tested symbol with the tests
// covering it; group_by=test inverts it — each test with the symbols
// it exercises. Both carry a summary of the edge layer's size.
type testEdgeRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type testEdgeRow struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
Line int `json:"line"`
Related []testEdgeRef `json:"related"`
Count int `json:"count"`
}
func (s *Server) handleAnalyzeTestsAsEdges(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
limit := intArg(args, "limit", 200)
groupBy := strings.ToLower(strings.TrimSpace(stringArg(args, "group_by")))
if groupBy == "" {
groupBy = "symbol"
}
if groupBy != "symbol" && groupBy != "test" {
return mcp.NewToolResultError("analyze tests_as_edges: group_by must be 'symbol' or 'test'"), nil
}
// Collect the EdgeTests layer. From = test function, To = the
// non-test symbol it exercises.
testsBySymbol := make(map[string][]string)
symbolsByTest := make(map[string][]string)
edgeCount := 0
for e := range edgesByKinds(s.graph, graph.EdgeTests) {
edgeCount++
testsBySymbol[e.To] = append(testsBySymbol[e.To], e.From)
symbolsByTest[e.From] = append(symbolsByTest[e.From], e.To)
}
primary := testsBySymbol
if groupBy == "test" {
primary = symbolsByTest
}
// Batch-fetch every primary key and every related ID in one bulk
// round-trip. On a repo with thousands of EdgeTests edges the old
// per-id GetNode pattern burned one round-trip per row plus
// one per related ID on a disk backend — easily 5-10k round-trips per
// analyze kind=tests_as_edges call.
idSet := make(map[string]struct{}, len(primary))
for id, relatedIDs := range primary {
idSet[id] = struct{}{}
for _, rid := range relatedIDs {
idSet[rid] = struct{}{}
}
}
allIDs := make([]string, 0, len(idSet))
for id := range idSet {
allIDs = append(allIDs, id)
}
nodeByID := s.graph.GetNodesByIDs(allIDs)
rows := make([]testEdgeRow, 0, len(primary))
for id, relatedIDs := range primary {
n := nodeByID[id]
if n == nil {
continue
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
related := make([]testEdgeRef, 0, len(relatedIDs))
seen := make(map[string]bool, len(relatedIDs))
for _, rid := range relatedIDs {
if seen[rid] {
continue
}
seen[rid] = true
name := rid
if rn := nodeByID[rid]; rn != nil {
name = rn.Name
}
related = append(related, testEdgeRef{ID: rid, Name: name})
}
sort.Slice(related, func(i, j int) bool { return related[i].ID < related[j].ID })
rows = append(rows, testEdgeRow{
ID: n.ID,
Name: n.Name,
Kind: string(n.Kind),
File: n.FilePath,
Line: n.StartLine,
Related: related,
Count: len(related),
})
}
// Scope narrowing: keep a row iff its primary symbol is visible to
// the request (workspace ceiling + optional repo allow-set), prune
// its related peers to the visible set, recompute Count, and rebuild
// the summary from the in-scope rows. Unbound requests skip this
// branch — a byte-for-byte no-op.
testEdges := edgeCount
testedSymbols := len(testsBySymbol)
testFunctions := len(symbolsByTest)
if s.scopeFiltersActive(ctx) {
kept := make([]testEdgeRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, nodeByID[r.ID]) {
continue
}
related := make([]testEdgeRef, 0, len(r.Related))
for _, ref := range r.Related {
if s.analyzeNodeVisible(ctx, nodeByID[ref.ID]) {
related = append(related, ref)
}
}
r.Related = related
r.Count = len(related)
kept = append(kept, r)
}
rows = kept
edges := 0
relatedSet := make(map[string]struct{})
for _, r := range rows {
edges += r.Count
for _, ref := range r.Related {
relatedSet[ref.ID] = struct{}{}
}
}
testEdges = edges
if groupBy == "symbol" {
testedSymbols = len(rows)
testFunctions = len(relatedSet)
} else {
testFunctions = len(rows)
testedSymbols = len(relatedSet)
}
}
// Most-covered first — the symbols with the deepest test backing,
// or the tests exercising the most code.
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].ID < rows[j].ID
})
total := len(rows)
truncated := false
if limit > 0 && len(rows) > limit {
rows = rows[:limit]
truncated = true
}
relatedKey := "tests"
if groupBy == "test" {
relatedKey = "covers"
}
// Re-key the generic "related" field to the group-appropriate name
// in the JSON payload via a thin projection.
projected := make([]map[string]any, 0, len(rows))
for _, r := range rows {
projected = append(projected, map[string]any{
"id": r.ID,
"name": r.Name,
"kind": r.Kind,
"file": r.File,
"line": r.Line,
relatedKey: r.Related,
"edge_count": r.Count,
})
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d %s:%d %s\n", r.Count, r.File, r.Line, r.ID)
}
if len(rows) == 0 {
b.WriteString("no test edges in scope\n")
}
return mcp.NewToolResultText(b.String()), nil
}
resp := map[string]any{
"group_by": groupBy,
"rows": projected,
"total": total,
"summary": map[string]any{
"test_edges": testEdges,
"tested_symbols": testedSymbols,
"test_functions": testFunctions,
},
"truncated": truncated,
}
if truncated {
resp["limit"] = limit
}
return s.respondJSONOrTOON(ctx, req, resp)
}