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
177 lines
5.2 KiB
Go
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
|
|
}
|