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
348 lines
11 KiB
Go
348 lines
11 KiB
Go
// pagerank — graph-EXTRACTION-flavoured centrality analysis.
|
|
//
|
|
// analyze kind=pagerank ranks symbols by PageRank authority: a
|
|
// symbol is "central" when central symbols depend on it, so a
|
|
// rarely-called API that's invoked from every domain layer ranks
|
|
// higher than a heavily-called test helper. This is qualitatively
|
|
// different from the degree-based `hotspots` analyzer — random-walk
|
|
// authority weights influence by reach, not by raw fan-in count.
|
|
//
|
|
// Routing:
|
|
//
|
|
// - When the backing graph.Store implements graph.PageRanker
|
|
// (today only store_sqlite), the analyzer delegates to the
|
|
// engine-native parallel implementation (Ligra-based). Saves
|
|
// the per-call cost of a fresh Go-side power iteration.
|
|
//
|
|
// - Otherwise (in-memory store), falls back to
|
|
// analysis.ComputePageRank — the same pure-Go implementation
|
|
// the search rerank pipeline consumes via the cached
|
|
// Server.pageRank field.
|
|
|
|
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"
|
|
)
|
|
|
|
// pageRankRow is the per-symbol shape the analyzer returns.
|
|
type pageRankRow 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"`
|
|
Rank float64 `json:"rank"`
|
|
}
|
|
|
|
func (s *Server) handleAnalyzePageRank(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)
|
|
}
|
|
damping := 0.0
|
|
if v, ok := args["damping"].(float64); ok && v > 0 && v < 1 {
|
|
damping = v
|
|
}
|
|
maxIter := 0
|
|
if v, ok := args["max_iterations"].(float64); ok && v > 0 {
|
|
maxIter = int(v)
|
|
}
|
|
tolerance := 0.0
|
|
if v, ok := args["tolerance"].(float64); ok && v > 0 {
|
|
tolerance = v
|
|
}
|
|
nodeKinds := parseKindFilter(stringArg(args, "node_kinds"))
|
|
|
|
// runPageRank applies the limit internally on BOTH the engine-native
|
|
// and in-process paths. When the request narrows scope we must rank the
|
|
// full graph, drop out-of-scope hits, and only THEN cap — capping first
|
|
// would spend the limit on rows the caller can't see and return fewer
|
|
// than `limit` visible symbols. So request unbounded under scope and
|
|
// re-apply the limit after the visibility filter below.
|
|
prLimit := limit
|
|
if s.scopeFiltersActive(ctx) {
|
|
prLimit = 0
|
|
}
|
|
|
|
hits := s.runPageRank(graph.PageRankOpts{
|
|
NodeKinds: nodeKinds,
|
|
DampingFactor: damping,
|
|
MaxIterations: maxIter,
|
|
Tolerance: tolerance,
|
|
Limit: prLimit,
|
|
})
|
|
|
|
// Narrow to the session workspace + optional repo allow-set, then
|
|
// re-apply the original limit (runPageRank ran unbounded above under
|
|
// scope). pagerank reads s.graph directly, so without this the authority
|
|
// ranking would span every workspace. Strict no-op for an unbound
|
|
// session with no RepoAllow.
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]graph.PageRankHit, 0, len(hits))
|
|
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 instead
|
|
// of per-id GetNode. On a disk backend each GetNode is a
|
|
// round-trip; on the default limit (20) the per-id path issued 20
|
|
// round-trips per pagerank invocation. Single GetNodesByIDs
|
|
// collapses that into one bulk query while preserving rank order
|
|
// (the local map lookup is keyed by NodeID).
|
|
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([]pageRankRow, 0, len(hits))
|
|
for _, h := range hits {
|
|
row := pageRankRow{ID: h.NodeID, Rank: h.Rank}
|
|
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("pagerank", rows))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
fmt.Fprintf(&b, "%s %s %s:%d rank=%.6f\n", r.Kind, r.ID, r.FilePath, r.Line, r.Rank)
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{"pagerank": rows, "count": len(rows)})
|
|
}
|
|
|
|
// runPageRank picks the engine-native PageRanker when the
|
|
// backing store implements it, otherwise falls back to the
|
|
// in-process power iteration.
|
|
func (s *Server) runPageRank(opts graph.PageRankOpts) []graph.PageRankHit {
|
|
if store := s.backendStore(); store != nil {
|
|
if pr, ok := store.(graph.PageRanker); ok {
|
|
hits, err := pr.PageRank(opts)
|
|
if err == nil {
|
|
return hits
|
|
}
|
|
// Fall through to the in-process path on backend
|
|
// error rather than surface a half-completed
|
|
// result; engine-native is a hot path optimisation,
|
|
// not the source of truth.
|
|
}
|
|
}
|
|
// Fallback: pure-Go power iteration on the in-memory mirror.
|
|
// analysis.ComputePageRank doesn't accept the same options
|
|
// as the engine-native call yet — it uses fixed damping /
|
|
// iteration constants — so opts.DampingFactor / MaxIterations
|
|
// / Tolerance are silently ignored on the fallback path. The
|
|
// NodeKinds filter is honoured by post-filtering the result.
|
|
res := analysis.ComputePageRank(s.graph)
|
|
if res == nil || len(res.Scores) == 0 {
|
|
return nil
|
|
}
|
|
allow := makeKindAllow(opts.NodeKinds)
|
|
hits := make([]graph.PageRankHit, 0, len(res.Scores))
|
|
for id, rank := range res.Scores {
|
|
if !allow(s.graph.GetNode(id)) {
|
|
continue
|
|
}
|
|
hits = append(hits, graph.PageRankHit{NodeID: id, Rank: rank})
|
|
}
|
|
sort.Slice(hits, func(i, j int) bool { return hits[i].Rank > hits[j].Rank })
|
|
if opts.Limit > 0 && opts.Limit < len(hits) {
|
|
hits = hits[:opts.Limit]
|
|
}
|
|
return hits
|
|
}
|
|
|
|
// backendStore returns the underlying graph.Store the indexer
|
|
// writes to — which is what implements the capability interfaces
|
|
// (PageRanker, CommunityDetector, …). Falls back to s.graph when
|
|
// no indexer is wired so test fixtures keep working.
|
|
func (s *Server) backendStore() graph.Store {
|
|
if s.indexer != nil {
|
|
return s.indexer.Graph()
|
|
}
|
|
return s.graph
|
|
}
|
|
|
|
// parseKindFilter parses a comma-separated list of graph node
|
|
// kinds (e.g. "function,method,type") into a typed slice. Empty
|
|
// input → empty slice (caller treats that as "no filter").
|
|
func parseKindFilter(in string) []graph.NodeKind {
|
|
in = strings.TrimSpace(in)
|
|
if in == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(in, ",")
|
|
out := make([]graph.NodeKind, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out = append(out, graph.NodeKind(p))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleAnalyzeLouvain returns the Louvain partitioning of the
|
|
// graph. When the backing store implements graph.CommunityDetector
|
|
// (today only store_sqlite), the partitioning is delegated to the
|
|
// engine-native implementation and threaded through the existing
|
|
// label / hub / cohesion / parent post-processing
|
|
// (analysis.DetectCommunitiesLouvainBackend) so the response is
|
|
// shape-identical to the in-process path. Otherwise the in-process
|
|
// DetectCommunitiesLouvain runs.
|
|
//
|
|
// Distinct from `analyze kind=clusters` which uses the Leiden
|
|
// algorithm (the Server's cached communities). Louvain produces
|
|
// different — typically more granular — partitions; this kind
|
|
// exposes it as a first-class result for clients that want the
|
|
// Louvain shape specifically.
|
|
func (s *Server) handleAnalyzeLouvain(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
limit := 50
|
|
if v, ok := req.GetArguments()["limit"].(float64); ok && v > 0 {
|
|
limit = int(v)
|
|
}
|
|
|
|
result := s.runLouvain()
|
|
if result == nil {
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"communities": []any{},
|
|
"modularity": 0.0,
|
|
})
|
|
}
|
|
|
|
communities := result.Communities
|
|
totalCommunities := len(result.Communities)
|
|
|
|
// Narrow to the session workspace + optional repo allow-set when the
|
|
// request scopes below the global graph: prune each community's members
|
|
// to visible nodes, recompute Size/Files, and drop communities left
|
|
// empty. louvain reads s.graph directly, so without this the partition
|
|
// would span every workspace. Hub is a symbol name (not a node ID) so it
|
|
// is left untouched. Filter before the limit cap so the cap and the
|
|
// total recompute over the visible set. Strict no-op for an unbound
|
|
// session with no RepoAllow.
|
|
if s.scopeFiltersActive(ctx) {
|
|
kept := make([]analysis.Community, 0, len(communities))
|
|
for _, c := range communities {
|
|
visMembers := make([]string, 0, len(c.Members))
|
|
files := make([]string, 0, len(c.Files))
|
|
seenFile := make(map[string]struct{}, len(c.Files))
|
|
for _, id := range c.Members {
|
|
n := s.graph.GetNode(id)
|
|
if !s.analyzeNodeVisible(ctx, n) {
|
|
continue
|
|
}
|
|
visMembers = append(visMembers, id)
|
|
if n.FilePath != "" {
|
|
if _, dup := seenFile[n.FilePath]; !dup {
|
|
seenFile[n.FilePath] = struct{}{}
|
|
files = append(files, n.FilePath)
|
|
}
|
|
}
|
|
}
|
|
if len(visMembers) == 0 {
|
|
continue
|
|
}
|
|
c.Members = visMembers
|
|
c.Size = len(visMembers)
|
|
c.Files = files
|
|
kept = append(kept, c)
|
|
}
|
|
communities = kept
|
|
totalCommunities = len(kept)
|
|
}
|
|
|
|
if limit > 0 && limit < len(communities) {
|
|
communities = communities[:limit]
|
|
}
|
|
|
|
if s.isGCX(ctx, req) {
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("louvain", map[string]any{
|
|
"communities": communities,
|
|
"modularity": result.Modularity,
|
|
}))
|
|
}
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "modularity=%.4f communities=%d\n", result.Modularity, totalCommunities)
|
|
for _, c := range communities {
|
|
fmt.Fprintf(&b, " %s size=%d cohesion=%.3f label=%s hub=%s\n",
|
|
c.ID, c.Size, c.Cohesion, c.Label, c.Hub)
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"communities": communities,
|
|
"modularity": result.Modularity,
|
|
"total": totalCommunities,
|
|
})
|
|
}
|
|
|
|
// runLouvain picks the engine-native CommunityDetector when the
|
|
// backing store implements it, otherwise falls back to the
|
|
// pure-Go in-process Louvain. The output shape is identical
|
|
// either way (analysis.DetectCommunitiesLouvainBackend threads
|
|
// the engine-native partition through the same post-processing).
|
|
func (s *Server) runLouvain() *analysis.CommunityResult {
|
|
if store := s.backendStore(); store != nil {
|
|
if cd, ok := store.(graph.CommunityDetector); ok {
|
|
if r := analysis.DetectCommunitiesLouvainBackend(s.graph, cd); r != nil {
|
|
return r
|
|
}
|
|
// Engine-native error path falls through to the
|
|
// in-process implementation rather than surfacing
|
|
// a half-completed result.
|
|
}
|
|
}
|
|
return analysis.DetectCommunitiesLouvain(s.graph)
|
|
}
|
|
|
|
// makeKindAllow returns a predicate that reports whether a node's
|
|
// kind passes the filter. nil node is always rejected (defensive).
|
|
func makeKindAllow(kinds []graph.NodeKind) func(*graph.Node) bool {
|
|
if len(kinds) == 0 {
|
|
return func(n *graph.Node) bool { return n != nil }
|
|
}
|
|
set := make(map[graph.NodeKind]struct{}, len(kinds))
|
|
for _, k := range kinds {
|
|
set[k] = struct{}{}
|
|
}
|
|
return func(n *graph.Node) bool {
|
|
if n == nil {
|
|
return false
|
|
}
|
|
_, ok := set[n.Kind]
|
|
return ok
|
|
}
|
|
}
|