a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
206 lines
6.3 KiB
Go
206 lines
6.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// handleGetUntestedSymbols returns functions and methods in non-test files
|
|
// that no test file reaches via the call graph — the inverse of
|
|
// get_test_targets. Test reachability is computed in a single forward BFS
|
|
// from every symbol defined in a test file, walking `calls` and
|
|
// `references` edges; any non-test function/method not visited is
|
|
// considered uncovered.
|
|
//
|
|
// This is the "where should we add tests first" tool: results are sorted by
|
|
// fan_in descending so the most-used untested symbols surface at the top —
|
|
// they're the ones most likely to break something when changed without a
|
|
// safety net.
|
|
func (s *Server) handleGetUntestedSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
limit := req.GetInt("limit", 50)
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
filePrefix := req.GetString("file_prefix", "")
|
|
minFanIn := req.GetInt("min_fan_in", 0)
|
|
|
|
covered := reachableFromTests(s.graph)
|
|
|
|
// Fan-in map for ranking — incoming calls/references only; imports and
|
|
// defines would flood every exported symbol with meaningless coverage.
|
|
// Backends that implement graph.InEdgeCounter serve this from one
|
|
// count(*) join — on a disk backend the legacy AllEdges() loop
|
|
// materialised every edge over the storage boundary just to bucket two kinds. The
|
|
// fallback walks AllEdges() as before.
|
|
fanIn := collectFanInByKind(s.graph, []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences})
|
|
|
|
type untestedEntry struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Line int `json:"line"`
|
|
FanIn int `json:"fan_in"`
|
|
}
|
|
|
|
var entries []untestedEntry
|
|
totalCandidates := 0
|
|
scoped := s.scopedNodesByKinds(ctx, []graph.NodeKind{graph.KindFunction, graph.KindMethod})
|
|
for _, n := range scoped {
|
|
// Skip symbols defined inside test files — those ARE test code.
|
|
if isTestFile(n.FilePath) {
|
|
continue
|
|
}
|
|
if filePrefix != "" && !strings.HasPrefix(n.FilePath, filePrefix) {
|
|
continue
|
|
}
|
|
totalCandidates++
|
|
if covered[n.ID] {
|
|
continue
|
|
}
|
|
fi := fanIn[n.ID]
|
|
if fi < minFanIn {
|
|
continue
|
|
}
|
|
entries = append(entries, untestedEntry{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: string(n.Kind),
|
|
FilePath: n.FilePath,
|
|
Line: n.StartLine,
|
|
FanIn: fi,
|
|
})
|
|
}
|
|
|
|
// Rank by fan_in desc, then file_path asc for stable ordering.
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].FanIn != entries[j].FanIn {
|
|
return entries[i].FanIn > entries[j].FanIn
|
|
}
|
|
if entries[i].FilePath != entries[j].FilePath {
|
|
return entries[i].FilePath < entries[j].FilePath
|
|
}
|
|
return entries[i].ID < entries[j].ID
|
|
})
|
|
totalUncovered := len(entries)
|
|
if len(entries) > limit {
|
|
entries = entries[:limit]
|
|
}
|
|
|
|
var coverageRatio float64
|
|
if totalCandidates > 0 {
|
|
coverageRatio = float64(totalCandidates-totalUncovered) / float64(totalCandidates)
|
|
}
|
|
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"untested": entries,
|
|
"total_candidates": totalCandidates,
|
|
"total_uncovered": totalUncovered,
|
|
"coverage_ratio": coverageRatio,
|
|
"truncated": totalUncovered > len(entries),
|
|
})
|
|
}
|
|
|
|
// reachableFromTests computes the set of symbol IDs reachable from any
|
|
// symbol defined in a test file, following outgoing `calls` and
|
|
// `references` edges. One pass over the graph at O(V+E) beats
|
|
// per-symbol BFS which would be O(V·(V+E)).
|
|
//
|
|
// Test files are detected via isTestFile so this works across languages
|
|
// (Go _test.go, Python test_*.py, JS .spec.ts, etc.) without per-language
|
|
// special-casing here.
|
|
//
|
|
// Seeds the frontier via NodesByKind(function|method) so disk backends
|
|
// only materialise the two kinds rather than the whole node table.
|
|
// The test-file predicate is a Go string heuristic — the backend has
|
|
// no equivalent — so it stays in the post-filter.
|
|
//
|
|
// The BFS itself runs through graph.ReachableForwardByKinds when the
|
|
// backend implements it (one query per layer over the frontier
|
|
// IN-list instead of N+1 GetOutEdges round-trips). Falls back to
|
|
// the per-id GetOutEdges loop on backends that don't.
|
|
func reachableFromTests(g graph.Store) map[string]bool {
|
|
// Seed: every function/method defined in a test file. NodesByKind
|
|
// pushes the kind filter into the backend; isTestFile stays Go.
|
|
seeds := make([]string, 0)
|
|
for _, kind := range []graph.NodeKind{graph.KindFunction, graph.KindMethod} {
|
|
for n := range g.NodesByKind(kind) {
|
|
if n == nil || !isTestFile(n.FilePath) {
|
|
continue
|
|
}
|
|
seeds = append(seeds, n.ID)
|
|
}
|
|
}
|
|
if len(seeds) == 0 {
|
|
return map[string]bool{}
|
|
}
|
|
|
|
kinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
|
|
if rf, ok := g.(graph.ReachableForwardByKinds); ok {
|
|
if got := rf.ReachableForwardByKinds(seeds, kinds); got != nil {
|
|
return got
|
|
}
|
|
return map[string]bool{}
|
|
}
|
|
|
|
// Fallback: layer-by-layer BFS using per-id GetOutEdges.
|
|
covered := make(map[string]bool, len(seeds))
|
|
frontier := make([]string, 0, len(seeds))
|
|
for _, id := range seeds {
|
|
if !covered[id] {
|
|
covered[id] = true
|
|
frontier = append(frontier, id)
|
|
}
|
|
}
|
|
for len(frontier) > 0 {
|
|
next := frontier[:0:0]
|
|
for _, id := range frontier {
|
|
for _, e := range g.GetOutEdges(id) {
|
|
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeReferences {
|
|
continue
|
|
}
|
|
if !covered[e.To] {
|
|
covered[e.To] = true
|
|
next = append(next, e.To)
|
|
}
|
|
}
|
|
}
|
|
frontier = next
|
|
}
|
|
return covered
|
|
}
|
|
|
|
// collectFanInByKind returns the per-target incoming-edge count for
|
|
// every edge whose kind is in the allowlist. Prefers the
|
|
// graph.InEdgeCounter capability — backends that ship it run one
|
|
// count(*) per request instead of an AllEdges() materialisation
|
|
// + Go-side bucketing.
|
|
func collectFanInByKind(g graph.Store, kinds []graph.EdgeKind) map[string]int {
|
|
if len(kinds) == 0 {
|
|
return map[string]int{}
|
|
}
|
|
if ic, ok := g.(graph.InEdgeCounter); ok {
|
|
if got := ic.InEdgeCountsByKind(kinds); got != nil {
|
|
return got
|
|
}
|
|
return map[string]int{}
|
|
}
|
|
allowed := make(map[graph.EdgeKind]struct{}, len(kinds))
|
|
for _, k := range kinds {
|
|
allowed[k] = struct{}{}
|
|
}
|
|
out := make(map[string]int)
|
|
for _, e := range g.AllEdges() {
|
|
if _, ok := allowed[e.Kind]; !ok {
|
|
continue
|
|
}
|
|
out[e.To]++
|
|
}
|
|
return out
|
|
}
|