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

304 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}