Skip to content
2 changes: 1 addition & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
85 changes: 85 additions & 0 deletions internal/indexer/extract_source.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions internal/mcp/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
183 changes: 183 additions & 0 deletions internal/mcp/rename_apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions internal/mcp/rename_recovery_facade_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading