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

363 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// registerKnowledgeGapsTool wires get_knowledge_gaps — a cold-start
// audit composing four signals the graph already carries:
// disconnected nodes, thin communities, single-file communities, and
// untested hotspots. Returns the bundled view so an agent can decide
// where to invest test-writing / refactor effort before opening
// individual analyze calls.
func (s *Server) registerKnowledgeGapsTool() {
s.addTool(
mcp.NewTool("get_knowledge_gaps",
mcp.WithDescription("Surface places the codebase under-documents itself. Composes disconnected nodes (zero in/out edges), thin communities (<thin_community_size members), single-file communities, and untested hotspots (high fan-in, low coverage_pct). Returns a bundled rollup so callers can rank where to invest test-writing / refactor effort. Cold-start audit aid: complements get_repo_outline by pointing at the weak spots, not the load-bearing ones."),
mcp.WithNumber("thin_community_size", mcp.Description("Communities with fewer members are flagged 'thin' (default: 3).")),
mcp.WithNumber("min_coverage_pct", mcp.Description("Hotspots with coverage_pct below this are 'untested' (default: 50). Hotspots without any coverage data are always included.")),
mcp.WithNumber("hotspot_limit", mcp.Description("Top-N highest-fan-in nodes to evaluate against the coverage threshold (default: 20).")),
mcp.WithNumber("limit_per_category", mcp.Description("Cap each rollup category (default: 20).")),
mcp.WithString("path_prefix", mcp.Description("Scope analysis to nodes under this file-path prefix — e.g. 'internal/auth/'.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleGetKnowledgeGaps,
)
}
// gapDisconnected — function/method with zero incoming and outgoing
// edges. Almost always either dead code or an isolated utility
// nobody wired up.
type gapDisconnected struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
Line int `json:"line"`
}
// gapCommunity — for thin and single-file communities the caller
// needs the same fields, so they share a row type.
type gapCommunity struct {
ID string `json:"id"`
Label string `json:"label"`
Size int `json:"size"`
Files []string `json:"files"`
}
// gapUntestedHotspot — high-fan-in node whose coverage_pct is below
// the threshold, or absent entirely. fan_in is the in-edge count
// computed locally — independent of the hotspots analyzer's mean+2σ
// gate so we surface load-bearing nodes even in small repos where
// the analyzer is conservative.
type gapUntestedHotspot struct {
ID string `json:"id"`
Name string `json:"name"`
File string `json:"file"`
Line int `json:"line"`
FanIn int `json:"fan_in"`
Coverage float64 `json:"coverage_pct"`
HasCoverage bool `json:"has_coverage"`
}
func (s *Server) handleGetKnowledgeGaps(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
thinSize := req.GetInt("thin_community_size", 3)
if thinSize < 1 {
thinSize = 3
}
minCov := req.GetFloat("min_coverage_pct", 50.0)
if minCov < 0 {
minCov = 0
}
hotspotLimit := max(req.GetInt("hotspot_limit", 20), 1)
perCategoryLimit := max(req.GetInt("limit_per_category", 20), 1)
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
// degreeByID maps node id -> (in, out) edge counts for every
// function/method in scope, computed once via the backend's
// NodeDegreeByKinds path when available. The legacy
// NodeDegreeCounts route shipped a 30k-element IN-list per call
// on a disk backend; NodeDegreeByKinds runs the same aggregate over the
// kind-filtered node set so the planner never builds the list.
degreeByID, scoped := s.scopedFunctionDegrees(ctx, pathPrefix)
disconnected := s.collectDisconnected(scoped, pathPrefix, perCategoryLimit, degreeByID)
thin, singleFile := s.collectCommunityGaps(thinSize, pathPrefix, perCategoryLimit)
untested := s.collectUntestedHotspots(scoped, pathPrefix, hotspotLimit, minCov, perCategoryLimit, degreeByID)
return s.respondJSONOrTOON(ctx, req, map[string]any{
"disconnected_nodes": disconnected,
"thin_communities": thin,
"single_file_communities": singleFile,
"untested_hotspots": untested,
"summary": map[string]any{
"disconnected_count": len(disconnected),
"thin_count": len(thin),
"single_file_count": len(singleFile),
"untested_count": len(untested),
},
"thresholds": map[string]any{
"thin_community_size": thinSize,
"min_coverage_pct": minCov,
"hotspot_limit": hotspotLimit,
"limit_per_category": perCategoryLimit,
},
})
}
// scopedFunctionDegrees returns the per-node in/out degree map and
// the scoped function/method node list, in two pushdown calls.
// NodeDegreeByKinds runs server-side over the kind-filtered node
// table — the previous path fed NodeDegreeCounts a 30k-element
// IN-list, which the planner had to materialise before joining. The
// scoped node list is built from NodesByKinds (or AllNodes when the
// backend has no NodesByKindsScanner) and post-filtered for the
// session workspace, matching scopedNodesByKinds' contract.
func (s *Server) scopedFunctionDegrees(ctx context.Context, pathPrefix string) (map[string]graph.NodeDegreeRow, []*graph.Node) {
kinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod}
scoped := s.scopedNodesByKinds(ctx, kinds)
var degByID map[string]graph.NodeDegreeRow
if dk, ok := s.graph.(graph.NodeDegreeByKinds); ok {
rows := dk.NodeDegreeByKinds(kinds, pathPrefix)
degByID = make(map[string]graph.NodeDegreeRow, len(rows))
for _, r := range rows {
degByID[r.NodeID] = r
}
}
return degByID, scoped
}
// collectDisconnected returns function/method nodes with zero
// incoming and zero outgoing edges in the scoped subgraph. The
// kind filter mirrors handleAnalyzeCoverageGaps' default — variables
// and constants always look disconnected, so including them would
// flood the result.
//
// Reads from the prebuilt degree map when present (the storage
// backend computed it once in scopedFunctionDegrees), falls back to
// per-node GetInEdges / GetOutEdges otherwise. The legacy
// NodeDegreeAggregator path is kept as a tertiary fallback for
// backends that publish NodeDegreeCounts but not NodeDegreeByKinds.
func (s *Server) collectDisconnected(scoped []*graph.Node, pathPrefix string, limit int, degreeByID map[string]graph.NodeDegreeRow) []gapDisconnected {
candidates := make([]*graph.Node, 0, len(scoped))
for _, n := range scoped {
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
candidates = append(candidates, n)
}
out := make([]gapDisconnected, 0)
switch {
case degreeByID != nil:
for _, n := range candidates {
r, ok := degreeByID[n.ID]
if !ok {
// Absent from the aggregate => zero edges, by
// definition of the kind-filtered aggregate.
out = append(out, gapDisconnected{
ID: n.ID, Name: n.Name, Kind: string(n.Kind),
File: n.FilePath, Line: n.StartLine,
})
continue
}
if r.InCount > 0 || r.OutCount > 0 {
continue
}
out = append(out, gapDisconnected{
ID: n.ID, Name: n.Name, Kind: string(n.Kind),
File: n.FilePath, Line: n.StartLine,
})
}
default:
if agg, ok := s.graph.(graph.NodeDegreeAggregator); ok && len(candidates) > 0 {
ids := make([]string, 0, len(candidates))
byID := make(map[string]*graph.Node, len(candidates))
for _, n := range candidates {
ids = append(ids, n.ID)
byID[n.ID] = n
}
for _, r := range agg.NodeDegreeCounts(ids, nil) {
if r.InCount > 0 || r.OutCount > 0 {
continue
}
n := byID[r.NodeID]
if n == nil {
continue
}
out = append(out, gapDisconnected{
ID: n.ID, Name: n.Name, Kind: string(n.Kind),
File: n.FilePath, Line: n.StartLine,
})
}
} else {
for _, n := range candidates {
if len(s.graph.GetInEdges(n.ID)) > 0 || len(s.graph.GetOutEdges(n.ID)) > 0 {
continue
}
out = append(out, gapDisconnected{
ID: n.ID, Name: n.Name, Kind: string(n.Kind),
File: n.FilePath, Line: n.StartLine,
})
}
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
return out[i].Line < out[j].Line
})
if len(out) > limit {
out = out[:limit]
}
return out
}
// collectCommunityGaps walks the cached community result and
// produces two parallel rollups in one pass: thin communities (under
// the size threshold) and single-file communities (every member from
// the same file — usually a sign the cluster never crossed a module
// boundary).
func (s *Server) collectCommunityGaps(thinSize int, pathPrefix string, limit int) (thin, singleFile []gapCommunity) {
thin = make([]gapCommunity, 0)
singleFile = make([]gapCommunity, 0)
cr := s.getCommunities()
if cr == nil {
return thin, singleFile
}
for _, c := range cr.Communities {
// Path-prefix scope: keep the community if at least one
// file lies under the prefix. Empty prefix = no filter.
if pathPrefix != "" {
match := false
for _, f := range c.Files {
if strings.HasPrefix(f, pathPrefix) {
match = true
break
}
}
if !match {
continue
}
}
row := gapCommunity{ID: c.ID, Label: c.Label, Size: c.Size, Files: c.Files}
if c.Size < thinSize {
thin = append(thin, row)
}
if len(c.Files) == 1 {
singleFile = append(singleFile, row)
}
}
sort.Slice(thin, func(i, j int) bool { return thin[i].Size < thin[j].Size })
sort.Slice(singleFile, func(i, j int) bool { return singleFile[i].Size > singleFile[j].Size })
if len(thin) > limit {
thin = thin[:limit]
}
if len(singleFile) > limit {
singleFile = singleFile[:limit]
}
return thin, singleFile
}
// collectUntestedHotspots ranks scoped function/method nodes by
// in-edge count, takes the top `hotspotLimit`, and keeps those with
// coverage_pct < minCov or no coverage data at all. Independent of
// analyze hotspots (which gates on mean+2σ) so it still surfaces
// load-bearing nodes in small repos.
//
// Reads from the prebuilt NodeDegreeByKinds aggregate when present;
// falls back to NodeDegreeAggregator (the older IN-list shape) for
// backends that only publish that one, and finally to per-node
// GetInEdges for everyone else.
func (s *Server) collectUntestedHotspots(scoped []*graph.Node, pathPrefix string, hotspotLimit int, minCov float64, limit int, degreeByID map[string]graph.NodeDegreeRow) []gapUntestedHotspot {
type ranked struct {
node *graph.Node
fanIn int
}
pool := make([]*graph.Node, 0, len(scoped))
for _, n := range scoped {
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
pool = append(pool, n)
}
candidates := make([]ranked, 0, len(pool))
switch {
case degreeByID != nil:
for _, n := range pool {
r := degreeByID[n.ID]
candidates = append(candidates, ranked{node: n, fanIn: r.InCount})
}
default:
if agg, ok := s.graph.(graph.NodeDegreeAggregator); ok && len(pool) > 0 {
ids := make([]string, 0, len(pool))
byID := make(map[string]*graph.Node, len(pool))
for _, n := range pool {
ids = append(ids, n.ID)
byID[n.ID] = n
}
for _, r := range agg.NodeDegreeCounts(ids, nil) {
n := byID[r.NodeID]
if n == nil {
continue
}
candidates = append(candidates, ranked{node: n, fanIn: r.InCount})
}
} else {
for _, n := range pool {
candidates = append(candidates, ranked{node: n, fanIn: len(s.graph.GetInEdges(n.ID))})
}
}
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].fanIn > candidates[j].fanIn
})
if len(candidates) > hotspotLimit {
candidates = candidates[:hotspotLimit]
}
out := make([]gapUntestedHotspot, 0)
covRows := s.coverageByID()
for _, c := range candidates {
// A "hotspot" with zero callers isn't a hotspot — drop it.
// Disconnected functions are already covered by the
// disconnected_nodes rollup.
if c.fanIn == 0 {
continue
}
pct, has := coveragePctFrom(covRows, c.node)
if has && pct >= minCov {
continue
}
out = append(out, gapUntestedHotspot{
ID: c.node.ID,
Name: c.node.Name,
File: c.node.FilePath,
Line: c.node.StartLine,
FanIn: c.fanIn,
Coverage: pct,
HasCoverage: has,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].FanIn != out[j].FanIn {
return out[i].FanIn > out[j].FanIn
}
return out[i].ID < out[j].ID
})
if len(out) > limit {
out = out[:limit]
}
return out
}