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
327 lines
9.7 KiB
Go
327 lines
9.7 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"maps"
|
|
"sort"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/analysis"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// handleGetRepoOutline returns a single-call narrative overview of the
|
|
// indexed codebase: primary languages, top communities, load-bearing
|
|
// hotspots, most-imported files, and entry points. It's the "new to this
|
|
// repo" tool — everything a reader wants to know about the codebase in one
|
|
// response without having to assemble it from graph_stats + analyze + manual
|
|
// inspection.
|
|
//
|
|
// Output is compact on purpose (a handful of each list) so it stays under
|
|
// ~1k tokens even on large repos. For deeper exploration, the agent
|
|
// follows up with smart_context, find_usages, etc. on specific symbols
|
|
// surfaced here.
|
|
func (s *Server) handleGetRepoOutline(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
const (
|
|
topCommunitiesN = 5
|
|
topHotspotsN = 5
|
|
topMostImportedN = 10
|
|
topEntryPointsN = 10
|
|
topLanguagesN = 5
|
|
)
|
|
|
|
// scopedNodes confines the whole-repo overview to the session's
|
|
// workspace — for an unbound session it returns every node, so the
|
|
// outline is byte-identical to the legacy global view. inScope is
|
|
// the node-ID set used to bound the edge-driven and analyzer-driven
|
|
// sections; nil for an unbound session means "no filter".
|
|
_, _, bound := s.sessionScope(ctx)
|
|
|
|
// Pull the full scoped node slice only when the session is bound
|
|
// — the lang count, total-node count, and edge filter need it then.
|
|
// Unbound sessions get the same numbers from the backend's cached
|
|
// Stats() (one indexed groupby on disk backends) and the
|
|
// callable-only entry-point pass, neither of which materialises
|
|
// the whole node table over cgo.
|
|
var scoped []*graph.Node
|
|
var inScope map[string]bool
|
|
if bound {
|
|
scoped = s.scopedNodes(ctx)
|
|
inScope = make(map[string]bool, len(scoped))
|
|
for _, n := range scoped {
|
|
inScope[n.ID] = true
|
|
}
|
|
}
|
|
|
|
// Language breakdown — computed from the scoped node set so the
|
|
// counts reflect only the session's workspace.
|
|
type langEntry struct {
|
|
Name string `json:"name"`
|
|
Nodes int `json:"nodes"`
|
|
}
|
|
langCounts := make(map[string]int)
|
|
totalScopedNodes := 0
|
|
if bound {
|
|
for _, n := range scoped {
|
|
if n.Language != "" {
|
|
langCounts[n.Language]++
|
|
}
|
|
}
|
|
totalScopedNodes = len(scoped)
|
|
} else {
|
|
// Unbound: Stats().ByLanguage already aggregates this server-
|
|
// side; the cgo cost is one GROUP BY instead of one row per node.
|
|
stats := s.graph.Stats()
|
|
maps.Copy(langCounts, stats.ByLanguage)
|
|
totalScopedNodes = stats.TotalNodes
|
|
}
|
|
var languages []langEntry
|
|
for name, n := range langCounts {
|
|
languages = append(languages, langEntry{Name: name, Nodes: n})
|
|
}
|
|
sort.Slice(languages, func(i, j int) bool {
|
|
if languages[i].Nodes != languages[j].Nodes {
|
|
return languages[i].Nodes > languages[j].Nodes
|
|
}
|
|
return languages[i].Name < languages[j].Name
|
|
})
|
|
primaryLang := ""
|
|
if len(languages) > 0 {
|
|
primaryLang = languages[0].Name
|
|
}
|
|
if len(languages) > topLanguagesN {
|
|
languages = languages[:topLanguagesN]
|
|
}
|
|
|
|
// Edge count, bounded to edges whose endpoints are both in scope.
|
|
// Unbound sessions never set inScope, so the count is exactly
|
|
// the backend's EdgeCount() — an O(1) lookup that skips
|
|
// materialising every edge over cgo.
|
|
totalEdges := 0
|
|
if inScope == nil {
|
|
totalEdges = s.graph.EdgeCount()
|
|
} else {
|
|
for _, e := range s.graph.AllEdges() {
|
|
if !inScope[e.From] || !inScope[e.To] {
|
|
continue
|
|
}
|
|
totalEdges++
|
|
}
|
|
}
|
|
|
|
summary := map[string]any{
|
|
"total_nodes": totalScopedNodes,
|
|
"total_edges": totalEdges,
|
|
"primary_language": primaryLang,
|
|
"languages": languages,
|
|
}
|
|
|
|
// Communities — top N by member count, filtered to communities
|
|
// with at least one member inside the session's workspace.
|
|
communitiesSection := map[string]any{"count": 0}
|
|
if comms := s.getCommunities(); comms != nil && len(comms.Communities) > 0 {
|
|
sorted := make([]analysis.Community, 0, len(comms.Communities))
|
|
for _, c := range comms.Communities {
|
|
if inScope == nil {
|
|
sorted = append(sorted, c)
|
|
continue
|
|
}
|
|
for _, m := range c.Members {
|
|
if inScope[m] {
|
|
sorted = append(sorted, c)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
return sorted[i].Size > sorted[j].Size
|
|
})
|
|
top := sorted
|
|
if len(top) > topCommunitiesN {
|
|
top = top[:topCommunitiesN]
|
|
}
|
|
communitiesSection = map[string]any{
|
|
"count": len(sorted),
|
|
"modularity": comms.Modularity,
|
|
"top": topCommunitiesSummary(top),
|
|
}
|
|
}
|
|
|
|
// Hotspots — load-bearing symbols by fan-in/out/crossings. Use a low
|
|
// threshold to ensure we get the top N regardless of repo size.
|
|
// Post-filtered to the session's workspace.
|
|
hotspotsSection := []map[string]any{}
|
|
hs := s.getHotspots()
|
|
for _, h := range hs {
|
|
if len(hotspotsSection) >= topHotspotsN {
|
|
break
|
|
}
|
|
if inScope != nil && !inScope[h.ID] {
|
|
continue
|
|
}
|
|
hotspotsSection = append(hotspotsSection, map[string]any{
|
|
"id": h.ID,
|
|
"name": h.Name,
|
|
"kind": h.Kind,
|
|
"file_path": h.FilePath,
|
|
"fan_in": h.FanIn,
|
|
"fan_out": h.FanOut,
|
|
"complexity_score": h.ComplexityScore,
|
|
})
|
|
}
|
|
|
|
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
|
"summary": summary,
|
|
"communities": communitiesSection,
|
|
"hotspots": hotspotsSection,
|
|
"most_imported_files": mostImportedFiles(s.graph, inScope, topMostImportedN),
|
|
"entry_points": entryPoints(s.graph, inScope, topEntryPointsN),
|
|
})
|
|
}
|
|
|
|
// topCommunitiesSummary shapes a subset of communities for the outline.
|
|
// Trimmed from the full Community struct (members can be thousands of IDs)
|
|
// to just label, size, and cohesion — enough for the reader to decide
|
|
// whether to drill into that subsystem.
|
|
func topCommunitiesSummary(comms []analysis.Community) []map[string]any {
|
|
out := make([]map[string]any, 0, len(comms))
|
|
for _, c := range comms {
|
|
out = append(out, map[string]any{
|
|
"id": c.ID,
|
|
"label": c.Label,
|
|
"size": c.Size,
|
|
"cohesion": c.Cohesion,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mostImportedFiles ranks files by incoming `imports` edges. This surfaces
|
|
// the shared modules — packages everyone reaches for — which is a strong
|
|
// "here's where the gravity lives" signal for newcomers.
|
|
// inScope, when non-nil, bounds the ranking to imports whose target
|
|
// node is inside the session's workspace.
|
|
//
|
|
// Picks the FileImportAggregator capability when the backend
|
|
// implements it (one server-side aggregate ships back the per-file count
|
|
// instead of materialising every edge over cgo just to bucket).
|
|
// Falls back to the AllEdges-driven loop on backends that don't.
|
|
func mostImportedFiles(g graph.Store, inScope map[string]bool, topN int) []map[string]any {
|
|
type fileCount struct {
|
|
path string
|
|
count int
|
|
}
|
|
counts := make(map[string]int)
|
|
if ag, ok := g.(graph.FileImportAggregator); ok {
|
|
var scope []string
|
|
if inScope != nil {
|
|
scope = make([]string, 0, len(inScope))
|
|
for id := range inScope {
|
|
scope = append(scope, id)
|
|
}
|
|
// An empty inScope means "nothing matches" — the
|
|
// aggregator contract maps that to nil so we never
|
|
// fire a whole-graph scan on a bound session.
|
|
if len(scope) == 0 {
|
|
scope = []string{}
|
|
}
|
|
}
|
|
for _, r := range ag.FileImportCounts(scope) {
|
|
counts[r.FilePath] = r.Count
|
|
}
|
|
} else {
|
|
for _, e := range g.AllEdges() {
|
|
if e.Kind != graph.EdgeImports {
|
|
continue
|
|
}
|
|
target := g.GetNode(e.To)
|
|
if target == nil {
|
|
continue
|
|
}
|
|
if inScope != nil && !inScope[target.ID] {
|
|
continue
|
|
}
|
|
// Aggregate at the file level. For Import-kind nodes the node's
|
|
// FilePath is the file being imported; for File-kind nodes the
|
|
// ID is already the path.
|
|
path := target.FilePath
|
|
if path == "" {
|
|
path = target.ID
|
|
}
|
|
counts[path]++
|
|
}
|
|
}
|
|
|
|
var ranked []fileCount
|
|
for p, c := range counts {
|
|
ranked = append(ranked, fileCount{path: p, count: c})
|
|
}
|
|
sort.Slice(ranked, func(i, j int) bool {
|
|
if ranked[i].count != ranked[j].count {
|
|
return ranked[i].count > ranked[j].count
|
|
}
|
|
return ranked[i].path < ranked[j].path
|
|
})
|
|
if len(ranked) > topN {
|
|
ranked = ranked[:topN]
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(ranked))
|
|
for _, r := range ranked {
|
|
out = append(out, map[string]any{
|
|
"path": r.path,
|
|
"import_count": r.count,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// entryPoints finds likely program entry points: functions named `main`
|
|
// (the Go / Rust / C convention) and top-level functions with no callers
|
|
// in files named `main.*` or `cmd/**`. Good enough for the outline; a
|
|
// fuller process-based walk is what `get_processes` does separately.
|
|
//
|
|
// Lookup goes through FindNodesByName so the name index runs server-
|
|
// side on disk backends — the legacy nodes-slice walk pulled the whole
|
|
// node table just to keep the ~10 nodes literally named "main". When
|
|
// an inScope filter is supplied (bound session), it's applied after
|
|
// the name lookup so a bound session never sees mains from other
|
|
// workspaces.
|
|
func entryPoints(g graph.Store, inScope map[string]bool, topN int) []map[string]any {
|
|
type ep struct {
|
|
id string
|
|
name string
|
|
filePath string
|
|
}
|
|
var out []ep
|
|
for _, n := range g.FindNodesByName("main") {
|
|
if n == nil {
|
|
continue
|
|
}
|
|
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
|
|
continue
|
|
}
|
|
if inScope != nil && !inScope[n.ID] {
|
|
continue
|
|
}
|
|
out = append(out, ep{id: n.ID, name: n.Name, filePath: n.FilePath})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].filePath < out[j].filePath
|
|
})
|
|
if len(out) > topN {
|
|
out = out[:topN]
|
|
}
|
|
|
|
shaped := make([]map[string]any, 0, len(out))
|
|
for _, e := range out {
|
|
shaped = append(shaped, map[string]any{
|
|
"id": e.id,
|
|
"name": e.name,
|
|
"file_path": e.filePath,
|
|
})
|
|
}
|
|
return shaped
|
|
}
|