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

160 lines
4.7 KiB
Go

// kcore — find the densely connected core of the graph.
//
// k-core decomposition assigns every node a k-degree: the largest
// k for which the node remains in the k-core after iteratively
// pruning nodes with degree < k. Nodes with high k-degree sit at
// the densely connected centre of the graph — useful for "what's
// the core infrastructure every other layer depends on", and as a
// complement to PageRank (which weights by random-walk authority,
// not local density).
//
// Routing:
//
// - When the backing graph.Store implements graph.KCorer (today
// only store_sqlite), the analyzer delegates to the engine-
// native parallel implementation.
//
// - Otherwise analysis.ComputeKCore runs in-process. The
// implementation is the classic Batagelj & Zaversnik bucket
// algorithm — O(V + E), no recursion.
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"
)
// kcoreRow is the per-symbol shape the analyzer returns.
type kcoreRow struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Kind string `json:"kind,omitempty"`
FilePath string `json:"file_path,omitempty"`
Line int `json:"line,omitempty"`
KDegree int `json:"k_degree"`
}
func (s *Server) handleAnalyzeKCore(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
minDegree := 0
if v, ok := args["min_degree"].(float64); ok && v > 0 {
minDegree = int(v)
}
hits := s.runKCore(graph.KCoreOpts{
NodeKinds: parseKindFilter(stringArg(args, "node_kinds")),
})
// Filter by min_degree (drop trivial low-core nodes), then cap.
if minDegree > 0 {
filtered := hits[:0]
for _, h := range hits {
if h.KDegree >= int64(minDegree) {
filtered = append(filtered, h)
}
}
hits = filtered
}
// Narrow to the session workspace + optional repo allow-set when the
// request scopes below the global graph. kcore reads s.graph directly,
// so without this the densely-connected core would span every
// workspace. Filter after the min_degree pass and before the limit cap
// so the cap lands on the visible set and count recomputes. Strict
// no-op for an unbound session with no RepoAllow.
if s.scopeFiltersActive(ctx) {
kept := hits[:0]
for _, h := range hits {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(h.NodeID)) {
kept = append(kept, h)
}
}
hits = kept
}
if limit > 0 && limit < len(hits) {
hits = hits[:limit]
}
// Batch-materialise hit nodes in one backend round-trip — same
// rationale as analyze(pagerank). Preserves the descending
// k-degree order from runKCore.
ids := make([]string, 0, len(hits))
for _, h := range hits {
if h.NodeID != "" {
ids = append(ids, h.NodeID)
}
}
nodeByID := s.graph.GetNodesByIDs(ids)
rows := make([]kcoreRow, 0, len(hits))
for _, h := range hits {
row := kcoreRow{ID: h.NodeID, KDegree: int(h.KDegree)}
if n := nodeByID[h.NodeID]; n != nil {
row.Name = n.Name
row.Kind = string(n.Kind)
row.FilePath = n.FilePath
row.Line = n.StartLine
}
rows = append(rows, row)
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze("kcore", rows))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%s %s %s:%d k=%d\n", r.Kind, r.ID, r.FilePath, r.Line, r.KDegree)
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{"kcore": rows, "count": len(rows)})
}
// runKCore picks the engine-native KCorer when available,
// otherwise falls back to the in-process implementation. Returns
// hits sorted by k-degree descending (the engine-native CALL
// returns them unordered; the in-process ComputeKCore returns
// already sorted — normalise both here so the handler doesn't
// have to re-sort).
func (s *Server) runKCore(opts graph.KCoreOpts) []graph.KCoreHit {
if store := s.backendStore(); store != nil {
if kc, ok := store.(graph.KCorer); ok {
hits, err := kc.KCoreDecomposition(opts)
if err == nil {
sort.Slice(hits, func(i, j int) bool {
if hits[i].KDegree != hits[j].KDegree {
return hits[i].KDegree > hits[j].KDegree
}
return hits[i].NodeID < hits[j].NodeID
})
return hits
}
// Engine-native error falls through.
}
}
res := analysis.ComputeKCore(s.graph, analysis.KCoreOptions{
NodeKinds: opts.NodeKinds,
EdgeKinds: opts.EdgeKinds,
})
out := make([]graph.KCoreHit, len(res))
for i, h := range res {
out[i] = graph.KCoreHit{NodeID: h.NodeID, KDegree: int64(h.KDegree)}
}
return out
}