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
228 lines
6.3 KiB
Go
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)
|
|
}
|