Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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
}