Skip to content
49 changes: 49 additions & 0 deletions internal/mcp/rename_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,55 @@ type renameEdit struct {
Reason string `json:"reason"`
}

// unindexedRenameRecovery distinguishes an invalid symbol ID from an existing
// file that semantic rename cannot see. It returns guidance only: without graph
// usage edges, automatically changing even one occurrence would imply a
// completeness guarantee the rename cannot make.
func (s *Server) unindexedRenameRecovery(ctx context.Context, id, newName string, dryRun bool) map[string]any {
filePart, requestedSymbol, ok := strings.Cut(id, "::")
if !ok || strings.TrimSpace(filePart) == "" || strings.TrimSpace(requestedSymbol) == "" {
return nil
}

absPath, relPath, err := s.resolveFilePath(filePart)
if err != nil {
return nil
}
info, err := os.Stat(absPath)
if err != nil || !info.Mode().IsRegular() {
return nil
}

file := s.graphPathSpelling(relPath)
if indexed := s.engineFor(ctx).GetFileSymbols(file); indexed != nil && indexed.TotalNodes > 0 {
return nil
}

return map[string]any{
"status": "refused",
"error_code": "symbol_not_indexed",
"symbol_id": id,
"requested_symbol": requestedSymbol,
"new_name": newName,
"file": file,
"semantic_rename_complete": false,
"written": false,
"dry_run": dryRun,
"safe_fallback": map[string]any{
"tool": "edit",
"operation": "file",
"target": map[string]any{"file": file},
"required_guards": []string{
"explicit file target",
"whole-identifier match",
"expected_occurrences",
"base_sha",
},
},
"warning": "Cross-file references are not proven because this file is outside the graph. No text was changed.",
}
}

// indexIdentifier returns the byte offset of the first whole-identifier
// occurrence of name at or after from, or -1 when there is none. A candidate
// is whole only when neither neighbouring rune is an identifier rune, so
Expand Down
35 changes: 35 additions & 0 deletions internal/mcp/rename_recovery_facade_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package mcp

import (
"context"
"os"
"path/filepath"
"testing"

mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
)

func TestRenameFacade_UnindexedRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Late() {}\n"
path := filepath.Join(dir, "late.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

req := mcplib.CallToolRequest{}
req.Params.Name = "refactor"
req.Params.Arguments = map[string]any{
"operation": "rename",
"target": map[string]any{"symbol": "late.go::Late"},
"new_name": "Renamed",
}
res, err := srv.handleFacade(context.Background(), "refactor", req)
require.NoError(t, err)
resp := decodeRenameResp(t, res)
require.Equal(t, "symbol_not_indexed", resp["error_code"])
require.Equal(t, false, resp["written"])

got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}
75 changes: 75 additions & 0 deletions internal/mcp/rename_recovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package mcp

import (
"context"
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"
)

func TestRenameSymbol_UnindexedRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Late() {}\n"
path := filepath.Join(dir, "late.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))

for _, dryRun := range []bool{false, true} {
res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "late.go::Late", "new_name": "Renamed", "dry_run": dryRun,
})
resp := decodeRenameResp(t, res)
require.Equal(t, "refused", resp["status"])
require.Equal(t, "symbol_not_indexed", resp["error_code"])
require.Equal(t, false, resp["semantic_rename_complete"])
require.Equal(t, false, resp["written"])
require.Equal(t, dryRun, resp["dry_run"])
require.Contains(t, resp["warning"], "Cross-file references are not proven")

fallback := resp["safe_fallback"].(map[string]any)
require.Equal(t, "edit", fallback["tool"])
require.Equal(t, "file", fallback["operation"])
require.Equal(t, "late.go", fallback["target"].(map[string]any)["file"])
require.ElementsMatch(t, []any{
"explicit file target", "whole-identifier match", "expected_occurrences", "base_sha",
}, fallback["required_guards"])

got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}
}

func TestRenameSymbol_IgnoredRecovery(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
const source = "package main\n\nfunc Ignored() {}\n"
path := filepath.Join(dir, "ignored.go")
require.NoError(t, os.WriteFile(path, []byte(source), 0o644))
srv.indexer.SetExcludePatterns([]string{"ignored.go"})
_, err := srv.indexer.Index(dir)
require.NoError(t, err)

res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": "ignored.go::Ignored", "new_name": "Renamed",
})
resp := decodeRenameResp(t, res)
require.Equal(t, "symbol_not_indexed", resp["error_code"])
got, err := os.ReadFile(path)
require.NoError(t, err)
require.Equal(t, source, string(got))
}

func TestRenameSymbol_InvalidTargetStaysNotFound(t *testing.T) {
srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc)
for _, id := range []string{"missing.go::Missing", "target.go::Missing"} {
res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{
"id": id, "new_name": "Renamed",
})
require.True(t, res.IsError)
require.Contains(t, toolResultText(res), "symbol not found: "+id)
}
target, err := os.ReadFile(filepath.Join(dir, "target.go"))
require.NoError(t, err)
require.Equal(t, renameTargetSrc, string(target))
}
3 changes: 3 additions & 0 deletions internal/mcp/tools_coding.go
Original file line number Diff line number Diff line change
Expand Up @@ -2556,6 +2556,9 @@ func (s *Server) handleRenameSymbol(ctx context.Context, req mcp.CallToolRequest

node := s.engineFor(ctx).GetSymbol(id)
if node == nil {
if recovery := s.unindexedRenameRecovery(ctx, id, newName, dryRun); recovery != nil {
return s.respondJSONOrTOON(ctx, req, recovery)
}
return mcp.NewToolResultError("symbol not found: " + id), nil
}

Expand Down
Loading