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

364 lines
13 KiB
Go

package mcp
import (
"context"
"go.uber.org/zap"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/cochange"
"github.com/zzet/gortex/internal/graph"
)
// registerCoChangeTool wires find_co_changing_symbols — the MCP
// surface over the git-history co-change graph.
func (s *Server) registerCoChangeTool() {
s.addTool(
mcp.NewTool("find_co_changing_symbols",
mcp.WithDescription("Files (and their symbols) that change together with a target across git history — \"logical coupling\" the import graph cannot see: a handler and its test, a struct and the serializer that mirrors it, a schema and its migration are coupled even when neither imports the other. Mines `git log` for files co-occurring in a commit, weighted by a cosine association score over per-file commit counts. Pass either symbol_id or file_path. Returns co-changing files ranked by score with {file, score, count, symbols}. The same signal also materialises EdgeCoChange graph edges and feeds search ranking as the co_change rerank signal."),
mcp.WithString("symbol_id", mcp.Description("Symbol node ID — resolved to its defining file. One of symbol_id / file_path is required.")),
mcp.WithString("file_path", mcp.Description("File path to analyse directly. One of symbol_id / file_path is required.")),
mcp.WithNumber("limit", mcp.Description("Cap the number of co-changing files returned (default: 20).")),
mcp.WithNumber("min_score", mcp.Description("Drop co-change relationships scoring below this threshold, 0..1 (default: 0).")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleFindCoChangingSymbols,
)
}
type coChangeRow struct {
File string `json:"file"`
Score float64 `json:"score"`
Count int `json:"count"`
Symbols []string `json:"symbols,omitempty"`
}
func (s *Server) handleFindCoChangingSymbols(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
symbolID := strings.TrimSpace(req.GetString("symbol_id", ""))
filePath := strings.TrimSpace(req.GetString("file_path", ""))
limit := max(req.GetInt("limit", 20), 1)
minScore := 0.0
if v, ok := req.GetArguments()["min_score"].(float64); ok {
minScore = v
}
var targetFile string
switch {
case symbolID != "":
n := s.graph.GetNode(symbolID)
if n == nil {
return mcp.NewToolResultError("symbol not found: " + symbolID), nil
}
targetFile = n.FilePath
case filePath != "":
targetFile = filePath
default:
return mcp.NewToolResultError("one of symbol_id or file_path is required"), nil
}
if targetFile == "" {
return mcp.NewToolResultError("target symbol has no file path"), nil
}
s.ensureCoChange()
scores := s.coChangeScores(targetFile)
counts := s.coChangeCounts(targetFile)
// Two-phase build: first collect (file, score, count) tuples that
// survive the minScore gate, then sort + truncate to the requested
// limit, then batch-resolve the per-file symbol names. The Symbols
// lookup is the only graph-touching work in this handler — pulling
// it through one capability call instead of N GetFileNodes round-
// trips is the entire disk-backend win.
type pending struct {
file string
score float64
count int
}
pendings := make([]pending, 0, len(scores))
for file, score := range scores {
if score < minScore {
continue
}
pendings = append(pendings, pending{file: file, score: score, count: counts[file]})
}
sort.Slice(pendings, func(i, j int) bool {
if pendings[i].score != pendings[j].score {
return pendings[i].score > pendings[j].score
}
return pendings[i].file < pendings[j].file
})
truncated := false
if len(pendings) > limit {
pendings = pendings[:limit]
truncated = true
}
keepFiles := make([]string, 0, len(pendings))
for _, p := range pendings {
keepFiles = append(keepFiles, p.file)
}
symbolsByFile := s.symbolNamesByFiles(keepFiles)
rows := make([]coChangeRow, 0, len(pendings))
for _, p := range pendings {
rows = append(rows, coChangeRow{
File: p.file,
Score: roundScore(p.score),
Count: p.count,
Symbols: symbolsByFile[p.file],
})
}
result := map[string]any{
"target_file": targetFile,
"co_changing": rows,
"total": len(rows),
"truncated": truncated,
}
if symbolID != "" {
result["symbol_id"] = symbolID
}
// When the cache is empty AND the background mine has not finished
// yet, surface an in-progress marker so the caller can distinguish
// "this file has no co-change data" from "the daemon hasn't built
// the data yet". The mine is fired at daemon-ready by RunAnalysis;
// a fresh daemon on a disk backend takes tens of seconds before the cache is
// populated.
if len(rows) == 0 && !s.coChangeReady() {
result["mining_in_progress"] = true
result["note"] = "co-change graph is still being mined; retry shortly"
}
return s.respondJSONOrTOON(ctx, req, result)
}
// ensureCoChange triggers the co-change mine if it has not run yet
// and returns IMMEDIATELY — the mine itself runs asynchronously.
//
// Why async? On a disk backend with no pre-existing
// EdgeCoChange edges, mineCoChange spends 60+ seconds in
// cochange.AddEdges: an AllNodes full-table scan plus thousands of
// per-pair AddEdge round-trips. Wrapping that in sync.Once.Do
// turned every queued tool call into a blocked-for-60s caller. The
// async shape keeps the request path off the slow path.
//
// PrewarmCoChange (called from RunAnalysis at daemon-ready) fires
// the mine ahead of any user-visible call so the cache is already
// populated by the time the first find_co_changing_symbols arrives.
//
// Returning immediately means the first user call may see an empty
// cache when the prewarm goroutine has not yet completed. That is
// the deliberate trade-off — the alternative is a 60s blocked tool
// call. The handler surfaces an `in_progress` flag when the cache is
// empty so callers know to retry rather than treating the file as
// genuinely uncoupled.
func (s *Server) ensureCoChange() {
s.cochangeOnce.Do(func() {
go s.mineCoChange()
})
}
// PrewarmCoChange triggers the co-change mine in the background so a
// later find_co_changing_symbols / search rerank call sees a
// populated cache without blocking. Safe to call multiple times — the
// underlying sync.Once still gates the work to one execution.
//
// Returns immediately whether mining is in progress, completed, or
// freshly started.
func (s *Server) PrewarmCoChange() {
go s.cochangeOnce.Do(s.mineCoChange)
}
// coChangeReady reports whether the mine has completed and the cache
// is populated. Used by the handler to set an `in_progress` flag
// when the cache is empty but mining is still running.
func (s *Server) coChangeReady() bool {
s.cochangeMu.RLock()
defer s.cochangeMu.RUnlock()
return s.cochangeByFile != nil
}
// mineCoChange populates the co-change caches. It prefers EdgeCoChange
// edges already present in the graph (an enriched snapshot); only when
// none exist does it mine `git log`.
//
// The mine populates the in-memory caches AND persists the mined
// pairs as EdgeCoChange edges (cochange.AddEdges) so a subsequent daemon
// start takes the coChangeFromEdges fast path instead of re-mining
// `git log` (the 5-15s restart cost).
//
// The earlier version deliberately skipped the persist to avoid the
// analyze[clusters] partition cache (keyed on NodeCount/EdgeCount/
// EdgeIdentityRevisions) being invalidated by edge-count drift. That
// concern was about CONTINUOUS drift; here the persist is bounded —
// mineCoChange runs once per process (sync.Once) and the fast path skips
// the mine once edges exist — so the edge count (and the clusters token)
// moves at most ONCE per graph, triggering a single recompute rather
// than per-restart thrash. Co-change edges are partition-irrelevant
// (edgeWeight 0; both endpoints are KindFile nodes, filtered out of
// community detection), so that one recompute yields the same partition.
//
// Reads are unaffected: find_co_changing_symbols and the search rerank's
// CoChangeOf hook both read the in-memory cache. The CLI cochange.EnrichGraph
// path already persisted via AddEdges; this aligns the lazy daemon path
// with it. Refreshing stale co-change after a HEAD move is still a manual
// `gortex enrich cochange` (or a cold reindex) — the lazy path does not
// auto-re-mine once edges exist.
func (s *Server) mineCoChange() {
scores := map[string]map[string]float64{}
counts := map[string]map[string]int{}
if s.coChangeFromEdges(scores, counts) {
s.storeCoChange(scores, counts)
// The co-change graph COULD be refreshed by re-mining git log,
// but was NOT: persisted EdgeCoChange edges already exist, so the
// lazy path serves them as-is. If history advanced since the last
// mine these counts are stale until an explicit refresh. Surface
// that rather than silently serving possibly-stale data.
if s.logger != nil {
s.logger.Info("co-change served from persisted edges; not re-mined (could be updated, but was not) — run `gortex enrich cochange` to refresh after history changes",
zap.Int("file_relations", len(scores)))
}
return
}
for prefix, root := range s.collectRepoRoots("") {
res := cochange.Mine(context.Background(), root, cochange.Options{})
if len(res.Pairs) == 0 {
continue
}
for _, p := range res.Pairs {
fa, fb := p.FileA, p.FileB
if prefix != "" {
fa = prefix + "/" + fa
fb = prefix + "/" + fb
}
addCoChangeLink(scores, counts, fa, fb, p.Score, p.Count)
addCoChangeLink(scores, counts, fb, fa, p.Score, p.Count)
}
// Persist the mined pairs as EdgeCoChange edges so a later daemon
// start takes the coChangeFromEdges fast path instead of re-mining
// git log (the 5-15s restart cost). Bounded: mineCoChange runs once
// per process (sync.Once) and the fast path above skips the mine
// once edges exist, so this persist (and its one clusters-cache
// token bump) happens at most once per graph, not per restart.
cochange.AddEdges(s.graph, res.Pairs, prefix)
}
s.storeCoChange(scores, counts)
}
// coChangeFromEdges rebuilds the path-keyed caches from EdgeCoChange
// edges already in the graph. Returns true when at least one edge was
// found — the signal that an enriched snapshot is loaded and no fresh
// git mine is needed.
//
// EdgesByKind streams only the CoChange edges; the endpoint nodes are
// fetched in one batched GetNodesByIDs call instead of two GetNode
// round-trips per edge. On disk backends that drops the
// whole-graph AllEdges materialisation plus the per-edge
// GetNode trips that loaded the file paths.
func (s *Server) coChangeFromEdges(scores map[string]map[string]float64, counts map[string]map[string]int) bool {
// First pass: collect CoChange edges + the set of node IDs they
// reference. Both can stream from EdgesByKind in one
// round-trip on disk backends.
type ccEdge struct {
from, to string
score float64
count int
}
var edges []ccEdge
idSet := make(map[string]struct{})
for e := range s.graph.EdgesByKind(graph.EdgeCoChange) {
if e == nil {
continue
}
score := e.Confidence
if e.Meta != nil {
if v, ok := e.Meta["score"].(float64); ok {
score = v
}
}
count := 0
if e.Meta != nil {
switch v := e.Meta["count"].(type) {
case int:
count = v
case int64:
count = int(v)
case float64:
count = int(v)
}
}
edges = append(edges, ccEdge{from: e.From, to: e.To, score: score, count: count})
idSet[e.From] = struct{}{}
idSet[e.To] = struct{}{}
}
if len(edges) == 0 {
return false
}
// Batched endpoint resolution — one batched id-IN query vs.
// 2 * len(edges) per-row GetNode trips. On a workspace with
// thousands of co-change edges this is the bulk of the latency.
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
nodes := s.graph.GetNodesByIDs(ids)
for _, e := range edges {
from, ok := nodes[e.from]
if !ok || from == nil {
continue
}
to, ok := nodes[e.to]
if !ok || to == nil {
continue
}
addCoChangeLink(scores, counts, from.FilePath, to.FilePath, e.score, e.count)
}
return true
}
// addCoChangeLink records one directed co-change relationship.
func addCoChangeLink(scores map[string]map[string]float64, counts map[string]map[string]int, from, to string, score float64, count int) {
if scores[from] == nil {
scores[from] = map[string]float64{}
}
if counts[from] == nil {
counts[from] = map[string]int{}
}
scores[from][to] = score
counts[from][to] = count
}
// storeCoChange publishes the freshly built caches under the lock.
func (s *Server) storeCoChange(scores map[string]map[string]float64, counts map[string]map[string]int) {
s.cochangeMu.Lock()
s.cochangeByFile = scores
s.cochangeCount = counts
s.cochangeMu.Unlock()
}
// coChangeScores returns the co-changing file -> score map for a file,
// or nil when the file has no co-change data.
func (s *Server) coChangeScores(filePath string) map[string]float64 {
s.cochangeMu.RLock()
defer s.cochangeMu.RUnlock()
return s.cochangeByFile[filePath]
}
// coChangeCounts returns the co-changing file -> commit-overlap count
// map for a file.
func (s *Server) coChangeCounts(filePath string) map[string]int {
s.cochangeMu.RLock()
defer s.cochangeMu.RUnlock()
return s.cochangeCount[filePath]
}
// hasCoChangeData reports whether the co-change caches hold anything —
// used by buildRerankContext to decide whether to wire the signal.
func (s *Server) hasCoChangeData() bool {
s.cochangeMu.RLock()
defer s.cochangeMu.RUnlock()
return len(s.cochangeByFile) > 0
}