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
194 lines
5.9 KiB
Go
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
|
|
}
|