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

315 lines
11 KiB
Go

package mcp
import (
"context"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// registerSurprisingConnectionsTool wires get_surprising_connections —
// the ranked anomaly surface. Improves on the read-only
// `gortex://surprises` resource by scoring every edge against five
// signals and returning the top-N as a structured tool response.
func (s *Server) registerSurprisingConnectionsTool() {
s.addTool(
mcp.NewTool("get_surprising_connections",
mcp.WithDescription("Rank edges in the graph by how anomalous they are. Composite score from five signals: cross-community (+0.30), cross-language (+0.20), peripheral-to-hub (+0.20), cross-test boundary (+0.15), unusual edge kind (+0.15). Returns the top-N with per-signal breakdown so the caller can see why each edge surfaced. Use as an audit aid — anomalies often flag layering violations, accidental coupling, or test code reaching into production paths."),
mcp.WithNumber("limit", mcp.Description("Cap the result set (default: 25).")),
mcp.WithNumber("min_score", mcp.Description("Drop edges below this composite score (default: 0.3 — at least one signal fires).")),
mcp.WithNumber("hub_threshold", mcp.Description("In-edge count above which a node counts as a hub for the peripheral-to-hub signal (default: 5).")),
mcp.WithNumber("rare_kind_pct", mcp.Description("Edge kinds whose share of all edges is at or below this percentage are 'unusual' (default: 5 — i.e. 5%%).")),
mcp.WithString("path_prefix", mcp.Description("Scope to edges where at least one endpoint lies under this file-path prefix.")),
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
),
s.handleGetSurprisingConnections,
)
}
// surprisingEdgeRow is the wire shape: rich enough that an agent can
// decide whether the anomaly is real or expected without an extra
// get_symbol_source round-trip.
type surprisingEdgeRow struct {
From string `json:"from"`
FromName string `json:"from_name,omitempty"`
FromFile string `json:"from_file,omitempty"`
To string `json:"to"`
ToName string `json:"to_name,omitempty"`
ToFile string `json:"to_file,omitempty"`
Kind string `json:"kind"`
Score float64 `json:"score"`
Signals map[string]float64 `json:"signals"`
Reasons []string `json:"reasons"`
}
func (s *Server) handleGetSurprisingConnections(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
limit := max(req.GetInt("limit", 25), 1)
minScore := req.GetFloat("min_score", 0.3)
hubThreshold := max(req.GetInt("hub_threshold", 5), 1)
rareKindPct := req.GetFloat("rare_kind_pct", 5.0)
if rareKindPct < 0 {
rareKindPct = 0
}
pathPrefix := strings.TrimSpace(req.GetString("path_prefix", ""))
// Build a fast scoped-node index. We still need ALL kinds here —
// edges in the surprise tally can land on any node, not just
// function/method. Use scopedNodes' single bulk pull rather than
// the per-edge GetNode lookups the legacy path fell back to.
scopedSet := make(map[string]*graph.Node, 1024)
for _, n := range s.scopedNodes(ctx) {
scopedSet[n.ID] = n
}
rows := s.collectSurprisingEdges(ctx, scopedSet, pathPrefix, minScore, hubThreshold, rareKindPct)
truncated := false
if len(rows) > limit {
rows = rows[:limit]
truncated = true
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"connections": rows,
"total": len(rows),
"truncated": truncated,
"thresholds": map[string]any{
"limit": limit,
"min_score": minScore,
"hub_threshold": hubThreshold,
"rare_kind_pct": rareKindPct,
},
"signal_weights": surprisingSignalWeights(),
})
}
// collectSurprisingEdges runs the anomaly-mining pass over the scoped
// node set and returns the scored rows, sorted highest-score-first.
// It is the shared core behind both the get_surprising_connections
// tool and the surprising category of suggested_review_questions: the
// caller passes the scoped node index (a single bulk pull it already
// holds) plus the tuning thresholds, and gets back every edge whose
// composite score clears minScore. The caller owns the limit/truncate
// step — this returns the full sorted set so a downstream consumer can
// pick its own cap.
func (s *Server) collectSurprisingEdges(
_ context.Context,
scopedSet map[string]*graph.Node,
pathPrefix string,
minScore float64,
hubThreshold int,
rareKindPct float64,
) []surprisingEdgeRow {
// Communities resolve cross-community; missing community result
// just disables that signal rather than failing the call.
var nodeToComm map[string]string
if cr := s.getCommunities(); cr != nil {
nodeToComm = cr.NodeToComm
}
// Kind tally — short-circuit the AllEdges scan when the backend
// implements EdgeKindCounter (returns one row per distinct kind,
// not one per edge — a few-dozen-row response replaces a ~286k
// edge round-trip on a disk backend). The total edge count then comes
// from the per-kind sum so we don't need a second backend call.
kindCounts := make(map[graph.EdgeKind]int, 16)
totalEdges := 0
var allEdges []*graph.Edge
if counter, ok := s.graph.(graph.EdgeKindCounter); ok {
for k, c := range counter.EdgeKindCounts() {
kindCounts[k] = c
totalEdges += c
}
} else {
allEdges = s.graph.AllEdges()
for _, e := range allEdges {
kindCounts[e.Kind]++
}
totalEdges = len(allEdges)
}
// In-degree still walks edges Go-side — the per-edge anomaly walk
// further down already pulls the full edge stream, so bucketing
// fan-in during that traversal is free. The InDegreeForNodes
// capability runs one COUNT { … } per id; on the gortex workspace
// the scoped set is ~30k function/method nodes, and tens of
// thousands of indexed subqueries are noticeably slower than the
// single AllEdges materialisation the anomaly walk already pays.
if allEdges == nil {
allEdges = s.graph.AllEdges()
}
inDegree := make(map[string]int, len(scopedSet))
for _, e := range allEdges {
if _, ok := scopedSet[e.To]; ok {
inDegree[e.To]++
}
}
// Determine which edge kinds are "unusual" — share of total
// edges is at or below rare_kind_pct. Recomputed once per call.
rareKinds := make(map[graph.EdgeKind]bool, len(kindCounts))
if totalEdges > 0 {
thresholdFrac := rareKindPct / 100.0
for k, c := range kindCounts {
if float64(c)/float64(totalEdges) <= thresholdFrac {
rareKinds[k] = true
}
}
}
rows := make([]surprisingEdgeRow, 0, 256)
for _, e := range allEdges {
from, fromOK := scopedSet[e.From]
to, toOK := scopedSet[e.To]
// Either endpoint outside scope → skip (avoids surfacing
// edges into unrelated repos in multi-repo mode).
if !fromOK || !toOK {
continue
}
if pathPrefix != "" &&
!strings.HasPrefix(from.FilePath, pathPrefix) &&
!strings.HasPrefix(to.FilePath, pathPrefix) {
continue
}
signals, reasons := scoreSurprisingEdge(from, to, e, nodeToComm, inDegree, hubThreshold, rareKinds)
score := 0.0
for _, v := range signals {
score += v
}
if score < minScore {
continue
}
rows = append(rows, surprisingEdgeRow{
From: e.From, FromName: from.Name, FromFile: from.FilePath,
To: e.To, ToName: to.Name, ToFile: to.FilePath,
Kind: string(e.Kind),
Score: roundScore(score),
Signals: signals,
Reasons: reasons,
})
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Score != rows[j].Score {
return rows[i].Score > rows[j].Score
}
// Stable secondary order so identical scores are deterministic.
if rows[i].From != rows[j].From {
return rows[i].From < rows[j].From
}
return rows[i].To < rows[j].To
})
return rows
}
// scoreSurprisingEdge fires the five composite signals and returns
// only the ones that contributed, so the caller can render a clean
// reason list. Threshold checks deliberately match the row weights
// in the spec — easy to re-tune from one place.
func scoreSurprisingEdge(
from, to *graph.Node,
e *graph.Edge,
nodeToComm map[string]string,
inDegree map[string]int,
hubThreshold int,
rareKinds map[graph.EdgeKind]bool,
) (map[string]float64, []string) {
weights := surprisingSignalWeights()
signals := map[string]float64{}
reasons := []string{}
if nodeToComm != nil {
fc, fok := nodeToComm[from.ID]
tc, tok := nodeToComm[to.ID]
if fok && tok && fc != "" && tc != "" && fc != tc {
signals["cross_community"] = weights["cross_community"]
reasons = append(reasons, "cross_community("+fc+"→"+tc+")")
}
}
if from.Language != "" && to.Language != "" && from.Language != to.Language {
signals["cross_language"] = weights["cross_language"]
reasons = append(reasons, "cross_language("+from.Language+"→"+to.Language+")")
}
if inDegree[from.ID] <= 2 && inDegree[to.ID] >= hubThreshold {
signals["peripheral_to_hub"] = weights["peripheral_to_hub"]
reasons = append(reasons, "peripheral_to_hub(in:"+itoaPair(inDegree[from.ID], inDegree[to.ID])+")")
}
if isTestPath(from.FilePath) != isTestPath(to.FilePath) {
signals["cross_test"] = weights["cross_test"]
reasons = append(reasons, "cross_test")
}
if rareKinds[e.Kind] {
signals["unusual_kind"] = weights["unusual_kind"]
reasons = append(reasons, "unusual_kind("+string(e.Kind)+")")
}
return signals, reasons
}
// surprisingSignalWeights is the single source of truth for the
// per-signal contribution. The spec calls for the exact values in
// the gap-analysis row: +0.30/+0.20/+0.20/+0.15/+0.15.
func surprisingSignalWeights() map[string]float64 {
return map[string]float64{
"cross_community": 0.30,
"cross_language": 0.20,
"peripheral_to_hub": 0.20,
"cross_test": 0.15,
"unusual_kind": 0.15,
}
}
// isTestPath mirrors the helper used by the impact analyzer — kept
// inline so this file doesn't depend on internal/analysis just for
// one predicate.
func isTestPath(p string) bool {
for _, s := range []string{"_test.go", ".test.ts", ".test.js", ".spec.ts", ".spec.js", "__tests__/", "test_"} {
if strings.Contains(p, s) {
return true
}
}
return false
}
// roundScore trims float jitter so identical edges hash the same in
// downstream consumers without lying about the actual sum.
func roundScore(v float64) float64 {
return float64(int(v*1000+0.5)) / 1000.0
}
// itoaPair renders the two-int hint "1,7" without pulling in fmt.
// Kept tiny and inline because it's called per-edge.
func itoaPair(a, b int) string {
return itoa(a) + "," + itoa(b)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}