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

277 lines
8.6 KiB
Go

package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// registerChurnRateTool wires get_churn_rate — a pure graph scan over
// per-symbol churn metadata pre-computed by `gortex enrich churn`.
//
// At read time the handler does NOT shell out to git. Every value it
// returns lives in n.Meta["churn"] on the node, populated either by
// the CLI/git-hook (which writes through the on-disk backend) or by
// an in-process call to the enrich_churn MCP tool. When no node in
// scope has the data, the response is a structured error pointing
// the agent at the enrich command.
func (s *Server) registerChurnRateTool() {
s.addTool(
mcp.NewTool("get_churn_rate",
mcp.WithDescription("Per-symbol git-commit density, read from pre-computed graph data. For each function/method in scope returns {symbol_id, name, file, churn_rate (commits per active day), commit_count, age_days, last_author, last_commit_at}. Sort and filter by churn_rate or commit_count to find unstable abstractions, hidden coupling, and bus-factor risks. Data is populated by `gortex enrich churn` (or the enrich_churn MCP tool); when nothing in scope has churn meta the tool returns a structured error with the suggested next command. No git subprocess at request time — sub-second on indexed repos."),
mcp.WithString("path_prefix", mcp.Description("Scope analysis to nodes under this file-path prefix.")),
mcp.WithNumber("min_commits", mcp.Description("Only return symbols with at least this many commits (default: 1).")),
mcp.WithString("kinds", mcp.Description("Comma-separated kinds (default: function,method). Pass 'all' for every symbol.")),
mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 100).")),
mcp.WithString("sort_by", mcp.Description("Sort key: churn_rate (default), commit_count, age_days.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleGetChurnRate,
)
}
type churnRow struct {
ID string `json:"symbol_id"`
Name string `json:"name"`
File string `json:"file"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
CommitCount int `json:"commit_count"`
AgeDays int `json:"age_days"`
ChurnRate float64 `json:"churn_rate"`
LastAuthor string `json:"last_author,omitempty"`
LastCommitAt string `json:"last_commit_at,omitempty"`
}
func (s *Server) handleGetChurnRate(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
minCommits := max(req.GetInt("min_commits", 1), 0)
limit := max(req.GetInt("limit", 100), 1)
sortBy := strings.TrimSpace(req.GetString("sort_by", "churn_rate"))
allowed := map[graph.NodeKind]struct{}{
graph.KindFunction: {},
graph.KindMethod: {},
}
if k := strings.TrimSpace(req.GetString("kinds", "")); k != "" && k != "all" {
allowed = parseAnalyzeKindsFilter(k)
} else if k == "all" {
allowed = nil
}
rows := make([]churnRow, 0, 64)
seenFiles := map[string]struct{}{}
sawMeta := false
usedSidecar := false
if reader, ok := s.graph.(graph.ChurnEnrichmentReader); ok {
// Sidecar fast-path (change A): read the typed churn rows via an
// index over the (small) enriched set, then resolve their nodes
// in one batch — instead of scanning AllNodes and gob-decoding
// every meta blob to peek at Meta["churn"].
if enrich := reader.ChurnRows(""); len(enrich) > 0 {
usedSidecar = true
sawMeta = true
ids := make([]string, 0, len(enrich))
for _, e := range enrich {
ids = append(ids, e.NodeID)
}
nodes := s.graph.GetNodesByIDs(ids)
sessWS, _, bound := s.sessionScope(ctx)
var opts query.QueryOptions
if bound {
opts = query.QueryOptions{WorkspaceID: sessWS}
}
for _, e := range enrich {
n := nodes[e.NodeID]
if n == nil {
continue
}
if bound && !opts.ScopeAllows(n) {
continue
}
if allowed != nil {
if _, ok := allowed[n.Kind]; !ok {
continue
}
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
if e.CommitCount < minCommits {
continue
}
rows = append(rows, churnRowFromEnrichment(n, e))
seenFiles[n.FilePath] = struct{}{}
}
}
}
if !usedSidecar {
// Fallback: no sidecar rows yet (un-migrated DB, recompute-on-
// next-enrich) or a backend without the capability — read
// Meta["churn"] off a full AllNodes scan.
for _, n := range s.scopedNodes(ctx) {
if allowed != nil {
if _, ok := allowed[n.Kind]; !ok {
continue
}
}
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
continue
}
row, ok := churnRowFromMeta(n)
if !ok {
continue
}
sawMeta = true
if row.CommitCount < minCommits {
continue
}
rows = append(rows, row)
seenFiles[n.FilePath] = struct{}{}
}
}
if !sawMeta {
// No node in scope carries meta.churn — the agent needs to
// run the enricher before this tool can answer. We surface
// the gap loudly rather than returning an empty result that
// looks like "nothing churns" (which is misleading).
return s.respondJSONOrTOON(ctx, req, map[string]any{
"error": "no churn data in scope; run `gortex enrich churn` (or call the enrich_churn MCP tool) to populate meta.churn",
"suggestion": "gortex enrich churn",
"symbols": []churnRow{},
"total": 0,
"truncated": false,
})
}
sort.Slice(rows, func(i, j int) bool {
switch sortBy {
case "commit_count":
if rows[i].CommitCount != rows[j].CommitCount {
return rows[i].CommitCount > rows[j].CommitCount
}
case "age_days":
if rows[i].AgeDays != rows[j].AgeDays {
return rows[i].AgeDays > rows[j].AgeDays
}
default: // churn_rate
if rows[i].ChurnRate != rows[j].ChurnRate {
return rows[i].ChurnRate > rows[j].ChurnRate
}
}
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,
"scanned_files": len(seenFiles),
"sort_by": sortBy,
"min_commits": minCommits,
})
}
// churnRowFromEnrichment builds a response row from a node + its typed
// sidecar churn enrichment (change A read path).
func churnRowFromEnrichment(n *graph.Node, e graph.ChurnEnrichment) churnRow {
endLine := n.EndLine
if endLine == 0 {
endLine = n.StartLine
}
return churnRow{
ID: n.ID, Name: n.Name, File: n.FilePath,
StartLine: n.StartLine, EndLine: endLine,
CommitCount: e.CommitCount,
AgeDays: e.AgeDays,
ChurnRate: e.ChurnRate,
LastAuthor: e.LastAuthor,
LastCommitAt: e.LastCommitAt,
}
}
// churnRowFromMeta projects a node's meta.churn payload into the
// response row. Returns (zero, false) when the node has no churn
// metadata — the caller distinguishes "missing data" from
// "filtered out". The Meta layout matches what
// internal/churn.EnrichGraph writes:
//
// meta.churn = {
// commit_count: int,
// age_days: int,
// churn_rate: float64,
// last_author: string,
// last_commit_at: RFC3339 string,
// }
//
// Numeric fields tolerate both int and float64 because Meta round-
// trips through the on-disk backend or JSON (snapshots), which can widen
// ints to floats. Missing fields default to zero — they're stamped
// together so partial payloads are unexpected, but a defensive read
// is cheaper than asserting and crashing on an old snapshot.
func churnRowFromMeta(n *graph.Node) (churnRow, bool) {
if n == nil || n.Meta == nil {
return churnRow{}, false
}
raw, ok := n.Meta["churn"].(map[string]any)
if !ok || len(raw) == 0 {
return churnRow{}, false
}
endLine := n.EndLine
if endLine == 0 {
endLine = n.StartLine
}
row := churnRow{
ID: n.ID, Name: n.Name, File: n.FilePath,
StartLine: n.StartLine, EndLine: endLine,
CommitCount: intFromAny(raw["commit_count"]),
AgeDays: intFromAny(raw["age_days"]),
ChurnRate: floatFromAny(raw["churn_rate"]),
}
if v, ok := raw["last_author"].(string); ok {
row.LastAuthor = v
}
if v, ok := raw["last_commit_at"].(string); ok {
row.LastCommitAt = v
}
return row, true
}
func intFromAny(v any) int {
switch x := v.(type) {
case int:
return x
case int64:
return int(x)
case float64:
return int(x)
}
return 0
}
func floatFromAny(v any) float64 {
switch x := v.(type) {
case float64:
return x
case float32:
return float64(x)
case int:
return float64(x)
case int64:
return float64(x)
}
return 0
}