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

311 lines
9.3 KiB
Go

package mcp
import (
"context"
"fmt"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// externalModuleRow rolls up call/symbol counts for one external module
// (stdlib or module-cache entry). Surfaces in the JSON / compact /
// GCX1 outputs of `analyze kind=external_calls`.
type externalModuleRow struct {
ID string `json:"id"`
Path string `json:"path"`
Version string `json:"version,omitempty"`
ModuleKind string `json:"module_kind"`
Symbols int `json:"symbols"`
Calls int `json:"calls"`
}
// externalSymbolRow lists one external symbol attributed to a specific
// KindModule. Returned when the caller pins `id` to a module node ID.
type externalSymbolRow struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
Path string `json:"import_path"`
Calls int `json:"calls"`
Callers int `json:"callers"`
}
// handleAnalyzeExternalCalls surfaces the cross-repo / external symbol
// attribution materialised by the goanalysis externals pass. It walks
// every edge whose target is an `ext::` node and groups by the owning
// module, yielding either a per-module rollup ("which stdlib /
// module-cache packages does this codebase reach into?") or a per-
// symbol detail when the caller pins a module via the `id` filter.
//
// Filters:
//
// - id: KindModule node ID (e.g. `module::go:stdlib`,
// `module::go:github.com/foo/bar@v1.2.3`). When set, returns one row
// per external symbol attributed to that module with caller counts.
// - module_kind: stdlib | module_cache. Restricts the rollup to one
// module class.
// - module_path: substring match on the module path (e.g. "github.com"
// to filter to module-cache only).
// - name: substring match on external symbol name (used with id).
func (s *Server) handleAnalyzeExternalCalls(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
idFilter := strings.TrimSpace(stringArg(args, "id"))
moduleKindFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "module_kind")))
modulePathFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "module_path")))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
if idFilter != "" {
return s.externalCallsForModule(ctx, req, idFilter, nameFilter)
}
return s.externalCallsRollup(ctx, req, moduleKindFilter, modulePathFilter)
}
// externalCallsRollup groups external attributions by module and returns
// per-module call/symbol counts.
func (s *Server) externalCallsRollup(ctx context.Context, req mcp.CallToolRequest, moduleKindFilter, modulePathFilter string) (*mcp.CallToolResult, error) {
byModule := map[string]*externalModuleRow{}
for _, n := range s.scopedNodes(ctx) {
if n == nil || n.Kind != graph.KindModule {
continue
}
// Skip synthetic module nodes — the resolver's external-call
// synthesis pass materialises a KindModule placeholder per
// un-indexed package so call chains keep the external hop, but
// those carry no goanalysis attribution and would only show as
// empty 0/0 rows here. This rollup is for type-checker-grounded
// module usage; synthetic nodes are surfaced via their edges.
if synthetic, _ := n.Meta["synthetic"].(bool); synthetic {
continue
}
moduleKind, _ := n.Meta["module_kind"].(string)
if moduleKindFilter != "" && moduleKind != moduleKindFilter {
continue
}
modulePath, _ := n.Meta["path"].(string)
if modulePathFilter != "" && !strings.Contains(strings.ToLower(modulePath), modulePathFilter) {
continue
}
version, _ := n.Meta["version"].(string)
byModule[n.ID] = &externalModuleRow{
ID: n.ID,
Path: modulePath,
Version: version,
ModuleKind: moduleKind,
}
}
if len(byModule) == 0 {
return s.emitExternalCallsRollup(ctx, req, nil)
}
for _, n := range s.scopedNodes(ctx) {
if n == nil {
continue
}
if !isExternalSymbolNode(n) {
continue
}
moduleID, _ := n.Meta["module_id"].(string)
row, ok := byModule[moduleID]
if !ok {
continue
}
row.Symbols++
row.Calls += countCallersToExternal(s.graph, n.ID)
}
rows := make([]*externalModuleRow, 0, len(byModule))
for _, r := range byModule {
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Calls != rows[j].Calls {
return rows[i].Calls > rows[j].Calls
}
if rows[i].Symbols != rows[j].Symbols {
return rows[i].Symbols > rows[j].Symbols
}
return rows[i].Path < rows[j].Path
})
return s.emitExternalCallsRollup(ctx, req, rows)
}
// externalCallsForModule lists every external symbol attributed to one
// KindModule, with its caller count.
func (s *Server) externalCallsForModule(ctx context.Context, req mcp.CallToolRequest, moduleID, nameFilter string) (*mcp.CallToolResult, error) {
mod := s.graph.GetNode(moduleID)
rows := []*externalSymbolRow{}
if mod != nil && mod.Kind == graph.KindModule {
for _, n := range s.scopedNodes(ctx) {
if n == nil {
continue
}
if !isExternalSymbolNode(n) {
continue
}
id, _ := n.Meta["module_id"].(string)
if id != moduleID {
continue
}
if nameFilter != "" && !strings.Contains(strings.ToLower(n.Name), nameFilter) {
continue
}
calls, callers := tallyExternalCallers(s.graph, n.ID)
importPath, _ := n.Meta["import_path"].(string)
rows = append(rows, &externalSymbolRow{
ID: n.ID,
Name: n.Name,
Kind: string(n.Kind),
Path: importPath,
Calls: calls,
Callers: callers,
})
}
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Calls != rows[j].Calls {
return rows[i].Calls > rows[j].Calls
}
return rows[i].ID < rows[j].ID
})
if s.isGCX(ctx, req) {
items := make([]externalSymbolItem, 0, len(rows))
for _, r := range rows {
items = append(items, externalSymbolItem(*r))
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("external_calls.symbols", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-4d %-4d %s (%s)\n", r.Calls, r.Callers, r.Name, r.ID)
}
if len(rows) == 0 {
b.WriteString("no external symbols for that module\n")
}
return mcp.NewToolResultText(b.String()), nil
}
out := map[string]any{
"module": moduleID,
"symbols": rows,
"total": len(rows),
}
if mod != nil {
out["module_kind"], _ = mod.Meta["module_kind"].(string)
out["module_path"], _ = mod.Meta["path"].(string)
if v, _ := mod.Meta["version"].(string); v != "" {
out["version"] = v
}
}
return s.respondJSONOrTOON(ctx, req, out)
}
func (s *Server) emitExternalCallsRollup(ctx context.Context, req mcp.CallToolRequest, rows []*externalModuleRow) (*mcp.CallToolResult, error) {
if s.isGCX(ctx, req) {
items := make([]externalModuleItem, 0, len(rows))
for _, r := range rows {
items = append(items, externalModuleItem{
ID: r.ID,
Path: r.Path,
Version: r.Version,
ModuleKind: r.ModuleKind,
Symbols: r.Symbols,
Calls: r.Calls,
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("external_calls", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-4d %-4d [%s] %s%s\n", r.Calls, r.Symbols, r.ModuleKind, r.Path, suffixVersion(r.Version))
}
if len(rows) == 0 {
b.WriteString("no external attributions — run goanalysis enrichment first\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"modules": rows,
"total": len(rows),
})
}
func suffixVersion(v string) string {
if v == "" {
return ""
}
return "@" + v
}
// countCallersToExternal counts every incoming non-EdgeDependsOnModule
// edge to an external symbol node — those are the calls / references
// that goanalysis attributed.
func countCallersToExternal(g graph.Store, nodeID string) int {
n := 0
for _, e := range g.GetInEdges(nodeID) {
if e.Kind == graph.EdgeDependsOnModule {
continue
}
n++
}
return n
}
// tallyExternalCallers returns (totalCallEdges, distinctCallers) — the
// detail surface for the per-module symbol listing.
func tallyExternalCallers(g graph.Store, nodeID string) (int, int) {
calls := 0
seen := map[string]struct{}{}
for _, e := range g.GetInEdges(nodeID) {
if e.Kind == graph.EdgeDependsOnModule {
continue
}
calls++
seen[e.From] = struct{}{}
}
return calls, len(seen)
}
// isExternalSymbolNode reports whether n is one of the synthetic nodes
// created by the goanalysis externals pass — distinguishable by the
// `external` Meta flag.
func isExternalSymbolNode(n *graph.Node) bool {
if n == nil {
return false
}
if n.Kind == graph.KindModule {
return false
}
if v, ok := n.Meta["external"].(bool); ok {
return v
}
return strings.HasPrefix(n.ID, "ext::")
}
// externalModuleItem is the GCX1 row layout for the rollup.
type externalModuleItem struct {
ID string `gcx:"id"`
Path string `gcx:"path"`
Version string `gcx:"version"`
ModuleKind string `gcx:"module_kind"`
Symbols int `gcx:"symbols"`
Calls int `gcx:"calls"`
}
// externalSymbolItem is the GCX1 row layout for per-module symbol detail.
type externalSymbolItem struct {
ID string `gcx:"id"`
Name string `gcx:"name"`
Kind string `gcx:"kind"`
Path string `gcx:"import_path"`
Calls int `gcx:"calls"`
Callers int `gcx:"callers"`
}