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
160 lines
4.7 KiB
Go
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
|
|
}
|