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
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
mcp "github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/reach"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// analyze kind=impact
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Composite per-symbol change-impact score. Where health_score grades
|
|
// code *quality*, impact grades *blast radius* — how much breaks, and
|
|
// how hard it is to change safely. Five axes, each normalised to
|
|
// 0..100 (higher = more impactful), then combined into one weighted
|
|
// composite:
|
|
//
|
|
// - centrality — PageRank over the call graph. A symbol central
|
|
// nodes depend on carries architectural weight.
|
|
// - reach — transitive dependent count (precomputed reach
|
|
// index when available, else direct fan-in).
|
|
// - complexity — McCabe cyclomatic complexity. Branchy bodies are
|
|
// harder to change without regressions.
|
|
// - co_change — git-history logical coupling of the symbol's file.
|
|
// Strong coupling means a change ripples to files
|
|
// the import graph never connects.
|
|
// - community — how many distinct graph communities the symbol's
|
|
// direct neighbours span. Cross-community symbols
|
|
// are architectural seams.
|
|
//
|
|
// The composite is a single ranked number so an agent can ask "what
|
|
// are the highest-impact symbols" or "how risky is changing X" with
|
|
// one call instead of fusing hotspots + explain_change_impact +
|
|
// find_co_changing_symbols by hand.
|
|
|
|
// Per-axis composite weights. The formula is the user-visible
|
|
// contract — named constants so a change is deliberate. Centrality
|
|
// and reach dominate: they answer "how much depends on this".
|
|
const (
|
|
impactWeightCentrality = 2.5
|
|
impactWeightReach = 2.5
|
|
impactWeightComplexity = 1.5
|
|
impactWeightCoChange = 1.5
|
|
impactWeightCommunity = 1.0
|
|
)
|
|
|
|
// Axis saturation constants — the half-saturation point of each
|
|
// 100*x/(x+k) curve, picked so a typical "notable" value lands near
|
|
// the 60-75 band rather than pinning the axis at 100.
|
|
const (
|
|
impactReachK = 30.0
|
|
impactComplexityK = 8.0
|
|
impactCoChangeK = 2.0
|
|
impactCommunityStep = 25.0 // neighbours spanning 4 communities = 100
|
|
)
|
|
|
|
// impactRow is the per-symbol breakdown returned by analyze impact.
|
|
type impactRow struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Score float64 `json:"score"`
|
|
Risk string `json:"risk"`
|
|
|
|
// Axes — each a 0..100 impact value.
|
|
Centrality float64 `json:"centrality"`
|
|
Reach float64 `json:"reach"`
|
|
Complexity float64 `json:"complexity"`
|
|
CoChange float64 `json:"co_change"`
|
|
Community float64 `json:"community"`
|
|
|
|
// Raw inputs behind the axes, for explainability.
|
|
PageRank float64 `json:"pagerank"`
|
|
ReachCount int `json:"reach_count"`
|
|
Cyclomatic int `json:"cyclomatic"`
|
|
CoChangeFiles int `json:"co_change_files"`
|
|
CommunitySpan int `json:"community_span"`
|
|
FanIn int `json:"fan_in"`
|
|
}
|
|
|
|
// handleAnalyzeImpactComposite ranks scoped symbols by composite
|
|
// change impact.
|
|
//
|
|
// Filters:
|
|
// - ids — comma-separated symbol IDs; score only these
|
|
// (the "blast radius of changing X" use).
|
|
// - path_prefix — keep only symbols whose file path starts here.
|
|
// - kinds — comma-separated (default function,method); "all"
|
|
// keeps every kind.
|
|
// - min_score / max_score — composite-score band filter.
|
|
// - limit — cap rows (default 100); total reports the
|
|
// pre-truncation count.
|
|
func (s *Server) handleAnalyzeImpactComposite(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
|
|
idFilter := splitIDSet(stringArg(args, "ids"))
|
|
limit := intArg(args, "limit", 100)
|
|
minScore := -1.0
|
|
if v, ok := args["min_score"].(float64); ok {
|
|
minScore = v
|
|
}
|
|
maxScore := -1.0
|
|
if v, ok := args["max_score"].(float64); ok {
|
|
maxScore = v
|
|
}
|
|
|
|
allowedKinds := map[graph.NodeKind]struct{}{
|
|
graph.KindFunction: {},
|
|
graph.KindMethod: {},
|
|
}
|
|
if k := strings.TrimSpace(stringArg(args, "kinds")); k != "" {
|
|
allowedKinds = parseAnalyzeKindsFilter(k)
|
|
}
|
|
|
|
// Co-change feeds one axis — make sure the git mine has run.
|
|
s.ensureCoChange()
|
|
|
|
pr := s.getPageRank()
|
|
maxPR := 0.0
|
|
if pr != nil {
|
|
maxPR = pr.Max
|
|
}
|
|
nodeToComm := map[string]string{}
|
|
if c := s.getCommunities(); c != nil && c.NodeToComm != nil {
|
|
nodeToComm = c.NodeToComm
|
|
}
|
|
|
|
// Build the candidate id set up front so both the fan-in
|
|
// aggregator and the per-edge community walk stay bounded by
|
|
// the kinds / path / ids the caller actually asked for. Without
|
|
// this, the analyzer paid for an unfiltered AllEdges()
|
|
// materialisation per call -- ~500k edges over cgo on the gortex
|
|
// workspace, the bulk of the wall-clock cost on a disk backend.
|
|
scoped := s.scopedNodes(ctx)
|
|
candidateIDs := make([]string, 0, len(scoped))
|
|
candidateSet := make(map[string]struct{}, len(scoped))
|
|
for _, n := range scoped {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
if allowedKinds != nil {
|
|
if _, ok := allowedKinds[n.Kind]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if pathPrefix != "" && !strings.HasPrefix(n.FilePath, pathPrefix) {
|
|
continue
|
|
}
|
|
if len(idFilter) > 0 {
|
|
if _, ok := idFilter[n.ID]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
candidateIDs = append(candidateIDs, n.ID)
|
|
candidateSet[n.ID] = struct{}{}
|
|
}
|
|
|
|
// fan-in: uses the NodeFanAggregator capability when the
|
|
// backend supports it (one bulk query per direction over the
|
|
// candidate id set) and falls back to a per-kind EdgesByKind
|
|
// stream otherwise. fanOutKinds is empty -- impact only reads
|
|
// fan-in.
|
|
fanIn, _ := analysis.CollectFanCounts(s.graph, candidateIDs,
|
|
[]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences},
|
|
nil,
|
|
)
|
|
|
|
// neighborComms[n] = set of distinct communities of n's call /
|
|
// reference neighbours (both directions). Streamed via
|
|
// EdgesByKind per kind so neither backend pays for an
|
|
// unfiltered AllEdges walk; the per-kind MATCH on disk backends
|
|
// is the same plan EdgesByKind feeds every other analyzer.
|
|
// Membership is restricted to candidate ids -- a node outside
|
|
// the result set has nowhere to receive a span count.
|
|
neighborComms := map[string]map[string]struct{}{}
|
|
addComm := func(node, comm string) {
|
|
if comm == "" {
|
|
return
|
|
}
|
|
if _, ok := candidateSet[node]; !ok {
|
|
return
|
|
}
|
|
set := neighborComms[node]
|
|
if set == nil {
|
|
set = map[string]struct{}{}
|
|
neighborComms[node] = set
|
|
}
|
|
set[comm] = struct{}{}
|
|
}
|
|
for _, kind := range []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences} {
|
|
for e := range s.graph.EdgesByKind(kind) {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
addComm(e.From, nodeToComm[e.To])
|
|
addComm(e.To, nodeToComm[e.From])
|
|
}
|
|
}
|
|
|
|
rows := make([]impactRow, 0, len(candidateIDs))
|
|
for _, n := range scoped {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
if _, ok := candidateSet[n.ID]; !ok {
|
|
continue
|
|
}
|
|
|
|
prVal := pr.ScoreOf(n.ID)
|
|
centrality := 0.0
|
|
if maxPR > 0 {
|
|
centrality = 100 * prVal / maxPR
|
|
}
|
|
|
|
// Reach: precomputed transitive set when the index is built,
|
|
// otherwise direct fan-in as the depth-1 proxy.
|
|
reachCount := fanIn[n.ID]
|
|
if d1, d2, d3, hit := reach.Lookup(s.graph, n.ID); hit {
|
|
reachCount = len(d1) + len(d2) + len(d3)
|
|
}
|
|
reachScore := saturate(float64(reachCount), impactReachK)
|
|
|
|
cyc := cyclomaticOf(n)
|
|
complexityScore := saturate(float64(cyc-1), impactComplexityK)
|
|
|
|
var ccSum float64
|
|
ccFiles := 0
|
|
for _, sc := range s.coChangeScores(n.FilePath) {
|
|
ccSum += sc
|
|
ccFiles++
|
|
}
|
|
coChangeScore := saturate(ccSum, impactCoChangeK)
|
|
|
|
span := len(neighborComms[n.ID])
|
|
communityScore := math.Min(100, float64(span)*impactCommunityStep)
|
|
|
|
composite := (centrality*impactWeightCentrality +
|
|
reachScore*impactWeightReach +
|
|
complexityScore*impactWeightComplexity +
|
|
coChangeScore*impactWeightCoChange +
|
|
communityScore*impactWeightCommunity) /
|
|
(impactWeightCentrality + impactWeightReach + impactWeightComplexity +
|
|
impactWeightCoChange + impactWeightCommunity)
|
|
|
|
row := impactRow{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: string(n.Kind),
|
|
File: n.FilePath,
|
|
Line: n.StartLine,
|
|
Score: roundTo(composite, 2),
|
|
Risk: impactRisk(composite),
|
|
Centrality: roundTo(centrality, 2),
|
|
Reach: roundTo(reachScore, 2),
|
|
Complexity: roundTo(complexityScore, 2),
|
|
CoChange: roundTo(coChangeScore, 2),
|
|
Community: roundTo(communityScore, 2),
|
|
PageRank: prVal,
|
|
ReachCount: reachCount,
|
|
Cyclomatic: cyc,
|
|
CoChangeFiles: ccFiles,
|
|
CommunitySpan: span,
|
|
FanIn: fanIn[n.ID],
|
|
}
|
|
if minScore >= 0 && row.Score < minScore {
|
|
continue
|
|
}
|
|
if maxScore >= 0 && row.Score > maxScore {
|
|
continue
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
|
|
// Rank by composite descending — the highest-impact symbols, the
|
|
// ones to change with the most care, surface first.
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
if rows[i].Score != rows[j].Score {
|
|
return rows[i].Score > rows[j].Score
|
|
}
|
|
if rows[i].File != rows[j].File {
|
|
return rows[i].File < rows[j].File
|
|
}
|
|
return rows[i].ID < rows[j].ID
|
|
})
|
|
|
|
total := len(rows)
|
|
truncated := false
|
|
if limit > 0 && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
truncated = true
|
|
}
|
|
|
|
if s.isGCX(ctx, req) {
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("impact", rows))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%-8s %6.2f %s:%d %s\n", r.Risk, r.Score, r.File, r.Line, r.ID)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no symbols scored\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"symbols": rows,
|
|
"total": total,
|
|
"truncated": truncated,
|
|
"weights": map[string]float64{
|
|
"centrality": impactWeightCentrality,
|
|
"reach": impactWeightReach,
|
|
"complexity": impactWeightComplexity,
|
|
"co_change": impactWeightCoChange,
|
|
"community": impactWeightCommunity,
|
|
},
|
|
}
|
|
if truncated {
|
|
resp["limit"] = limit
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
// saturate maps a non-negative raw value onto 0..100 with a
|
|
// half-saturation point of k: x==k yields 50, x==3k yields 75.
|
|
func saturate(x, k float64) float64 {
|
|
if x <= 0 || k <= 0 {
|
|
return 0
|
|
}
|
|
return 100 * x / (x + k)
|
|
}
|
|
|
|
// impactRisk buckets a composite score into a coarse risk label.
|
|
func impactRisk(score float64) string {
|
|
switch {
|
|
case score >= 75:
|
|
return "CRITICAL"
|
|
case score >= 55:
|
|
return "HIGH"
|
|
case score >= 35:
|
|
return "MEDIUM"
|
|
default:
|
|
return "LOW"
|
|
}
|
|
}
|
|
|
|
// cyclomaticOf reads the McCabe complexity stamped on a function node
|
|
// by the language extractors. The extractors only stamp values above
|
|
// 1, so an absent key means the canonical "no branches" score of 1.
|
|
func cyclomaticOf(n *graph.Node) int {
|
|
if n == nil || n.Meta == nil {
|
|
return 1
|
|
}
|
|
switch v := n.Meta["complexity"].(type) {
|
|
case int:
|
|
if v > 0 {
|
|
return v
|
|
}
|
|
case int64:
|
|
if v > 0 {
|
|
return int(v)
|
|
}
|
|
case float64:
|
|
if v > 0 {
|
|
return int(v)
|
|
}
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// splitIDSet parses a comma-separated symbol-ID list into a set.
|
|
// Unlike parseCSVSet it preserves case — symbol IDs are case-sensitive.
|
|
func splitIDSet(in string) map[string]struct{} {
|
|
out := map[string]struct{}{}
|
|
for _, part := range strings.Split(in, ",") {
|
|
if p := strings.TrimSpace(part); p != "" {
|
|
out[p] = struct{}{}
|
|
}
|
|
}
|
|
return out
|
|
}
|