Files
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

177 lines
5.2 KiB
Go

package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// handleAnalyzeRole classifies every function/method node in scope
// with exactly one role tag — the deterministic answer to "what
// shape is this symbol playing in the codebase?"
//
// Six roles:
//
// dead zero in-edges AND zero out-edges. Almost always unused.
// entry zero in-edges AND ≥1 out-edges. Top-of-stack functions
// (main, http handlers, cron entry points, test funcs).
// leaf ≥1 in-edges AND zero out-edges. Pure terminals —
// helpers, formatters, getters, accessors.
// adapter in-edges from one community AND out-edges into a
// different community. Boundary-crossers between modules.
// utility high fan-in / low fan-out / small file. Widely-used
// helpers (≥3 callers, ≤2 callees, ≤30 lines).
// core everything else — the load-bearing middle of the graph.
//
// The classifier is heuristic; rule precedence is the order above
// so a tie surfaces the more informative label (entry beats utility,
// utility beats core).
func (s *Server) handleAnalyzeRole(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
roleFilter := strings.TrimSpace(req.GetString("role", ""))
limit := max(req.GetInt("limit", 200), 1)
scoped := s.scopedNodes(ctx)
var nodeToComm map[string]string
if cr := s.getCommunities(); cr != nil {
nodeToComm = cr.NodeToComm
}
type roleRow struct {
ID string `json:"symbol_id"`
Name string `json:"name"`
Kind string `json:"kind"`
File string `json:"file"`
StartLine int `json:"start_line"`
Role string `json:"role"`
FanIn int `json:"fan_in"`
FanOut int `json:"fan_out"`
}
rows := make([]roleRow, 0, len(scoped))
tally := map[string]int{}
for _, n := range scoped {
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
continue
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
fanIn := countCallEdges(s.graph.GetInEdges(n.ID))
fanOut := countCallEdges(s.graph.GetOutEdges(n.ID))
role := classifyRole(n, fanIn, fanOut, s.graph, nodeToComm)
tally[role]++
if roleFilter != "" && role != roleFilter {
continue
}
rows = append(rows, roleRow{
ID: n.ID, Name: n.Name, Kind: string(n.Kind), File: n.FilePath,
StartLine: n.StartLine, Role: role,
FanIn: fanIn, FanOut: fanOut,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Role != rows[j].Role {
return rows[i].Role < rows[j].Role
}
if rows[i].FanIn != rows[j].FanIn {
return rows[i].FanIn > rows[j].FanIn
}
return rows[i].ID < rows[j].ID
})
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"symbols": rows,
"total": len(rows),
"truncated": truncated,
"tally_by_role": tally,
"path_prefix": pathPrefix,
"role_filter": roleFilter,
})
}
// classifyRole walks the six-rule precedence ladder and returns
// the first matching label. Rules are deliberately conservative;
// false-negatives (defaulting to "core") are preferable to noisy
// false-positives on a label that pretends to be authoritative.
func classifyRole(n *graph.Node, fanIn, fanOut int, g graph.Store, nodeToComm map[string]string) string {
switch {
case fanIn == 0 && fanOut == 0:
return "dead"
case fanIn == 0 && fanOut > 0:
return "entry"
case fanIn > 0 && fanOut == 0:
return "leaf"
}
// Adapter: in-edges originate in a different community than
// the out-edge targets. Skip when communities aren't available.
if nodeToComm != nil {
myComm, ok := nodeToComm[n.ID]
if ok && myComm != "" {
incomingFromOther := false
outgoingToOther := false
for _, e := range g.GetInEdges(n.ID) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeCrossRepoCalls {
continue
}
if other, ok2 := nodeToComm[e.From]; ok2 && other != myComm {
incomingFromOther = true
break
}
}
for _, e := range g.GetOutEdges(n.ID) {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeCrossRepoCalls {
continue
}
if other, ok2 := nodeToComm[e.To]; ok2 && other != myComm {
outgoingToOther = true
break
}
}
if incomingFromOther && outgoingToOther {
return "adapter"
}
}
}
// Utility: widely-used helper. The size threshold is heuristic;
// 30 lines is the common "is this a one-screen function" rule.
lineCount := 0
if n.EndLine > 0 {
lineCount = n.EndLine - n.StartLine + 1
}
if fanIn >= 3 && fanOut <= 2 && lineCount > 0 && lineCount <= 30 {
return "utility"
}
return "core"
}
// countCallEdges returns the number of distinct call-flavoured
// edges in the slice. Filters out structural edges (defines,
// member_of) so the role classifier reads the call graph cleanly.
func countCallEdges(edges []*graph.Edge) int {
n := 0
seen := map[string]bool{}
for _, e := range edges {
if e.Kind != graph.EdgeCalls && e.Kind != graph.EdgeCrossRepoCalls {
continue
}
var key string
if seen[e.From+">"+e.To] {
continue
}
key = e.From + ">" + e.To
seen[key] = true
n++
}
return n
}