项目文件夹

文件
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 行
12 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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
}