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

1780 lines
54 KiB
Go

// Edge-driven analyzers built on top of the coverage parser
// extensions: channel send/recv, goroutine spawns, field writes,
// annotations, config readers, event emitters, error throws. Each
// walks one (kind -> edge kind) family and groups call sites by
// target so producer/consumer mismatches and hotspots become a
// graph query rather than a grep run.
//
// Conventions match the older analyzers in tools_enhancements.go:
// - typed `<kind>Row` struct, JSON output by default,
// - optional `compact: true` for one-line text,
// - optional `format: "gcx"` for the GCX1 wire format,
// - stable sort orders so diffs are predictable across runs.
//
// Filters are intentionally narrow: each analyzer surfaces the
// dimensions the parser actually emits in meta. Adding new filters
// means new meta keys upstream; we don't fabricate dimensions
// here.
package mcp
import (
"context"
"fmt"
"iter"
"slices"
"sort"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/zzet/gortex/internal/graph"
)
// ---------------------------------------------------------------------------
// channel_ops — list channels with their senders/receivers.
// ---------------------------------------------------------------------------
// handleAnalyzeChannelOps walks every EdgeSends and EdgeRecvs edge
// and groups by channel target. The resulting rows surface
// producer/consumer mismatches (channels with sends but no
// receivers, or vice-versa) and concurrency hotspots (channels with
// many senders or receivers fanning across the codebase).
//
// Channels in v1 are extracted as `unresolved::<name>` targets
// because Go's tree-sitter pass doesn't propagate channel typing.
// The analyzer therefore reports the synthetic target id, not a
// fully-resolved channel symbol. That's the same fidelity
// `find_usages` would give a caller today and lets the rows be
// diff-able across runs.
//
// Filters:
//
// - path_prefix: scope to operations originating in a directory
// subtree. Useful for "channel discipline in package X".
func (s *Server) handleAnalyzeChannelOps(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pathPrefix := strings.TrimSpace(stringArg(req.GetArguments(), "path_prefix"))
type channelRow struct {
Channel string `json:"channel"`
Sends int `json:"sends"`
Recvs int `json:"recvs"`
Senders []string `json:"senders,omitempty"`
Receivers []string `json:"receivers,omitempty"`
}
byChannel := map[string]*channelRow{}
get := func(target string) *channelRow {
row, ok := byChannel[target]
if !ok {
row = &channelRow{Channel: target}
byChannel[target] = row
}
return row
}
// One scan over Sends+Recvs only — replaces the legacy AllEdges()
// walk that pulled every edge over cgo just to keep two kinds.
for e := range edgesByKinds(s.graph, graph.EdgeSends, graph.EdgeRecvs) {
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
continue
}
row := get(e.To)
if e.Kind == graph.EdgeSends {
row.Sends++
row.Senders = appendUnique(row.Senders, e.From)
} else {
row.Recvs++
row.Receivers = appendUnique(row.Receivers, e.From)
}
}
rows := make([]*channelRow, 0, len(byChannel))
for _, r := range byChannel {
sort.Strings(r.Senders)
sort.Strings(r.Receivers)
rows = append(rows, r)
}
// Scope filter: keep a channel row iff its target node is visible to
// the request (session workspace ceiling + optional repo allow-set),
// and prune sender/receiver lists to visible nodes only. No-op for an
// unbound, un-narrowed request. Sends/Recvs are edge counts, left as-is.
if s.scopeFiltersActive(ctx) {
kept := make([]*channelRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Channel)) {
continue
}
senders := make([]string, 0, len(r.Senders))
for _, id := range r.Senders {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
senders = append(senders, id)
}
}
r.Senders = senders
receivers := make([]string, 0, len(r.Receivers))
for _, id := range r.Receivers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
receivers = append(receivers, id)
}
}
r.Receivers = receivers
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
// Total op count desc; tie-break by channel id for stability.
ti := rows[i].Sends + rows[i].Recvs
tj := rows[j].Sends + rows[j].Recvs
if ti != tj {
return ti > tj
}
return rows[i].Channel < rows[j].Channel
})
if s.isGCX(ctx, req) {
items := make([]channelOpItem, 0, len(rows))
for _, r := range rows {
items = append(items, channelOpItem{
Channel: r.Channel,
Sends: r.Sends,
Recvs: r.Recvs,
Senders: strings.Join(r.Senders, ","),
Receivers: strings.Join(r.Receivers, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("channel_ops", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "send=%d recv=%d %s\n", r.Sends, r.Recvs, r.Channel)
}
if len(rows) == 0 {
b.WriteString("no channel ops\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"channels": rows,
"total": len(rows),
})
}
// ---------------------------------------------------------------------------
// goroutine_spawns — list spawn sites grouped by spawned target.
// ---------------------------------------------------------------------------
// handleAnalyzeGoroutineSpawns walks every EdgeSpawns edge and
// groups by spawned target. The mode meta (goroutine / async /
// promise / worker_pool) is surfaced verbatim so cross-language
// concurrency hotspots stay separable. Useful for spotting leaks
// (a single function with many spawn sites), unowned background
// work, and codebase-wide concurrency hygiene reviews.
func (s *Server) handleAnalyzeGoroutineSpawns(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Concurrency carries the shared sync_guarded / cross_concurrent
// classification of the spawned target: sync_guarded reports
// whether the goroutine body is a method on a lock-holding type,
// and cross_concurrent confirms the target is reached across a
// concurrency boundary. Omitted (nil) when neither flag is set.
type spawnRow struct {
Target string `json:"target"`
Mode string `json:"mode,omitempty"`
Spawns int `json:"spawns"`
Spawners []string `json:"spawners,omitempty"`
Concurrency *graph.ConcurrencyAnnotation `json:"concurrency,omitempty"`
}
byTarget := map[string]*spawnRow{}
for e := range edgesByKinds(s.graph, graph.EdgeSpawns) {
mode, _ := e.Meta["mode"].(string)
key := e.To + "|" + mode
row, ok := byTarget[key]
if !ok {
row = &spawnRow{Target: e.To, Mode: mode}
byTarget[key] = row
}
row.Spawns++
row.Spawners = appendUnique(row.Spawners, e.From)
}
rows := make([]*spawnRow, 0, len(byTarget))
for _, r := range byTarget {
sort.Strings(r.Spawners)
if ann := graph.ClassifyConcurrency(s.graph, r.Target); ann.Any() {
a := ann
r.Concurrency = &a
}
rows = append(rows, r)
}
// Scope filter: keep a spawn row iff its spawned target is visible,
// and prune spawners to visible nodes only. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*spawnRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Target)) {
continue
}
spawners := make([]string, 0, len(r.Spawners))
for _, id := range r.Spawners {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
spawners = append(spawners, id)
}
}
r.Spawners = spawners
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Spawns != rows[j].Spawns {
return rows[i].Spawns > rows[j].Spawns
}
if rows[i].Target != rows[j].Target {
return rows[i].Target < rows[j].Target
}
return rows[i].Mode < rows[j].Mode
})
if s.isGCX(ctx, req) {
items := make([]spawnItem, 0, len(rows))
for _, r := range rows {
it := spawnItem{
Target: r.Target,
Mode: r.Mode,
Spawns: r.Spawns,
Spawners: strings.Join(r.Spawners, ","),
}
if r.Concurrency != nil {
it.SyncGuarded = r.Concurrency.SyncGuarded
it.SyncGuardedWhy = r.Concurrency.SyncGuardedWhy
it.CrossConcurrent = r.Concurrency.CrossConcurrent
it.CrossConcurrentWhy = r.Concurrency.CrossConcurrentWhy
}
items = append(items, it)
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("goroutine_spawns", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
mode := r.Mode
if mode == "" {
mode = "?"
}
fmt.Fprintf(&b, "%-3d [%s] %s", r.Spawns, mode, r.Target)
if r.Concurrency != nil {
if r.Concurrency.SyncGuarded {
b.WriteString(" sync_guarded")
}
if r.Concurrency.CrossConcurrent {
b.WriteString(" cross_concurrent")
}
}
b.WriteByte('\n')
}
if len(rows) == 0 {
b.WriteString("no spawn sites\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"spawns": rows,
"total": len(rows),
})
}
// ---------------------------------------------------------------------------
// field_writers — for a given field id (or top-N most-written
// fields when no id given), list writer functions.
// ---------------------------------------------------------------------------
// handleAnalyzeFieldWriters walks EdgeWrites edges with a field
// target and groups by field. With `id` the analyzer scopes to one
// field — useful for "who mutates Server.config?". Without `id` it
// surfaces the top-N most-written fields, the mutability hotspot
// view.
//
// Filters:
//
// - id: a specific field node id. When set, only that field is
// reported. Useful for targeted review of a single field's
// write surface.
// - limit: max rows when no id is set (default 20).
func (s *Server) handleAnalyzeFieldWriters(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
idFilter := strings.TrimSpace(stringArg(args, "id"))
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
type writerRow struct {
Field string `json:"field"`
Writes int `json:"writes"`
Writers []string `json:"writers,omitempty"`
}
byField := map[string]*writerRow{}
for e := range edgesByKinds(s.graph, graph.EdgeWrites) {
if idFilter != "" && e.To != idFilter {
continue
}
// Only count writes whose target resolves to a field node.
// Pre-resolution edges land on `unresolved::*.foo` and would
// muddy the per-field rollup; the resolver post-pass
// rewrites the To, so any unresolved edges left at query
// time are a different problem.
if idFilter == "" {
target := s.graph.GetNode(e.To)
if target == nil || target.Kind != graph.KindField {
continue
}
}
row, ok := byField[e.To]
if !ok {
row = &writerRow{Field: e.To}
byField[e.To] = row
}
row.Writes++
row.Writers = appendUnique(row.Writers, e.From)
}
rows := make([]*writerRow, 0, len(byField))
for _, r := range byField {
sort.Strings(r.Writers)
rows = append(rows, r)
}
// Scope filter: keep a field row iff the field node is visible, and
// prune writers to visible nodes only. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*writerRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) {
continue
}
writers := make([]string, 0, len(r.Writers))
for _, id := range r.Writers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
writers = append(writers, id)
}
}
r.Writers = writers
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Writes != rows[j].Writes {
return rows[i].Writes > rows[j].Writes
}
return rows[i].Field < rows[j].Field
})
truncated := false
if idFilter == "" && len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
items := make([]fieldWriterItem, 0, len(rows))
for _, r := range rows {
items = append(items, fieldWriterItem{
Field: r.Field,
Writes: r.Writes,
Writers: strings.Join(r.Writers, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("field_writers", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d %s\n", r.Writes, r.Field)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no field writes\n")
}
return mcp.NewToolResultText(b.String()), nil
}
resp := map[string]any{
"fields": rows,
"total": len(rows),
"truncated": truncated,
}
return s.respondJSONOrTOON(ctx, req, resp)
}
// handleAnalyzeIndirectMutations lists fields mutated *indirectly* — via a
// method call on the field (s.counter.Increment()) or a sibling call on the
// receiver (s.helper()) — surfacing the `via` method for each. These are the
// accesses_field edges tagged Meta["indirect"]=true synthesized by the
// receiver-mutation fixpoint. With `id` it scopes to one field.
func (s *Server) handleAnalyzeIndirectMutations(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
idFilter := strings.TrimSpace(stringArg(args, "id"))
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
type mutator struct {
Function string `json:"function"`
Via string `json:"via,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
}
type fieldRow struct {
Field string `json:"field"`
Mutations int `json:"mutations"`
Mutators []mutator `json:"mutators,omitempty"`
}
byField := map[string]*fieldRow{}
for e := range edgesByKinds(s.graph, graph.EdgeAccessesField) {
if e.Meta == nil {
continue
}
if ind, _ := e.Meta["indirect"].(bool); !ind {
continue
}
if idFilter != "" && e.To != idFilter {
continue
}
via, _ := e.Meta["via"].(string)
row, ok := byField[e.To]
if !ok {
row = &fieldRow{Field: e.To}
byField[e.To] = row
}
row.Mutations++
row.Mutators = append(row.Mutators, mutator{Function: e.From, Via: via, File: e.FilePath, Line: e.Line})
}
rows := make([]*fieldRow, 0, len(byField))
for _, r := range byField {
sort.Slice(r.Mutators, func(i, j int) bool { return r.Mutators[i].Function < r.Mutators[j].Function })
rows = append(rows, r)
}
// Scope filter: keep a field row iff the field node is visible, and
// prune mutators to those whose function node is visible. The `via`
// method name is not a node ID, so it is left intact. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*fieldRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Field)) {
continue
}
mutators := make([]mutator, 0, len(r.Mutators))
for _, m := range r.Mutators {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(m.Function)) {
mutators = append(mutators, m)
}
}
r.Mutators = mutators
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Mutations != rows[j].Mutations {
return rows[i].Mutations > rows[j].Mutations
}
return rows[i].Field < rows[j].Field
})
truncated := false
if idFilter == "" && len(rows) > limit {
rows = rows[:limit]
truncated = true
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"fields": rows,
"total": len(rows),
"truncated": truncated,
})
}
// handleAnalyzeSpeculative is the audit surface for opt-in speculative
// dynamic-dispatch edges: it groups the best-guess call edges (Meta
// speculative=true) by their shape (computed_member / getattr / …) with a
// candidate-count histogram and samples, so an agent can review what the
// best-guess synthesizer produced before trusting any of it.
func (s *Server) handleAnalyzeSpeculative(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
type sample struct {
From string `json:"from"`
To string `json:"to"`
CandidateCount int `json:"candidate_count,omitempty"`
Confidence string `json:"confidence,omitempty"`
}
type shapeRow struct {
Shape string `json:"shape"`
Edges int `json:"edges"`
Samples []sample `json:"samples,omitempty"`
}
byShape := map[string]*shapeRow{}
total := 0
scoped := s.scopeFiltersActive(ctx)
for e := range edgesByKinds(s.graph, graph.EdgeCalls) {
if !e.IsSpeculative() {
continue
}
// Scope filter: a speculative edge is a from->to peer pair; drop it
// unless both endpoints are visible. Gating the loop recomputes
// Edges, total, and Samples together. No-op when unbound.
if scoped && (!s.analyzeNodeVisible(ctx, s.graph.GetNode(e.From)) || !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To))) {
continue
}
total++
shape, _ := e.Meta["via"].(string)
shape = strings.TrimPrefix(shape, "speculative.")
if shape == "" {
shape = "unknown"
}
row, ok := byShape[shape]
if !ok {
row = &shapeRow{Shape: shape}
byShape[shape] = row
}
row.Edges++
if len(row.Samples) < 10 {
cc, _ := e.Meta["candidate_count"].(int)
row.Samples = append(row.Samples, sample{From: e.From, To: e.To, CandidateCount: cc, Confidence: e.ConfidenceLabel})
}
}
rows := make([]*shapeRow, 0, len(byShape))
for _, r := range byShape {
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Edges != rows[j].Edges {
return rows[i].Edges > rows[j].Edges
}
return rows[i].Shape < rows[j].Shape
})
return s.respondJSONOrTOON(ctx, req, map[string]any{
"shapes": rows,
"total": total,
"note": "speculative edges are best-guess fan-outs, hidden from default queries; pass include_speculative:true to surface them",
})
}
// handleAnalyzeRefFacts surfaces resolved-reference facts — each reference edge
// that resolved to a concrete target, with the provenance tier that resolved
// it. The durable form is the backend's ref_facts sidecar; this read derives
// the same facts from the live graph (backend-agnostic). With `id` it scopes to
// references originating in one file.
func (s *Server) handleAnalyzeRefFacts(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
fileFilter := strings.TrimSpace(stringArg(args, "id"))
if fileFilter == "" {
fileFilter = strings.TrimSpace(stringArg(args, "path"))
}
limit := 200
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
type fact struct {
From string `json:"from"`
To string `json:"to"`
Kind string `json:"kind"`
RefName string `json:"ref_name,omitempty"`
Origin string `json:"origin,omitempty"`
Tier string `json:"tier,omitempty"`
File string `json:"file,omitempty"`
}
var nodes []*graph.Node
if fileFilter != "" {
nodes = s.graph.GetFileNodes(fileFilter)
} else {
for _, k := range []graph.NodeKind{graph.KindFunction, graph.KindMethod} {
for n := range s.graph.NodesByKind(k) {
nodes = append(nodes, n)
}
}
}
var facts []fact
truncated := false
scoped := s.scopeFiltersActive(ctx)
for _, n := range nodes {
if n == nil {
continue
}
// Scope filter: drop reference facts whose origin node is out of
// scope. No-op when unbound.
if scoped && !s.analyzeNodeVisible(ctx, n) {
continue
}
for _, e := range s.graph.GetOutEdges(n.ID) {
if e == nil || !graph.IsResolvableRefEdge(e.Kind) {
continue
}
if e.To == "" || graph.IsUnresolvedTarget(e.To) || graph.IsStub(e.To) {
continue
}
// Scope filter: also drop facts whose resolved target is out
// of scope, so no cross-workspace target id leaks into a row.
if scoped && !s.analyzeNodeVisible(ctx, s.graph.GetNode(e.To)) {
continue
}
refName := ""
if t := s.graph.GetNode(e.To); t != nil {
refName = t.Name
}
origin := e.Origin
if origin == "" {
sem, _ := e.Meta["semantic_source"].(string)
origin = graph.DefaultOriginFor(e.Kind, e.Confidence, sem)
}
facts = append(facts, fact{
From: e.From, To: e.To, Kind: string(e.Kind), RefName: refName,
Origin: origin, Tier: graph.ResolvedBy(origin), File: n.FilePath,
})
if len(facts) >= limit {
truncated = true
break
}
}
if truncated {
break
}
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"facts": facts,
"total": len(facts),
"truncated": truncated,
})
}
// ---------------------------------------------------------------------------
// annotation_users — for an annotation node id (or list all when no
// id), surface annotated symbols.
// ---------------------------------------------------------------------------
// handleAnalyzeAnnotationUsers walks EdgeAnnotated edges. With `id`
// the analyzer scopes to one annotation target — "every symbol
// annotated `@Deprecated`". Without `id` it lists every distinct
// annotation found, with annotated count, so the agent can decide
// which one to dive into. The `name` filter narrows the
// annotation-name match without requiring a synthetic id.
//
// Filters:
//
// - id: annotation node id (e.g. `annotation::java::Deprecated`).
// Returns one row per annotated symbol.
// - name: annotation bare name (case-insensitive). Returns one
// row per matching annotation grouped by id.
func (s *Server) handleAnalyzeAnnotationUsers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
idFilter := strings.TrimSpace(stringArg(args, "id"))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
if idFilter != "" {
type annotatedRow struct {
Symbol string `json:"symbol"`
File string `json:"file"`
Line int `json:"line"`
Args string `json:"args,omitempty"`
}
var rows []annotatedRow
for e := range edgesByKinds(s.graph, graph.EdgeAnnotated) {
if e.To != idFilter {
continue
}
argsStr, _ := e.Meta["args"].(string)
rows = append(rows, annotatedRow{
Symbol: e.From,
File: e.FilePath,
Line: e.Line,
Args: argsStr,
})
}
// Scope filter: keep an annotated-symbol row iff the annotated
// symbol node is visible. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]annotatedRow, 0, len(rows))
for _, r := range rows {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) {
kept = append(kept, r)
}
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].File != rows[j].File {
return rows[i].File < rows[j].File
}
return rows[i].Line < rows[j].Line
})
if s.isGCX(ctx, req) {
items := make([]annotatedItem, 0, len(rows))
for _, r := range rows {
items = append(items, annotatedItem(r))
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("annotation_users", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
if r.Args != "" {
fmt.Fprintf(&b, "%s:%d %s (%s)\n", r.File, r.Line, r.Symbol, r.Args)
} else {
fmt.Fprintf(&b, "%s:%d %s\n", r.File, r.Line, r.Symbol)
}
}
if len(rows) == 0 {
b.WriteString("no users for that annotation\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"annotation": idFilter,
"users": rows,
"total": len(rows),
})
}
// No id — group annotations by id, count usages.
type annoRow struct {
ID string `json:"id"`
Name string `json:"name"`
Users int `json:"users"`
}
byID := map[string]*annoRow{}
for e := range edgesByKinds(s.graph, graph.EdgeAnnotated) {
row, ok := byID[e.To]
if !ok {
n := s.graph.GetNode(e.To)
name := ""
if n != nil {
name = n.Name
}
if nameFilter != "" && strings.ToLower(name) != nameFilter {
continue
}
row = &annoRow{ID: e.To, Name: name}
byID[e.To] = row
}
row.Users++
}
rows := make([]*annoRow, 0, len(byID))
for _, r := range byID {
rows = append(rows, r)
}
// Scope filter: keep an annotation row iff the annotation node is
// visible. No-op when unbound. Users is a count, left as-is.
if s.scopeFiltersActive(ctx) {
kept := make([]*annoRow, 0, len(rows))
for _, r := range rows {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
kept = append(kept, r)
}
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Users != rows[j].Users {
return rows[i].Users > rows[j].Users
}
return rows[i].ID < rows[j].ID
})
if s.isGCX(ctx, req) {
items := make([]annotationItem, 0, len(rows))
for _, r := range rows {
items = append(items, annotationItem{
ID: r.ID,
Name: r.Name,
Users: r.Users,
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("annotation_users.list", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-4d %s (%s)\n", r.Users, r.Name, r.ID)
}
if len(rows) == 0 {
b.WriteString("no annotations\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"annotations": rows,
"total": len(rows),
})
}
// ---------------------------------------------------------------------------
// config_readers — list config_key nodes with their readers.
// ---------------------------------------------------------------------------
// handleAnalyzeConfigReaders walks EdgeReadsConfig edges and
// groups by config-key target. Each row carries the config-key id,
// its surface name, the source (env/viper/etc — kept verbatim from
// node meta), and the reader symbol list. Useful for tracing
// configuration drift ("which functions read DATABASE_URL?") and
// finding hot config keys with many readers.
//
// Filters:
//
// - name: config-key bare name (case-insensitive). Returns the
// readers of that single key.
// - limit: max rows when no name filter is set (default 20).
func (s *Server) handleAnalyzeConfigReaders(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
type configRow struct {
ID string `json:"id"`
Name string `json:"name"`
Source string `json:"source,omitempty"`
Readers []string `json:"readers,omitempty"`
Reads int `json:"reads"`
}
byKey := map[string]*configRow{}
for e := range edgesByKinds(s.graph, graph.EdgeReadsConfig) {
row, ok := byKey[e.To]
if !ok {
n := s.graph.GetNode(e.To)
name := ""
source := ""
if n != nil {
name = n.Name
source, _ = n.Meta["source"].(string)
}
if nameFilter != "" && strings.ToLower(name) != nameFilter {
continue
}
row = &configRow{ID: e.To, Name: name, Source: source}
byKey[e.To] = row
}
row.Reads++
row.Readers = appendUnique(row.Readers, e.From)
}
rows := make([]*configRow, 0, len(byKey))
for _, r := range byKey {
sort.Strings(r.Readers)
rows = append(rows, r)
}
// Scope filter: keep a config-key row iff the key node is visible, and
// prune readers to visible nodes only. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*configRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
continue
}
readers := make([]string, 0, len(r.Readers))
for _, id := range r.Readers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
readers = append(readers, id)
}
}
r.Readers = readers
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Reads != rows[j].Reads {
return rows[i].Reads > rows[j].Reads
}
return rows[i].ID < rows[j].ID
})
truncated := false
if nameFilter == "" && len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
items := make([]configReaderItem, 0, len(rows))
for _, r := range rows {
items = append(items, configReaderItem{
ID: r.ID,
Name: r.Name,
Source: r.Source,
Reads: r.Reads,
Readers: strings.Join(r.Readers, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("config_readers", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
source := r.Source
if source == "" {
source = "?"
}
fmt.Fprintf(&b, "%-3d [%s] %s\n", r.Reads, source, r.Name)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no config readers\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"config_keys": rows,
"total": len(rows),
"truncated": truncated,
})
}
// isEnvConfigKey reports whether a config-key node represents an
// environment variable (as opposed to a viper key, a K8s ConfigMap
// entry, a struct-tag default, …).
func isEnvConfigKey(n *graph.Node) bool {
if n == nil || n.Kind != graph.KindConfigKey {
return false
}
if src, _ := n.Meta["source"].(string); src == "env" {
return true
}
return strings.HasPrefix(n.ID, "env::") || strings.HasPrefix(n.ID, "cfg::env::")
}
// handleAnalyzeEnvVarUsers walks EdgeReadsConfig edges restricted to
// environment-variable config keys and groups by the variable. Each
// row carries the env var name and the symbols that read it — the
// answer to "which functions depend on DATABASE_URL?" and which env
// vars are the most load-bearing.
//
// Filters: name (env var, case-insensitive), limit.
func (s *Server) handleAnalyzeEnvVarUsers(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
type envRow struct {
ID string `json:"id"`
Name string `json:"name"`
Readers []string `json:"readers,omitempty"`
Reads int `json:"reads"`
}
byKey := map[string]*envRow{}
for e := range edgesByKinds(s.graph, graph.EdgeReadsConfig) {
row, ok := byKey[e.To]
if !ok {
n := s.graph.GetNode(e.To)
if !isEnvConfigKey(n) {
continue
}
if nameFilter != "" && strings.ToLower(n.Name) != nameFilter {
continue
}
row = &envRow{ID: e.To, Name: n.Name}
byKey[e.To] = row
}
row.Reads++
row.Readers = appendUnique(row.Readers, e.From)
}
rows := make([]*envRow, 0, len(byKey))
for _, r := range byKey {
sort.Strings(r.Readers)
rows = append(rows, r)
}
// Scope filter: keep an env-var row iff the key node is visible, and
// prune readers to visible nodes only. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*envRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
continue
}
readers := make([]string, 0, len(r.Readers))
for _, id := range r.Readers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
readers = append(readers, id)
}
}
r.Readers = readers
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Reads != rows[j].Reads {
return rows[i].Reads > rows[j].Reads
}
return rows[i].Name < rows[j].Name
})
truncated := false
if nameFilter == "" && len(rows) > limit {
rows = rows[:limit]
truncated = true
}
if s.isGCX(ctx, req) {
return s.gcxResponseWithBudget(req)(encodeAnalyze("env_var_users", rows))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d %s\n", r.Reads, r.Name)
}
if truncated {
fmt.Fprintf(&b, "... truncated to %d\n", limit)
}
if len(rows) == 0 {
b.WriteString("no env var users\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"env_vars": rows,
"total": len(rows),
"truncated": truncated,
})
}
// ---------------------------------------------------------------------------
// event_emitters — list event nodes with their emitters.
// ---------------------------------------------------------------------------
// handleAnalyzeEventEmitters walks EdgeEmits edges and groups by
// event target. The `level` filter narrows by log level when meta
// carries it (error/warn/info/debug); without a level filter every
// event is included. Useful for "every site that logs an error" or
// "what does this metric get emitted from".
//
// Filters:
//
// - level: log/event level — error, warn, warning, info, debug,
// trace, fatal. Case-insensitive. The matcher checks both
// `level` and the `event_kind`/`method` meta keys so it works
// across the parsers that use different conventions.
// - name: event name (case-insensitive). Returns the emitters
// for that single event.
func (s *Server) handleAnalyzeEventEmitters(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
levelFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "level")))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
type eventRow struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"event_kind,omitempty"`
Emits int `json:"emits"`
Emitters []string `json:"emitters,omitempty"`
}
byEvent := map[string]*eventRow{}
for e := range edgesByKinds(s.graph, graph.EdgeEmits) {
// Level filter: an emit edge stores the method on the edge
// (e.g. "Errorf"); the event node may carry an event_kind.
// We accept either source so both per-event and per-call
// taxonomies match.
if levelFilter != "" {
method, _ := e.Meta["method"].(string)
if !levelMatches(levelFilter, method) {
n := s.graph.GetNode(e.To)
if n == nil {
continue
}
kind, _ := n.Meta["event_kind"].(string)
if !levelMatches(levelFilter, kind) {
continue
}
}
}
row, ok := byEvent[e.To]
if !ok {
n := s.graph.GetNode(e.To)
name := ""
kind := ""
if n != nil {
name = n.Name
kind, _ = n.Meta["event_kind"].(string)
}
if nameFilter != "" && strings.ToLower(name) != nameFilter {
continue
}
row = &eventRow{ID: e.To, Name: name, Kind: kind}
byEvent[e.To] = row
}
row.Emits++
row.Emitters = appendUnique(row.Emitters, e.From)
}
rows := make([]*eventRow, 0, len(byEvent))
for _, r := range byEvent {
sort.Strings(r.Emitters)
rows = append(rows, r)
}
// Scope filter: keep an event row iff the event node is visible, and
// prune emitters to visible nodes only. No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*eventRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
continue
}
emitters := make([]string, 0, len(r.Emitters))
for _, id := range r.Emitters {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
emitters = append(emitters, id)
}
}
r.Emitters = emitters
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Emits != rows[j].Emits {
return rows[i].Emits > rows[j].Emits
}
return rows[i].ID < rows[j].ID
})
if s.isGCX(ctx, req) {
items := make([]eventEmitterItem, 0, len(rows))
for _, r := range rows {
items = append(items, eventEmitterItem{
ID: r.ID,
Name: r.Name,
Kind: r.Kind,
Emits: r.Emits,
Emitters: strings.Join(r.Emitters, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("event_emitters", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
kind := r.Kind
if kind == "" {
kind = "?"
}
fmt.Fprintf(&b, "%-3d [%s] %s\n", r.Emits, kind, r.Name)
}
if len(rows) == 0 {
b.WriteString("no event emitters\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"events": rows,
"total": len(rows),
})
}
// ---------------------------------------------------------------------------
// pubsub — event pub/sub topics with their publishers and subscribers.
// ---------------------------------------------------------------------------
// handleAnalyzePubsub walks the EdgeEmits (publish) and EdgeListensOn
// (subscribe) edges of the event pub/sub layer and groups them by
// topic node. Each row is one KindEvent topic (Meta["event_kind"]=
// "pubsub"): its transport (nats|kafka|rabbitmq|redis|socketio|
// eventemitter|unknown), the count + symbol list of publishers, and
// the count + symbol list of subscribers. Lets agents answer "who
// publishes order.created and who listens for it" with one graph walk,
// and spot orphan topics — published with no subscriber, or subscribed
// with no publisher.
//
// Filters: `transport` (one broker label), `name` (exact topic name),
// `role` (publish | subscribe — keep only topics with at least one
// edge of that side).
func (s *Server) handleAnalyzePubsub(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
transportFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "transport")))
nameFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "name")))
roleFilter := strings.ToLower(strings.TrimSpace(stringArg(args, "role")))
type pubsubRow struct {
ID string `json:"id"`
Name string `json:"name"`
Transport string `json:"transport,omitempty"`
Publishes int `json:"publishes"`
Subscribes int `json:"subscribes"`
Publishers []string `json:"publishers,omitempty"`
Subscribers []string `json:"subscribers,omitempty"`
}
byTopic := map[string]*pubsubRow{}
// rejected caches topic nodes that failed the transport/name
// filter (or aren't pub/sub events) so a topic with many edges
// isn't re-checked per edge.
rejected := map[string]struct{}{}
ensureRow := func(nodeID string) *pubsubRow {
if row, ok := byTopic[nodeID]; ok {
return row
}
if _, ok := rejected[nodeID]; ok {
return nil
}
n := s.graph.GetNode(nodeID)
if n == nil || n.Meta == nil {
rejected[nodeID] = struct{}{}
return nil
}
if kind, _ := n.Meta["event_kind"].(string); kind != "pubsub" {
rejected[nodeID] = struct{}{}
return nil
}
transport, _ := n.Meta["transport"].(string)
if transportFilter != "" && strings.ToLower(transport) != transportFilter {
rejected[nodeID] = struct{}{}
return nil
}
if nameFilter != "" && strings.ToLower(n.Name) != nameFilter {
rejected[nodeID] = struct{}{}
return nil
}
row := &pubsubRow{ID: nodeID, Name: n.Name, Transport: transport}
byTopic[nodeID] = row
return row
}
for e := range edgesByKinds(s.graph, graph.EdgeEmits, graph.EdgeListensOn) {
switch e.Kind {
case graph.EdgeEmits:
row := ensureRow(e.To)
if row == nil {
continue
}
row.Publishes++
row.Publishers = appendUnique(row.Publishers, e.From)
case graph.EdgeListensOn:
row := ensureRow(e.To)
if row == nil {
continue
}
row.Subscribes++
row.Subscribers = appendUnique(row.Subscribers, e.From)
}
}
rows := make([]*pubsubRow, 0, len(byTopic))
for _, r := range byTopic {
switch roleFilter {
case "publish":
if r.Publishes == 0 {
continue
}
case "subscribe":
if r.Subscribes == 0 {
continue
}
}
sort.Strings(r.Publishers)
sort.Strings(r.Subscribers)
rows = append(rows, r)
}
// Scope filter: keep a topic row iff the topic node is visible, and
// prune publisher/subscriber lists to visible nodes only. No-op when
// unbound. Publishes/Subscribes are edge counts, left as-is.
if s.scopeFiltersActive(ctx) {
kept := make([]*pubsubRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.ID)) {
continue
}
publishers := make([]string, 0, len(r.Publishers))
for _, id := range r.Publishers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
publishers = append(publishers, id)
}
}
r.Publishers = publishers
subscribers := make([]string, 0, len(r.Subscribers))
for _, id := range r.Subscribers {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
subscribers = append(subscribers, id)
}
}
r.Subscribers = subscribers
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
ti := rows[i].Publishes + rows[i].Subscribes
tj := rows[j].Publishes + rows[j].Subscribes
if ti != tj {
return ti > tj
}
return rows[i].ID < rows[j].ID
})
if s.isGCX(ctx, req) {
items := make([]pubsubItem, 0, len(rows))
for _, r := range rows {
items = append(items, pubsubItem{
ID: r.ID,
Name: r.Name,
Transport: r.Transport,
Publishes: r.Publishes,
Subscribes: r.Subscribes,
Publishers: strings.Join(r.Publishers, ","),
Subscribers: strings.Join(r.Subscribers, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("pubsub", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
transport := r.Transport
if transport == "" {
transport = "?"
}
fmt.Fprintf(&b, "%dP/%dS [%s] %s\n", r.Publishes, r.Subscribes, transport, r.Name)
}
if len(rows) == 0 {
b.WriteString("no pub/sub topics\n")
}
return mcp.NewToolResultText(b.String()), nil
}
return s.respondJSONOrTOON(ctx, req, map[string]any{
"topics": rows,
"total": len(rows),
})
}
// ---------------------------------------------------------------------------
// error_surface — function/method nodes with their EdgeThrows
// targets.
// ---------------------------------------------------------------------------
// handleAnalyzeErrorSurface walks EdgeThrows edges and groups by
// thrower (the From side — the function/method that returns or
// raises the error). Each row lists the error types it can produce.
// Useful for sketching the error surface of a package, for finding
// functions that throw too many distinct error kinds, and for
// confirming that a refactor didn't widen the error surface
// inadvertently.
//
// Filters:
//
// - path_prefix: scope to throwers under a directory subtree.
func (s *Server) handleAnalyzeErrorSurface(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pathPrefix := strings.TrimSpace(stringArg(req.GetArguments(), "path_prefix"))
type throwerRow struct {
Symbol string `json:"symbol"`
File string `json:"file"`
Line int `json:"line"`
Throws int `json:"throws"`
Errors []string `json:"errors"`
ErrorMsgs []string `json:"error_msgs,omitempty"`
}
rows := make([]*throwerRow, 0)
if surfacer, ok := s.graph.(graph.ThrowerErrorSurfacer); ok {
// Server-side path: one server-side aggregate for the per-thrower
// throws+targets dedup, one for the per-thrower error-msg
// attachment. No per-thrower GetOutEdges fanout.
for _, r := range surfacer.ThrowerErrorSurface(pathPrefix) {
row := &throwerRow{
Symbol: r.ThrowerID,
File: r.FilePath,
Line: r.Line,
Throws: r.Throws,
Errors: append([]string(nil), r.ErrorTargets...),
ErrorMsgs: append([]string(nil), r.ErrorMsgs...),
}
sort.Strings(row.Errors)
sort.Strings(row.ErrorMsgs)
rows = append(rows, row)
}
} else {
byThrower := map[string]*throwerRow{}
for e := range edgesByKinds(s.graph, graph.EdgeThrows) {
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
continue
}
row, ok := byThrower[e.From]
if !ok {
n := s.graph.GetNode(e.From)
file := e.FilePath
line := e.Line
if n != nil {
if file == "" {
file = n.FilePath
}
if line == 0 {
line = n.StartLine
}
}
row = &throwerRow{Symbol: e.From, File: file, Line: line}
byThrower[e.From] = row
}
row.Throws++
row.Errors = appendUnique(row.Errors, e.To)
}
// For every thrower, also surface the error_msg KindString
// literals it emits. EdgeThrows targets error types; the
// data-side companion (errors.New("…") → string::error_msg::…)
// carries the literal message.
for thrower, row := range byThrower {
for _, e := range s.graph.GetOutEdges(thrower) {
if e == nil || e.Kind != graph.EdgeEmits {
continue
}
n := s.graph.GetNode(e.To)
if n == nil || n.Kind != graph.KindString {
continue
}
ctxLabel, _ := n.Meta["context"].(string)
if ctxLabel != "error_msg" {
continue
}
row.ErrorMsgs = appendUnique(row.ErrorMsgs, n.Name)
}
}
for _, r := range byThrower {
sort.Strings(r.Errors)
sort.Strings(r.ErrorMsgs)
rows = append(rows, r)
}
}
// Scope filter: keep a thrower row iff the throwing symbol is visible,
// and prune the error-target list to visible nodes only. Error message
// literals (ErrorMsgs) are string values, not node ids, left intact.
// Applies to both the ThrowerErrorSurfacer and fallback build paths.
// No-op when unbound.
if s.scopeFiltersActive(ctx) {
kept := make([]*throwerRow, 0, len(rows))
for _, r := range rows {
if !s.analyzeNodeVisible(ctx, s.graph.GetNode(r.Symbol)) {
continue
}
errs := make([]string, 0, len(r.Errors))
for _, id := range r.Errors {
if s.analyzeNodeVisible(ctx, s.graph.GetNode(id)) {
errs = append(errs, id)
}
}
r.Errors = errs
kept = append(kept, r)
}
rows = kept
}
sort.Slice(rows, func(i, j int) bool {
// Throwers with the most distinct error targets surface
// first — those are the highest-leverage refactor candidates.
ai, aj := len(rows[i].Errors), len(rows[j].Errors)
if ai != aj {
return ai > aj
}
if rows[i].Throws != rows[j].Throws {
return rows[i].Throws > rows[j].Throws
}
return rows[i].Symbol < rows[j].Symbol
})
// Cap response size — large repos blow past the MCP token cap on
// the JSON shape. Default 200 keeps the response useful while
// callers that need everything pass an explicit limit.
limit := 200
if v, ok := req.GetArguments()["limit"].(float64); ok && v > 0 {
limit = int(v)
}
totalRows := len(rows)
rowsTruncated := false
if len(rows) > limit {
rows = rows[:limit]
rowsTruncated = true
}
if s.isGCX(ctx, req) {
items := make([]errorSurfaceItem, 0, len(rows))
for _, r := range rows {
items = append(items, errorSurfaceItem{
Symbol: r.Symbol,
File: r.File,
Line: r.Line,
Throws: r.Throws,
Errors: strings.Join(r.Errors, ","),
ErrorMsgs: strings.Join(r.ErrorMsgs, "\x1f"),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("error_surface", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-3d %s:%d %s -> %s\n",
len(r.Errors), r.File, r.Line, r.Symbol, strings.Join(r.Errors, ","))
}
if len(rows) == 0 {
b.WriteString("no error surface\n")
}
return mcp.NewToolResultText(b.String()), nil
}
resp := map[string]any{
"throwers": rows,
"total": totalRows,
"truncated": rowsTruncated,
}
if rowsTruncated {
resp["limit"] = limit
}
return s.respondJSONOrTOON(ctx, req, resp)
}
// ---------------------------------------------------------------------------
// cross_repo — list repo-boundary-crossing call / type-hierarchy edges.
// ---------------------------------------------------------------------------
// handleAnalyzeCrossRepo walks the cross_repo_* edge layer (M3) and
// groups edges by (source repo, target repo, base relation). Each row
// is one directed dependency between two repos — "repo A's code calls
// into repo B", "a type in repo A implements an interface in repo B" —
// with a count and a capped sample of the underlying edges.
//
// The cross_repo_* edges are materialised by the resolver's
// DetectCrossRepoEdges pass; this analyzer is a pure read over that
// graph layer, so it stays correct under incremental reindex without
// re-deriving the repo-boundary test here.
//
// Filters:
//
// - path_prefix: scope to edges anchored in a directory subtree
// (matches the call/declaration site's file path).
// - repo: scope to dependencies that touch a repo on either side
// (source or target).
// - kind: scope to one base relation — "calls", "implements", or
// "extends".
func (s *Server) handleAnalyzeCrossRepo(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := req.GetArguments()
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
repoFilter := strings.TrimSpace(stringArg(args, "repo"))
// The `kind` arg is consumed by the analyze dispatcher to route
// here; an optional base-relation filter rides on `base_kind`
// (one of calls / implements / extends).
kindFilter := strings.TrimSpace(stringArg(args, "base_kind"))
type crossRepoRow struct {
FromRepo string `json:"from_repo"`
ToRepo string `json:"to_repo"`
Kind string `json:"kind"`
Count int `json:"count"`
Samples []string `json:"samples,omitempty"`
}
byKey := map[string]*crossRepoRow{}
// repoOf prefers the live node's RepoPrefix and falls back to the
// edge Meta stamped at materialisation time — Meta is dropped by
// snapshot round-trips, so the node lookup is the durable path.
repoOf := func(id, metaKey string, e *graph.Edge) string {
if n := s.graph.GetNode(id); n != nil && n.RepoPrefix != "" {
return n.RepoPrefix
}
if e.Meta != nil {
if r, _ := e.Meta[metaKey].(string); r != "" {
return r
}
}
return ""
}
for e := range edgesByKinds(s.graph,
graph.EdgeCrossRepoCalls,
graph.EdgeCrossRepoImplements,
graph.EdgeCrossRepoExtends,
) {
base, ok := graph.BaseKindForCrossRepo(e.Kind)
if !ok {
continue
}
if pathPrefix != "" && !strings.HasPrefix(e.FilePath, pathPrefix) {
continue
}
if kindFilter != "" && string(base) != kindFilter {
continue
}
fromRepo := repoOf(e.From, "source_repo", e)
toRepo := repoOf(e.To, "target_repo", e)
if repoFilter != "" && fromRepo != repoFilter && toRepo != repoFilter {
continue
}
key := fromRepo + "\x00" + toRepo + "\x00" + string(base)
row, ok := byKey[key]
if !ok {
row = &crossRepoRow{FromRepo: fromRepo, ToRepo: toRepo, Kind: string(base)}
byKey[key] = row
}
row.Count++
// Cap the per-row sample list — a hot repo pair can have
// thousands of edges and the samples are only an orientation
// aid, not the payload.
if len(row.Samples) < 20 {
row.Samples = appendUnique(row.Samples, e.From+" -> "+e.To)
}
}
rows := make([]*crossRepoRow, 0, len(byKey))
for _, r := range byKey {
sort.Strings(r.Samples)
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
if rows[i].FromRepo != rows[j].FromRepo {
return rows[i].FromRepo < rows[j].FromRepo
}
if rows[i].ToRepo != rows[j].ToRepo {
return rows[i].ToRepo < rows[j].ToRepo
}
return rows[i].Kind < rows[j].Kind
})
limit := 200
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
totalRows := len(rows)
rowsTruncated := false
if len(rows) > limit {
rows = rows[:limit]
rowsTruncated = true
}
if s.isGCX(ctx, req) {
items := make([]crossRepoItem, 0, len(rows))
for _, r := range rows {
items = append(items, crossRepoItem{
FromRepo: r.FromRepo,
ToRepo: r.ToRepo,
Kind: r.Kind,
Count: r.Count,
Samples: strings.Join(r.Samples, ","),
})
}
return s.gcxResponseWithBudget(req)(encodeAnalyze("cross_repo", items))
}
if isCompact(req) {
var b strings.Builder
for _, r := range rows {
fmt.Fprintf(&b, "%-4d %s -> %s (%s)\n", r.Count, r.FromRepo, r.ToRepo, r.Kind)
}
if len(rows) == 0 {
b.WriteString("no cross-repo edges\n")
}
return mcp.NewToolResultText(b.String()), nil
}
resp := map[string]any{
"dependencies": rows,
"total": totalRows,
"truncated": rowsTruncated,
}
if rowsTruncated {
resp["limit"] = limit
}
return s.respondJSONOrTOON(ctx, req, resp)
}
// ---------------------------------------------------------------------------
// shared helpers
// ---------------------------------------------------------------------------
// edgesByKinds streams every edge whose Kind is in the supplied set
// using the EdgesByKindsScanner capability when the backend
// implements it (one round-trip with a `kind IN (…)` filter), or
// falls back to per-kind EdgesByKind iteration otherwise.
//
// The edge-driven analyzers below use it instead of `for _, e := range
// s.graph.AllEdges() { switch e.Kind … }` so the disk backends stop
// materialising the full edge table over cgo for a handful of kinds.
// Pass each kind as a separate argument — kinds typed inline as a
// variadic so call sites read as `edgesByKinds(g, EdgeEmits,
// EdgeListensOn)` rather than constructing a slice each time.
//
// Empty kinds yields nothing — matches both the capability contract
// and the original semantics (no kinds requested means no rows).
func edgesByKinds(g graph.Store, kinds ...graph.EdgeKind) iter.Seq[*graph.Edge] {
if len(kinds) == 0 {
return func(yield func(*graph.Edge) bool) {}
}
if scanner, ok := g.(graph.EdgesByKindsScanner); ok {
return scanner.EdgesByKinds(kinds)
}
return func(yield func(*graph.Edge) bool) {
for _, k := range kinds {
if k == "" {
continue
}
for e := range g.EdgesByKind(k) {
if !yield(e) {
return
}
}
}
}
}
// appendUnique returns dst with v added if not already present.
// Used by every analyzer above to dedupe the From-side caller list
// without falling back to a map (the lists are small per row, so a
// linear scan is cheaper than building a set per group).
func appendUnique(dst []string, v string) []string {
if v == "" {
return dst
}
if slices.Contains(dst, v) {
return dst
}
return append(dst, v)
}
// levelMatches returns true when the requested level matches the
// candidate string. The match is case-insensitive and accepts
// common aliases (warn/warning, debug/trace) so callers can pass
// either the canonical level or the parser's verbatim method/kind
// without ceremony.
func levelMatches(want, candidate string) bool {
candidate = strings.ToLower(candidate)
if candidate == "" {
return false
}
if strings.Contains(candidate, want) {
return true
}
switch want {
case "warn", "warning":
return strings.Contains(candidate, "warn")
case "info":
return strings.Contains(candidate, "info")
case "debug", "trace":
return strings.Contains(candidate, "debug") || strings.Contains(candidate, "trace")
case "error":
return strings.Contains(candidate, "error") || strings.Contains(candidate, "err")
case "fatal":
return strings.Contains(candidate, "fatal") || strings.Contains(candidate, "panic")
}
return false
}