Files
zzet--gortex/internal/mcp/tools_analyze_components.go
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

194 lines
5.9 KiB
Go

// wcc / scc — connected-component diagnostics.
//
// `analyze kind=wcc` returns the weakly connected components: pairs
// of symbols reachable from each other ignoring edge direction. A
// healthy index has a small number of large WCCs (the connected
// codebase) plus a long tail of singletons (isolated extracted
// symbols). A WCC count that explodes between reindexes signals
// extraction drift, not code change.
//
// `analyze kind=scc` returns the strongly connected components:
// pairs of symbols mutually reachable along directed edges. Every
// non-trivial SCC (size > 1) is a recursion ring — mutual
// recursion in calls, two-way references between data types,
// circular module dependencies. Useful for cycle audits beyond
// what kind=cycles surfaces today.
//
// Routing:
//
// - When the backing graph.Store implements graph.ComponentFinder
// (today only store_sqlite), both kinds delegate to the
// engine-native algorithm.
//
// - Otherwise the in-process analysis.ComputeWCC /
// analysis.ComputeSCC runs. SCC uses an iterative Tarjan so a
// deep call graph won't blow the goroutine stack.
package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/graph"
)
// componentRow is the per-component shape the analyzer returns.
type componentRow struct {
ID int `json:"id"`
Size int `json:"size"`
Members []string `json:"members"`
}
// handleAnalyzeConnectedComponents serves both `analyze kind=wcc`
// and `analyze kind=scc`. The directed flag picks SCC; unset picks
// WCC.
func (s *Server) handleAnalyzeConnectedComponents(
ctx context.Context, req mcp.CallToolRequest, directed bool,
) (*mcp.CallToolResult, error) {
args := req.GetArguments()
limit := 50
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
minSize := 0
if v, ok := args["min_size"].(float64); ok && v > 0 {
minSize = int(v)
}
memberLimit := 100
if v, ok := args["member_limit"].(float64); ok && v > 0 {
memberLimit = int(v)
}
kindLabel := "wcc"
if directed {
kindLabel = "scc"
}
results := s.runComponents(directed, analysis.ComponentOptions{MinSize: minSize})
// Narrow to the session workspace + optional repo allow-set when the
// request scopes below the global graph: prune each component's
// members to visible nodes, recompute Size, and drop components left
// empty. wcc/scc read s.graph directly, so without this the components
// would span every workspace. The row ID is an opaque component index
// (not a node ID), so it is left untouched. Filter before the limit
// cap and the per-component member-limit truncation so both land on
// the visible set. Strict no-op for an unbound session with no
// RepoAllow.
if s.scopeFiltersActive(ctx) {
kept := make([]analysis.ComponentResult, 0, len(results))
for _, r := range results {
visMembers := make([]string, 0, len(r.Members))
for _, id := range r.Members {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
visMembers = append(visMembers, id)
}
}
if len(visMembers) == 0 {
continue
}
r.Members = visMembers
r.Size = len(visMembers)
kept = append(kept, r)
}
results = kept
}
if limit > 0 && limit < len(results) {
results = results[:limit]
}
rows := make([]componentRow, 0, len(results))
for _, r := range results {
members := r.Members
if memberLimit > 0 && memberLimit < len(members) {
members = members[:memberLimit]
}
rows = append(rows, componentRow{ID: r.ID, Size: r.Size, Members: members})
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze(kindLabel, rows))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "id=%d size=%d members=%v\n", r.ID, r.Size, r.Members)
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"components": rows,
"total": len(rows),
"kind": kindLabel,
})
}
// runComponents picks the engine-native path when the backing
// store implements graph.ComponentFinder, otherwise falls back to
// the in-process analysis.ComputeWCC / ComputeSCC.
func (s *Server) runComponents(directed bool, opts analysis.ComponentOptions) []analysis.ComponentResult {
if store := s.backendStore(); store != nil {
if cf, ok := store.(graph.ComponentFinder); ok {
hits, err := callComponentFinder(cf, directed, graph.ComponentOpts{
NodeKinds: opts.NodeKinds,
EdgeKinds: opts.EdgeKinds,
})
if err == nil {
return collectHits(hits, opts.MinSize)
}
// Engine-native error falls through to the in-process
// path rather than returning a half-done result.
}
}
if directed {
return analysis.ComputeSCC(s.graph, opts)
}
return analysis.ComputeWCC(s.graph, opts)
}
func callComponentFinder(cf graph.ComponentFinder, directed bool, opts graph.ComponentOpts) ([]graph.ComponentHit, error) {
if directed {
return cf.StronglyConnectedComponents(opts)
}
return cf.WeaklyConnectedComponents(opts)
}
// collectHits groups CommunityHits by ID, applies MinSize, sorts
// for determinism, and renumbers — mirrors analysis.collectComponents
// without exporting that internal helper.
func collectHits(hits []graph.ComponentHit, minSize int) []analysis.ComponentResult {
groups := make(map[int64][]string)
for _, h := range hits {
groups[h.ComponentID] = append(groups[h.ComponentID], h.NodeID)
}
out := make([]analysis.ComponentResult, 0, len(groups))
for _, members := range groups {
if minSize > 0 && len(members) < minSize {
continue
}
sort.Strings(members)
out = append(out, analysis.ComponentResult{Members: members, Size: len(members)})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Size != out[j].Size {
return out[i].Size > out[j].Size
}
if len(out[i].Members) > 0 && len(out[j].Members) > 0 {
return out[i].Members[0] < out[j].Members[0]
}
return false
})
for i := range out {
out[i].ID = i
}
return out
}