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
304 lines
10 KiB
Go
304 lines
10 KiB
Go
package mcp
|
||
|
||
import (
|
||
"context"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/mark3labs/mcp-go/mcp"
|
||
"github.com/zzet/gortex/internal/graph"
|
||
)
|
||
|
||
// registerCheckReferencesTool wires check_references — a one-call
|
||
// composite that answers "is X used anywhere?" with a unified verdict
|
||
// and the underlying evidence. Pairs with safe_delete_symbol (N26 ✅)
|
||
// as the pre-delete sanity check: agents call check_references first;
|
||
// when referenced=false they know the delete is safe; when true the
|
||
// evidence list tells them what to update first.
|
||
//
|
||
// Composes three signals from the graph:
|
||
// - in_edges_by_kind: every incoming dependency edge grouped by
|
||
// kind (calls, implements, extends, references, instantiates, ...)
|
||
// - same_name_elsewhere: other symbols sharing the literal name in
|
||
// different files — useful when the named symbol *isn't* in the
|
||
// graph (e.g. removed during refactor) or when an extractor missed
|
||
// a binding edge
|
||
// - importing_files: files importing the file the symbol lives in
|
||
// — answers "does anyone consume this package at all?"
|
||
func (s *Server) registerCheckReferencesTool() {
|
||
s.addTool(
|
||
mcp.NewTool("check_references",
|
||
mcp.WithDescription("Unified 'is this symbol used?' verdict. Returns {referenced, total_references, by_kind, callers, same_name_elsewhere, importing_files, evidence}. Use either `symbol_id` (preferred — anchored on the actual graph node) or `name` (when the node was removed but a literal-name check is still useful). Composes find_usages + name lookup + file-import scan into one call so agents don't have to chain three tools before deciding whether a delete or rename is safe."),
|
||
mcp.WithString("symbol_id", mcp.Description("Symbol ID to check (preferred). When provided, scans every in-edge kind plus same-name nodes in other files plus importing files of the symbol's home file.")),
|
||
mcp.WithString("name", mcp.Description("Symbol name to check by literal lookup (used when symbol_id is empty). Falls back to a graph-wide same-name scan.")),
|
||
mcp.WithBoolean("exclude_tests", mcp.Description("Drop references whose origin file path looks like a test (_test.go, .test.ts, __tests__/, test_*). Default false.")),
|
||
mcp.WithNumber("evidence_limit", mcp.Description("Cap on the evidence row count (default: 50).")),
|
||
mcp.WithString("min_tier", mcp.Description("Minimum edge confidence tier (lsp_resolved / lsp_dispatch / ast_resolved / ast_inferred / text_matched). Empty = no filter.")),
|
||
mcp.WithString("format", mcp.Description("Output format: json (default), gcx, or toon")),
|
||
),
|
||
s.handleCheckReferences,
|
||
)
|
||
}
|
||
|
||
type checkRefsEvidence struct {
|
||
FromID string `json:"from_id"`
|
||
FromName string `json:"from_name,omitempty"`
|
||
FilePath string `json:"file_path,omitempty"`
|
||
Line int `json:"line,omitempty"`
|
||
Kind string `json:"kind"`
|
||
Tier string `json:"tier,omitempty"`
|
||
}
|
||
|
||
type checkRefsSameName struct {
|
||
ID string `json:"id"`
|
||
FilePath string `json:"file_path"`
|
||
Kind string `json:"kind"`
|
||
}
|
||
|
||
func (s *Server) handleCheckReferences(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||
symbolID := strings.TrimSpace(req.GetString("symbol_id", ""))
|
||
name := strings.TrimSpace(req.GetString("name", ""))
|
||
if symbolID == "" && name == "" {
|
||
return mcp.NewToolResultError("check_references requires `symbol_id` or `name`"), nil
|
||
}
|
||
excludeTests := req.GetBool("exclude_tests", false)
|
||
evidenceLimit := max(req.GetInt("evidence_limit", 50), 1)
|
||
minTier := strings.TrimSpace(req.GetString("min_tier", ""))
|
||
|
||
var target *graph.Node
|
||
if symbolID != "" {
|
||
target = s.graph.GetNode(symbolID)
|
||
}
|
||
|
||
// If name wasn't given explicitly, derive it from the resolved
|
||
// symbol so the same-name scan still runs.
|
||
if name == "" && target != nil {
|
||
name = target.Name
|
||
}
|
||
|
||
byKind := map[string]int{}
|
||
evidence := make([]checkRefsEvidence, 0, evidenceLimit)
|
||
callers := map[string]bool{}
|
||
totalEdges := 0
|
||
if target != nil {
|
||
// Pre-filter the in-edges and batch-fetch the surviving
|
||
// `From` nodes in one round-trip. On a disk backend the per-edge
|
||
// GetNode pattern was a round-trip per inbound edge —
|
||
// for heavily-referenced symbols (hundreds of callers) the
|
||
// cost was dominant. One GetNodesByIDs gives us the same
|
||
// data in a single bulk query.
|
||
inEdges := s.graph.GetInEdges(target.ID)
|
||
fromIDs := make([]string, 0, len(inEdges))
|
||
seenFrom := make(map[string]struct{}, len(inEdges))
|
||
for _, e := range inEdges {
|
||
if !isCheckRefEdge(e.Kind) {
|
||
continue
|
||
}
|
||
if minTier != "" && !atOrAboveTier(string(e.Origin), minTier) {
|
||
continue
|
||
}
|
||
if _, dup := seenFrom[e.From]; dup {
|
||
continue
|
||
}
|
||
seenFrom[e.From] = struct{}{}
|
||
fromIDs = append(fromIDs, e.From)
|
||
}
|
||
fromByID := s.graph.GetNodesByIDs(fromIDs)
|
||
|
||
for _, e := range inEdges {
|
||
if !isCheckRefEdge(e.Kind) {
|
||
continue
|
||
}
|
||
if minTier != "" && !atOrAboveTier(string(e.Origin), minTier) {
|
||
continue
|
||
}
|
||
from := fromByID[e.From]
|
||
if from != nil && excludeTests && isTestPath(from.FilePath) {
|
||
continue
|
||
}
|
||
byKind[string(e.Kind)]++
|
||
totalEdges++
|
||
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeCrossRepoCalls {
|
||
callers[e.From] = true
|
||
}
|
||
if len(evidence) < evidenceLimit {
|
||
ev := checkRefsEvidence{FromID: e.From, Kind: string(e.Kind), Tier: string(e.Origin)}
|
||
if from != nil {
|
||
ev.FromName = from.Name
|
||
ev.FilePath = from.FilePath
|
||
ev.Line = from.StartLine
|
||
}
|
||
// Prefer the edge's own call-site line / file when
|
||
// they're populated. Two distinct calls from the
|
||
// same caller used to collapse onto the caller's
|
||
// start line — same shape as the find_usages bug
|
||
// fixed earlier; the evidence builder owns its own
|
||
// copy of that pattern.
|
||
if e.Line > 0 {
|
||
ev.Line = e.Line
|
||
}
|
||
if e.FilePath != "" {
|
||
ev.FilePath = e.FilePath
|
||
}
|
||
evidence = append(evidence, ev)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Same-name scan — other graph nodes with the same Name living
|
||
// in a different file. Useful when the graph hasn't materialised
|
||
// a binding edge but the literal identifier is in use.
|
||
sameName := []checkRefsSameName{}
|
||
if name != "" {
|
||
excludePath := ""
|
||
if target != nil {
|
||
excludePath = target.FilePath
|
||
}
|
||
for _, n := range s.scopedNodes(ctx) {
|
||
if n.Name != name {
|
||
continue
|
||
}
|
||
if target != nil && n.ID == target.ID {
|
||
continue
|
||
}
|
||
if excludePath != "" && n.FilePath == excludePath {
|
||
continue
|
||
}
|
||
if excludeTests && isTestPath(n.FilePath) {
|
||
continue
|
||
}
|
||
sameName = append(sameName, checkRefsSameName{
|
||
ID: n.ID, FilePath: n.FilePath, Kind: string(n.Kind),
|
||
})
|
||
}
|
||
}
|
||
|
||
// Importing-files scan — every file whose nodes carry an
|
||
// EdgeImports edge into the target's FilePath. Backends that
|
||
// implement graph.FileImporters serve this from one query
|
||
// (no AllEdges() materialisation, no per-edge GetNode round-
|
||
// trip). The legacy AllEdges + per-edge GetNode loop stays as
|
||
// the fallback for backends that don't ship the capability.
|
||
importingFiles := s.collectImportingFiles(target, excludeTests)
|
||
|
||
referenced := totalEdges > 0 || len(sameName) > 0 || len(importingFiles) > 0
|
||
|
||
return s.respondJSONOrTOON(ctx, req, map[string]any{
|
||
"referenced": referenced,
|
||
"total_references": totalEdges,
|
||
"by_kind": byKind,
|
||
"caller_count": len(callers),
|
||
"same_name_elsewhere": sameName,
|
||
"importing_files": importingFiles,
|
||
"evidence": evidence,
|
||
"symbol_id": symbolID,
|
||
"name": name,
|
||
"excluded_tests": excludeTests,
|
||
})
|
||
}
|
||
|
||
// collectImportingFiles answers "which files import the file that
|
||
// holds target?". Prefers the graph.FileImporters capability when
|
||
// the backend implements it — that path runs one query
|
||
// instead of an AllEdges() scan plus 2× per-edge GetNode round-trip.
|
||
// Returns a sorted, deduplicated, optionally test-filtered slice
|
||
// of file paths.
|
||
//
|
||
// When target is nil or has no FilePath the question is undefined;
|
||
// returns an empty slice (consistent with the legacy behaviour).
|
||
func (s *Server) collectImportingFiles(target *graph.Node, excludeTests bool) []string {
|
||
importingFiles := []string{}
|
||
if target == nil || target.FilePath == "" {
|
||
return importingFiles
|
||
}
|
||
seen := map[string]bool{}
|
||
add := func(fromFile string) {
|
||
if fromFile == "" {
|
||
return
|
||
}
|
||
if excludeTests && isTestPath(fromFile) {
|
||
return
|
||
}
|
||
if seen[fromFile] {
|
||
return
|
||
}
|
||
seen[fromFile] = true
|
||
importingFiles = append(importingFiles, fromFile)
|
||
}
|
||
|
||
if fi, ok := s.graph.(graph.FileImporters); ok {
|
||
for _, row := range fi.FileImporters(target.FilePath) {
|
||
add(row.FromFile)
|
||
}
|
||
sort.Strings(importingFiles)
|
||
return importingFiles
|
||
}
|
||
|
||
// Fallback: pull every edge and filter Go-side. Identical
|
||
// pre-capability behaviour — only the cgo-heavy backend ever
|
||
// reaches this path.
|
||
for _, e := range s.graph.AllEdges() {
|
||
if e.Kind != graph.EdgeImports {
|
||
continue
|
||
}
|
||
toNode := s.graph.GetNode(e.To)
|
||
if toNode == nil {
|
||
continue
|
||
}
|
||
if toNode.FilePath != target.FilePath && toNode.ID != target.FilePath {
|
||
continue
|
||
}
|
||
fromNode := s.graph.GetNode(e.From)
|
||
if fromNode == nil {
|
||
continue
|
||
}
|
||
add(fromNode.FilePath)
|
||
}
|
||
sort.Strings(importingFiles)
|
||
return importingFiles
|
||
}
|
||
|
||
// isCheckRefEdge identifies edges that mean "this symbol is being
|
||
// used". Mirrors safe_delete_symbol's referencing-edge filter so
|
||
// the two tools agree on what "referenced" means.
|
||
func isCheckRefEdge(k graph.EdgeKind) bool {
|
||
switch k {
|
||
case graph.EdgeCalls,
|
||
graph.EdgeImplements,
|
||
graph.EdgeExtends,
|
||
graph.EdgeReferences,
|
||
graph.EdgeInstantiates,
|
||
graph.EdgeCrossRepoCalls,
|
||
graph.EdgeCrossRepoImplements,
|
||
graph.EdgeCrossRepoExtends:
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// atOrAboveTier returns true when `actual` >= `min` in the
|
||
// 5-tier confidence ladder. The ladder, highest to lowest:
|
||
//
|
||
// lsp_resolved → lsp_dispatch → ast_resolved → ast_inferred → text_matched
|
||
//
|
||
// Empty `actual` is treated as the lowest tier (text_matched) so old
|
||
// edges produced before tier-stamping landed don't get dropped.
|
||
func atOrAboveTier(actual, min string) bool {
|
||
rank := func(t string) int {
|
||
switch strings.ToLower(t) {
|
||
case "lsp_resolved", "lspresolved":
|
||
return 5
|
||
case "lsp_dispatch", "lspdispatch":
|
||
return 4
|
||
case "ast_resolved", "astresolved":
|
||
return 3
|
||
case "ast_inferred", "astinferred":
|
||
return 2
|
||
case "", "text_matched", "textmatched":
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
return rank(actual) >= rank(min)
|
||
}
|