diff --git a/docs/mcp.md b/docs/mcp.md index fa12e5c8..4a62b882 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -286,7 +286,7 @@ over a very large tree, or `ask` against a slow local model. | `edit_symbol` | Edit a symbol's source directly by ID — no Read needed. Line-ending tolerant: an LF-authored `old_source` matches a CRLF file (and vice versa) and the replacement adopts the file's endings (`eol_normalized: true` rides on the response). Optional `base_sha` content-hash guard refuses the write when the on-disk SHA has drifted; every success carries `new_sha` so the next edit can pipeline without re-reading | | `edit_file` | Edit any file (markdown, config, spec, template, source) by exact string replacement — accepts absolute paths or repo-rooted paths. Line-ending tolerant: an LF-authored `old_string` matches a CRLF file (and vice versa) and the replacement is written with the file's own endings (`eol_normalized: true` rides on the response). Same `base_sha` / `new_sha` drift guard. Kills Read-before-Edit for files not in the graph | | `write_file` | Create or overwrite any file — atomic temp+rename, re-indexes on write. Same `base_sha` / `new_sha` drift guard | -| `rename_symbol` | Coordinated multi-file rename with all references — definition, graph usages, receiver lines, and test names that embed the old identifier. Replacement is whole-identifier, so renaming `Get` leaves `GetUser` intact. Every target line is re-verified against disk and every affected file is parse-gated before anything is written, so the rename lands completely or is refused; `dry_run: true` returns the identical edit list without writing. Response carries `status` (`applied` / `would_apply` / `no_edits`) plus per-file `bytes_written` / `new_sha` / `reindexed` | +| `rename_symbol` | Coordinated multi-file rename with all references — definition, graph usages, receiver lines, and test names that embed the old identifier. Replacement is whole-identifier, so renaming `Get` leaves `GetUser` intact. Every target line is re-verified against disk and every affected file is parse-gated before anything is written, so the rename lands completely or is refused; `dry_run: true` returns the identical edit list without writing. Successful responses carry `status` (`applied` / `would_apply` / `no_edits`) plus per-file `bytes_written` / `new_sha` / `reindexed`. An existing unindexed target returns a structured `symbol_not_indexed` error only when the configured extractor anchors the requested declaration. Its `safe_fallback.request` is a guarded exact edit of that declaration line (`scope: declaration_only`); same-file and cross-file references remain explicitly unproven, and the refusal itself writes no bytes | | `move_symbol` | Relocate a function / method / type / variable / const to another file. Cross-package moves rewrite every qualified reference, drop the source import, add the target import, synthesise the target file if missing. Go for now | | `inline_symbol` | Replace every callsite of a trivial single-statement / single-expression callee with the body — refuses cleanly on defer, spawn, close-over-scope, multi-return, or side-effecting arg. `delete_after: true` removes the declaration. Go for now | | `safe_delete_symbol` | Atomic dead-code removal with a graph-aware safety gate. A `cascade` parameter (`off` / `preview` / `apply`) drives a fixed-point orphan-propagation pass; cross-workspace and out-of-closure callers (and, by default, test-only callers) disqualify a candidate | diff --git a/internal/indexer/extract_source.go b/internal/indexer/extract_source.go new file mode 100644 index 00000000..f0ce817f --- /dev/null +++ b/internal/indexer/extract_source.go @@ -0,0 +1,85 @@ +package indexer + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/zzet/gortex/internal/parser" + "github.com/zzet/gortex/internal/parser/crashpool" +) + +// maxAdHocExtractSourceBytes is a hard ceiling for caller-owned, out-of-index +// validation. Normal indexing may admit larger files under configuration, but +// interactive recovery must not turn one MCP request into an unbounded parse. +const maxAdHocExtractSourceBytes = 1 << 20 // 1 MiB + +// ExtractSource parses caller-owned bytes through the indexer's normal +// admission, minified-content, timeout, panic-recovery, and optional +// crash-isolation path without mutating graph state. The caller owns the +// returned result and must call ReleaseTree when it is no longer needed. +func (idx *Indexer) ExtractSource(ctx context.Context, filePath string, src []byte) (*parser.ExtractionResult, error) { + if idx == nil || idx.registry == nil { + return nil, fmt.Errorf("indexer source extraction is unavailable") + } + if ctx == nil { + return nil, fmt.Errorf("indexer source extraction requires a context") + } + if err := ctx.Err(); err != nil { + return nil, err + } + limit := int64(maxAdHocExtractSourceBytes) + if idx.config.MaxFileSize > 0 && idx.config.MaxFileSize < limit { + limit = idx.config.MaxFileSize + } + if int64(len(src)) > limit { + return nil, fmt.Errorf("source exceeds bounded extraction limit (%d bytes)", limit) + } + + language, ok := idx.registry.DetectLanguageContent(filePath, src) + if !ok { + return nil, fmt.Errorf("unsupported source language for %s", filePath) + } + extractor, ok := idx.registry.GetByLanguage(language) + if !ok { + return nil, fmt.Errorf("no extractor registered for %s", language) + } + prepared := parser.ApplyPreParse(extractor, src) + if int64(len(prepared)) > limit { + return nil, fmt.Errorf("prepared source exceeds bounded extraction limit (%d bytes)", limit) + } + + var pool *crashpool.Pool + var quarantine *crashpool.Quarantine + if idx.crashIsolationEnabled() { + pool, quarantine = idx.sharedParsePool() + } + nativeAdmission := newNativeParseExtractionAdmission(0, nil, idx.nativeParseAdmission.Load()) + path := filePath + if !filepath.IsAbs(path) && idx.rootPath != "" { + path = filepath.Join(idx.rootPath, filepath.FromSlash(filePath)) + } + result, skipped, err := idx.extractFileCtx( + ctx, nativeAdmission, pool, quarantine, + path, filePath, language, extractor, prepared, + ) + if err != nil { + if result != nil { + result.ReleaseTree() + } + return nil, err + } + if skipped { + if result != nil { + result.ReleaseTree() + } + return nil, fmt.Errorf("source extraction refused by index safeguards") + } + if err := ctx.Err(); err != nil { + if result != nil { + result.ReleaseTree() + } + return nil, err + } + return result, nil +} diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go index df1b8b11..002d722d 100644 --- a/internal/mcp/errors.go +++ b/internal/mcp/errors.go @@ -55,6 +55,10 @@ const ( // index. Recoverable: the agent can search for it instead. ErrCodeSymbolNotFound ErrorCode = "symbol_not_found" + // ErrCodeSymbolNotIndexed — the symbol's file exists and is in scope but + // carries no indexed symbols, so semantic rename cannot see it. + ErrCodeSymbolNotIndexed ErrorCode = "symbol_not_indexed" + // ErrCodeFileNotIndexed — no symbols are indexed for the requested // file (new / ignored / unsupported language). Recoverable. ErrCodeFileNotIndexed ErrorCode = "file_not_indexed" diff --git a/internal/mcp/rename_apply.go b/internal/mcp/rename_apply.go index a5f5dad1..265fab00 100644 --- a/internal/mcp/rename_apply.go +++ b/internal/mcp/rename_apply.go @@ -23,6 +23,189 @@ type renameEdit struct { Reason string `json:"reason"` } +// unindexedRenameRecovery distinguishes an invalid symbol ID from an existing +// file that semantic rename cannot see. When the configured language extractor +// identifies exactly one requested declaration, it returns a guarded exact-line +// edit for that declaration only. References remain outside the recovery scope. +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.Lstat(absPath) + if err != nil || !info.Mode().IsRegular() { + // Refuse symlink leaves: an atomic edit of the link path would replace + // the link object while leaving its target unchanged. + return nil + } + + file := s.graphPathSpelling(relPath) + if indexed := s.engineFor(ctx).GetFileSymbols(file); indexed != nil && indexed.TotalNodes > 0 { + return nil + } + + content, err := os.ReadFile(absPath) + if err != nil { + return nil + } + extracted, err := s.indexer.ExtractSource(ctx, relPath, content) + if err != nil || extracted == nil { + return nil + } + defer extracted.ReleaseTree() + + var declarationName string + var declarationLine int + for _, node := range extracted.Nodes { + _, suffix, hasSuffix := strings.Cut(node.ID, "::") + if !hasSuffix || suffix != requestedSymbol { + continue + } + if declarationName != "" { + return nil + } + declarationName = node.Name + declarationLine = node.StartLine + } + if declarationName == "" || declarationLine <= 0 { + return nil + } + + oldLine, newLine, ok := s.verifiedDeclarationRename( + ctx, relPath, content, declarationLine, requestedSymbol, declarationName, newName, + ) + if !ok { + return nil + } + + return map[string]any{ + "status": "refused", + "symbol_id": id, + "requested_symbol": requestedSymbol, + "declaration_name": declarationName, + "new_name": newName, + "file": file, + "occurrences": 1, + "semantic_rename_complete": false, + "written": false, + "dry_run": dryRun, + "safe_fallback": map[string]any{ + "tool": "edit", + "request": map[string]any{ + "operation": "file", + "target": map[string]any{"file": file}, + "match": oldLine, + "replacement": newLine, + "guard": map[string]any{ + "expected_occurrences": 1, + "base_sha": gitBlobSHA(content), + }, + }, + "scope": "declaration_only", + "guidance": []string{ + "Apply this exact contextual edit only after reviewing the declaration line.", + "Update same-file and cross-file references separately; completeness is not proven.", + }, + }, + "warning": "Only the parsed declaration is anchored. Same-file and cross-file references are not proven because this symbol is unavailable to the semantic graph. No text was changed.", + } +} + +const ( + maxUnindexedRenameLineBytes = 16 << 10 + maxUnindexedRenameCandidates = 8 +) + +// verifiedDeclarationRename tries each whole-identifier occurrence on the +// declaration line and reparses the resulting file. It accepts exactly one +// candidate: the edit must remove the requested symbol ID and create the +// expected renamed ID on the same declaration line. This avoids relying on +// optional extractor columns, which may point at the start of the declaration +// rather than at its name. +func (s *Server) verifiedDeclarationRename( + ctx context.Context, + relPath string, + content []byte, + declarationLine int, + requestedSymbol, declarationName, newName string, +) (string, string, bool) { + if ctx.Err() != nil { + return "", "", false + } + lines := splitLinesKeepEnds(string(content)) + if declarationLine > len(lines) { + return "", "", false + } + oldLine := lines[declarationLine-1] + body, term := splitLineTerminator(oldLine) + if len(body) > maxUnindexedRenameLineBytes { + return "", "", false + } + + if !strings.HasSuffix(requestedSymbol, declarationName) { + return "", "", false + } + expectedSymbol := strings.TrimSuffix(requestedSymbol, declarationName) + newName + + offsets := make([]int, 0, maxUnindexedRenameCandidates) + for from := 0; ; { + nameOffset := indexIdentifier(body, declarationName, from) + if nameOffset < 0 { + break + } + if len(offsets) == maxUnindexedRenameCandidates { + return "", "", false + } + offsets = append(offsets, nameOffset) + from = nameOffset + 1 + } + + var accepted string + for _, nameOffset := range offsets { + if ctx.Err() != nil { + return "", "", false + } + candidateLine := body[:nameOffset] + newName + body[nameOffset+len(declarationName):] + term + candidateLines := append([]string(nil), lines...) + candidateLines[declarationLine-1] = candidateLine + candidateContent := []byte(strings.Join(candidateLines, "")) + + candidate, err := s.indexer.ExtractSource(ctx, relPath, candidateContent) + if err == nil && candidate != nil { + originalPresent := false + expectedAtDeclaration := 0 + for _, node := range candidate.Nodes { + _, suffix, hasSuffix := strings.Cut(node.ID, "::") + if !hasSuffix { + continue + } + if suffix == requestedSymbol { + originalPresent = true + } + if suffix == expectedSymbol && node.StartLine == declarationLine { + expectedAtDeclaration++ + } + } + candidate.ReleaseTree() + if !originalPresent && expectedAtDeclaration == 1 { + if accepted != "" { + return "", "", false + } + accepted = candidateLine + } + } + } + if accepted == "" { + return "", "", false + } + return oldLine, accepted, true +} + // 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 diff --git a/internal/mcp/rename_recovery_facade_test.go b/internal/mcp/rename_recovery_facade_test.go new file mode 100644 index 00000000..923b798d --- /dev/null +++ b/internal/mcp/rename_recovery_facade_test.go @@ -0,0 +1,38 @@ +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) + require.True(t, res.IsError) + require.Equal(t, facadeOutcomeToolError, classifyFacadeOutcome(res, nil)) + resp := decodeRenameErrorResp(t, res) + require.Equal(t, "symbol_not_indexed", resp["error_code"]) + require.Equal(t, "refused", resp["data"].(map[string]any)["status"]) + require.Equal(t, false, resp["data"].(map[string]any)["written"]) + + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} diff --git a/internal/mcp/rename_recovery_test.go b/internal/mcp/rename_recovery_test.go new file mode 100644 index 00000000..484f2def --- /dev/null +++ b/internal/mcp/rename_recovery_test.go @@ -0,0 +1,223 @@ +package mcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func decodeRenameErrorResp(t *testing.T, res *mcplib.CallToolResult) map[string]any { + t.Helper() + require.True(t, res.IsError, "%v", res.Content) + require.NotEmpty(t, res.Content) + var resp map[string]any + require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &resp)) + return resp +} + +func TestRenameSymbol_UnindexedRecovery(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + const source = "package main\n\nfunc Late() {}\nfunc Use() { 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, + }) + require.True(t, res.IsError) + resp := decodeRenameErrorResp(t, res) + require.Equal(t, "symbol_not_indexed", resp["error_code"]) + recovery := resp["data"].(map[string]any) + require.Equal(t, "refused", recovery["status"]) + require.Equal(t, false, recovery["semantic_rename_complete"]) + require.Equal(t, false, recovery["written"]) + require.Equal(t, dryRun, recovery["dry_run"]) + require.EqualValues(t, 1, recovery["occurrences"]) + require.Contains(t, recovery["warning"], "Only the parsed declaration is anchored") + + fallback := recovery["safe_fallback"].(map[string]any) + require.Equal(t, "edit", fallback["tool"]) + require.Equal(t, "declaration_only", fallback["scope"]) + request := fallback["request"].(map[string]any) + require.Equal(t, "file", request["operation"]) + require.Equal(t, "late.go", request["target"].(map[string]any)["file"]) + require.Equal(t, "func Late() {}\n", request["match"]) + require.Equal(t, "func Renamed() {}\n", request["replacement"]) + guard := request["guard"].(map[string]any) + require.EqualValues(t, 1, guard["expected_occurrences"]) + require.NotEmpty(t, guard["base_sha"]) + require.NotEmpty(t, fallback["guidance"]) + + 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", + }) + require.True(t, res.IsError) + resp := decodeRenameErrorResp(t, res) + require.Equal(t, "symbol_not_indexed", resp["error_code"]) + require.EqualValues(t, 1, resp["data"].(map[string]any)["occurrences"]) + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} + +func TestRenameSymbol_RecoveryExecutesDeclarationOnly(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + const source = "package main\n\ntype Run struct{}\nfunc (r *Run) Run() {}\nfunc Use(r *Run) { r.Run() }\n" + path := filepath.Join(dir, "late.go") + require.NoError(t, os.WriteFile(path, []byte(source), 0o644)) + + res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{ + "id": "late.go::Run.Run", "new_name": "Execute", + }) + resp := decodeRenameErrorResp(t, res) + recovery := resp["data"].(map[string]any) + require.Equal(t, "Run", recovery["declaration_name"]) + fallback := recovery["safe_fallback"].(map[string]any) + request := fallback["request"].(map[string]any) + + req := mcplib.CallToolRequest{} + req.Params.Name = "edit" + req.Params.Arguments = request + editResult, err := srv.handleFacade(context.Background(), "edit", req) + require.NoError(t, err) + require.False(t, editResult.IsError, toolResultText(editResult)) + + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, "package main\n\ntype Run struct{}\nfunc (r *Run) Execute() {}\nfunc Use(r *Run) { r.Run() }\n", string(got)) +} + +func TestRenameSymbol_SubIdentifierIsNotADeclaration(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + const source = "const foo$bar = 1;\n" + path := filepath.Join(dir, "late.js") + require.NoError(t, os.WriteFile(path, []byte(source), 0o644)) + + res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{ + "id": "late.js::foo", "new_name": "renamed", + }) + require.True(t, res.IsError) + require.Contains(t, toolResultText(res), "symbol not found: late.js::foo") + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} + +func TestRenameSymbol_IgnoredMinifiedRecoveryIsRefused(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + source := "const foo=1;function use(){return " + strings.Repeat("foo+", 4096) + "0}\n" + path := filepath.Join(dir, "ignored.js") + require.NoError(t, os.WriteFile(path, []byte(source), 0o644)) + srv.indexer.SetExcludePatterns([]string{"ignored.js"}) + _, err := srv.indexer.Index(dir) + require.NoError(t, err) + + res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{ + "id": "ignored.js::foo", "new_name": "renamed", + }) + require.True(t, res.IsError) + require.Contains(t, toolResultText(res), "symbol not found: ignored.js::foo") + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} + +func TestRenameSymbol_OneLineCandidateBudgetRefusesRecovery(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + source := "package main; func Many() {}; func Use() { " + strings.Repeat("Many(); ", maxUnindexedRenameCandidates) + "Many() }\n" + path := filepath.Join(dir, "late.go") + require.NoError(t, os.WriteFile(path, []byte(source), 0o644)) + + extracted, err := srv.indexer.ExtractSource(context.Background(), "late.go", []byte(source)) + require.NoError(t, err) + defer extracted.ReleaseTree() + found := false + for _, node := range extracted.Nodes { + _, suffix, ok := strings.Cut(node.ID, "::") + if ok && suffix == "Many" { + found = true + break + } + } + require.True(t, found, "fixture must reach the candidate-budget gate with a parsed declaration") + + require.Nil(t, srv.unindexedRenameRecovery(context.Background(), "late.go::Many", "Renamed", false)) + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} + +func TestRenameSymbol_CancelledRecoveryDoesNotParseOrOfferFallback(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)) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.Nil(t, srv.unindexedRenameRecovery(ctx, "late.go::Late", "Renamed", false)) + got, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, source, string(got)) +} + +func TestRenameSymbol_SymlinkRecoveryIsRefused(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + const source = "package main\n\nfunc Linked() {}\n" + targetPath := filepath.Join(dir, "symlink-target.go") + linkPath := filepath.Join(dir, "late.go") + require.NoError(t, os.WriteFile(targetPath, []byte(source), 0o644)) + if err := os.Symlink("symlink-target.go", linkPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + res := callToolByName(t, srv, context.Background(), "rename_symbol", map[string]any{ + "id": "late.go::Linked", "new_name": "Renamed", + }) + require.True(t, res.IsError) + require.Contains(t, toolResultText(res), "symbol not found: late.go::Linked") + + got, err := os.ReadFile(targetPath) + require.NoError(t, err) + require.Equal(t, source, string(got)) + info, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) +} + +func TestRenameSymbol_InvalidTargetStaysNotFound(t *testing.T) { + srv, dir := setupRenameServer(t, renameTargetSrc, renameCallerSrc) + require.NoError(t, os.WriteFile(filepath.Join(dir, "late.go"), []byte("package main\n\nfunc Late() {}\n"), 0o644)) + for _, id := range []string{"missing.go::Missing", "target.go::Missing", "late.go::Typo"} { + 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)) +} diff --git a/internal/mcp/tools_coding.go b/internal/mcp/tools_coding.go index 828a58ef..f8197ddc 100644 --- a/internal/mcp/tools_coding.go +++ b/internal/mcp/tools_coding.go @@ -175,7 +175,7 @@ func (s *Server) registerCodingTools() { s.addTool( mcp.NewTool("rename_symbol", - mcp.WithDescription("Applies a coordinated multi-file rename for a symbol. Rewrites the definition, every graph usage (calls / references / instantiates), receiver lines when renaming a type, and test-function names that embed the old identifier. Returns status (applied / would_apply / no_edits), the {file, line, old_text, new_text, confidence, reason} edit list, and per-file {bytes_written, new_sha, reindexed}. Every affected line is re-verified against disk and parse-gated before anything is written, so the rename either lands completely or is refused. Pass dry_run=true to preview the identical edit list without writing."), + mcp.WithDescription("Applies a coordinated multi-file rename for a symbol. Rewrites the definition, every graph usage (calls / references / instantiates), receiver lines when renaming a type, and test-function names that embed the old identifier. Returns status (applied / would_apply / no_edits), the {file, line, old_text, new_text, confidence, reason} edit list, and per-file {bytes_written, new_sha, reindexed}. An existing but unindexed target returns a structured symbol_not_indexed error only when the configured extractor anchors the requested declaration; its guarded edit/file fallback is an exact declaration-line edit and does not claim reference completeness. The refusal itself writes nothing. Every affected line is re-verified against disk and parse-gated before anything is written, so the rename either lands completely or is refused. Pass dry_run=true to preview the identical edit list without writing."), mcp.WithString("id", mcp.Required(), mcp.Description("Symbol ID to rename (e.g. auth/token.go::validateToken)")), mcp.WithString("new_name", mcp.Required(), mcp.Description("New name for the symbol")), mcp.WithBoolean("dry_run", mcp.Description("Preview only: compute and verify every edit but write nothing. Returns status \"would_apply\" with the same edits and per-file new_sha the real call would produce. Default false — the call writes.")), @@ -2556,6 +2556,13 @@ 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 NewStructuredErrorResult(StructuredError{ + ErrorCode: ErrCodeSymbolNotIndexed, + Message: fmt.Sprintf("semantic rename cannot resolve %s because its file has no indexed symbols", id), + Data: recovery, + }), nil + } return mcp.NewToolResultError("symbol not found: " + id), nil }