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

498 lines
14 KiB
Go

package mcp
import (
"os"
"path/filepath"
"strings"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/query"
)
// setupMoveInlineRepo materialises a tiny Go module on disk with the given
// per-path content, indexes it through the parser registry, and returns a
// fully-wired MCP Server plus the temp root. The module is named
// `example.com/movetest` so import-path resolution can hit the synthetic
// go.mod.
func setupMoveInlineRepo(t *testing.T, files map[string]string) (*Server, string) {
t.Helper()
dir := t.TempDir()
if _, ok := files["go.mod"]; !ok {
files["go.mod"] = "module example.com/movetest\n\ngo 1.21\n"
}
for rel, content := range files {
full := filepath.Join(dir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
require.NoError(t, os.WriteFile(full, []byte(content), 0o644))
}
g := graph.New()
reg := testRegistry()
cfg := config.Default()
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
_, err := idx.Index(dir)
require.NoError(t, err)
eng := query.NewEngine(g)
srv := NewServer(eng, g, idx, nil, zap.NewNop(), nil)
srv.RunAnalysis()
return srv, dir
}
func mustErrText(t *testing.T, result *mcplib.CallToolResult) string {
t.Helper()
require.NotEmpty(t, result.Content)
tc, ok := result.Content[0].(mcplib.TextContent)
require.True(t, ok, "expected TextContent")
return tc.Text
}
// ---------------------------------------------------------------------------
// move_symbol
// ---------------------------------------------------------------------------
func TestMoveSymbol_SamePackage_FunctionRelocated(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
// Foo is the function under test.
func Foo() int { return 42 }
`,
"pkga/b.go": `package pkga
// existing keeps the file non-empty.
func bar() int { return 1 }
`,
})
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkga/b.go",
})
require.False(t, result.IsError, "result error: %v", result.Content)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, true, resp["same_package"])
assert.Equal(t, "pkga", resp["target_package"])
assert.Equal(t, float64(0), resp["references_rewritten"])
// Foo should be gone from a.go and present in b.go.
aBytes, err := os.ReadFile(filepath.Join(dir, "pkga/a.go"))
require.NoError(t, err)
bBytes, err := os.ReadFile(filepath.Join(dir, "pkga/b.go"))
require.NoError(t, err)
assert.NotContains(t, string(aBytes), "func Foo()")
assert.Contains(t, string(bBytes), "func Foo() int { return 42 }")
assert.Contains(t, string(bBytes), "// Foo is the function under test.")
}
func TestMoveSymbol_CrossPackage_RewritesThirdPartyCaller(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
func Foo() int { return 42 }
`,
"pkgb/b.go": `package pkgb
`,
"pkgc/c.go": `package pkgc
import "example.com/movetest/pkga"
func UseFoo() int {
return pkga.Foo()
}
`,
})
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkgb/b.go",
})
require.False(t, result.IsError, "result error: %v", result.Content)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, false, resp["same_package"])
assert.GreaterOrEqual(t, resp["references_rewritten"], float64(1))
cBytes, err := os.ReadFile(filepath.Join(dir, "pkgc/c.go"))
require.NoError(t, err)
assert.Contains(t, string(cBytes), "pkgb.Foo()", "caller's qualifier should be rewritten")
assert.NotContains(t, string(cBytes), "pkga.Foo()", "old qualifier should be removed")
assert.Contains(t, string(cBytes), `"example.com/movetest/pkgb"`, "new import should be present")
}
func TestMoveSymbol_CrossPackage_CallerInTarget_BecomesBare(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
func Foo() int { return 42 }
`,
"pkgb/other.go": `package pkgb
import "example.com/movetest/pkga"
func UseFoo() int {
return pkga.Foo()
}
`,
"pkgb/b.go": `package pkgb
`,
})
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkgb/b.go",
})
require.False(t, result.IsError, "result error: %v", result.Content)
otherBytes, err := os.ReadFile(filepath.Join(dir, "pkgb/other.go"))
require.NoError(t, err)
otherSrc := string(otherBytes)
assert.Contains(t, otherSrc, "Foo()", "callsite should remain")
assert.NotContains(t, otherSrc, "pkga.Foo()", "qualifier should be stripped")
assert.NotContains(t, otherSrc, `"example.com/movetest/pkga"`, "import should be dropped once no other pkga usage remains")
}
func TestMoveSymbol_CrossPackage_CallerInSource_GainsImport(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
func Foo() int { return 42 }
`,
"pkga/other.go": `package pkga
func UseFoo() int {
return Foo()
}
`,
"pkgb/b.go": `package pkgb
`,
})
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkgb/b.go",
})
require.False(t, result.IsError, "result error: %v", result.Content)
otherBytes, err := os.ReadFile(filepath.Join(dir, "pkga/other.go"))
require.NoError(t, err)
otherSrc := string(otherBytes)
assert.Contains(t, otherSrc, "pkgb.Foo()", "bare call should be qualified with new pkg")
assert.Contains(t, otherSrc, `"example.com/movetest/pkgb"`, "new import should be added")
}
func TestMoveSymbol_TargetFileMissing_CreatesIt(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
func Foo() int { return 42 }
`,
})
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkgb/new.go",
"target_package": "pkgb",
})
require.False(t, result.IsError, "result error: %v", result.Content)
got, err := os.ReadFile(filepath.Join(dir, "pkgb/new.go"))
require.NoError(t, err)
assert.Contains(t, string(got), "package pkgb")
assert.Contains(t, string(got), "func Foo() int { return 42 }")
}
func TestMoveSymbol_DryRun_NoOnDiskChanges(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkga/a.go": `package pkga
func Foo() int { return 42 }
`,
"pkga/b.go": `package pkga
func keep() int { return 1 }
`,
})
origA, _ := os.ReadFile(filepath.Join(dir, "pkga/a.go"))
origB, _ := os.ReadFile(filepath.Join(dir, "pkga/b.go"))
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "pkga/a.go::Foo",
"target_file": "pkga/b.go",
"dry_run": true,
})
require.False(t, result.IsError)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, true, resp["dry_run"])
postA, _ := os.ReadFile(filepath.Join(dir, "pkga/a.go"))
postB, _ := os.ReadFile(filepath.Join(dir, "pkga/b.go"))
assert.Equal(t, string(origA), string(postA), "dry run must not write the source file")
assert.Equal(t, string(origB), string(postB), "dry run must not write the target file")
}
func TestMoveSymbol_UnsupportedLanguage(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"py/foo.py": `def Foo():
return 42
`,
})
// The Python file may not be indexed in this minimal harness; synthesise
// a Python node directly so the language gate is exercised.
g := srv.engineFor(t.Context()).Reader()
_ = g
// Insert a fake Python symbol directly into the graph.
pyNode := &graph.Node{
ID: "py/foo.py::Foo",
Kind: graph.KindFunction,
Name: "Foo",
FilePath: "py/foo.py",
StartLine: 1,
EndLine: 2,
Language: "python",
}
srv.graph.AddNode(pyNode)
_ = dir
result := callTool(t, srv, "move_symbol", map[string]any{
"id": "py/foo.py::Foo",
"target_file": "py/bar.py",
})
require.True(t, result.IsError)
body := mustErrText(t, result)
assert.Contains(t, body, "unsupported language")
}
// ---------------------------------------------------------------------------
// inline_symbol
// ---------------------------------------------------------------------------
func TestInlineSymbol_TrivialAccessor(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkg/types.go": `package pkg
type S struct {
X int
}
`,
"pkg/accessor.go": `package pkg
func GetX(s *S) int { return s.X }
`,
"pkg/caller.go": `package pkg
func use() int {
s := &S{X: 3}
return GetX(s)
}
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/accessor.go::GetX",
})
require.False(t, result.IsError, "result error: %v", result.Content)
resp := decodeFileOpsResult(t, result)
assert.GreaterOrEqual(t, resp["callsites_inlined"], float64(1))
assert.Equal(t, true, resp["callee_deleted"])
callerBytes, err := os.ReadFile(filepath.Join(dir, "pkg/caller.go"))
require.NoError(t, err)
assert.Contains(t, string(callerBytes), "s.X", "callsite should be replaced with body")
assert.NotContains(t, string(callerBytes), "GetX(s)", "old call should be gone")
accessorBytes, err := os.ReadFile(filepath.Join(dir, "pkg/accessor.go"))
require.NoError(t, err)
assert.NotContains(t, string(accessorBytes), "func GetX", "callee should be deleted")
}
func TestInlineSymbol_TwoCallsites_BothRewritten(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkg/accessor.go": `package pkg
func Double(x int) int { return x + x }
`,
"pkg/use.go": `package pkg
func a() int { return Double(2) }
func b() int { return Double(5) }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/accessor.go::Double",
"delete_after": true,
})
require.False(t, result.IsError, "result error: %v", result.Content)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, float64(2), resp["callsites_inlined"])
body, err := os.ReadFile(filepath.Join(dir, "pkg/use.go"))
require.NoError(t, err)
src := string(body)
assert.Contains(t, src, "2 + 2")
assert.Contains(t, src, "5 + 5")
assert.NotContains(t, src, "Double(")
}
func TestInlineSymbol_ArgumentSubstitution(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkg/add.go": `package pkg
func Add(a, b int) int { return a + b }
`,
"pkg/use.go": `package pkg
func f(x, y int) int { return Add(x, y) }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/add.go::Add",
})
require.False(t, result.IsError)
body, _ := os.ReadFile(filepath.Join(dir, "pkg/use.go"))
assert.Contains(t, string(body), "x + y")
}
func TestInlineSymbol_SideEffectArgRefuses(t *testing.T) {
srv, _ := setupMoveInlineRepo(t, map[string]string{
"pkg/sq.go": `package pkg
func Square(x int) int { return x * x }
func rand() int { return 7 }
func use() int { return Square(rand()) }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/sq.go::Square",
})
// The tool returns status=refused with refusals[]. We accept either
// IsError true (with refusal text) or status=refused on a JSON body.
if result.IsError {
body := mustErrText(t, result)
assert.True(t, strings.Contains(body, "refus") || strings.Contains(body, "side effects"),
"expected refusal mention, got %q", body)
} else {
resp := decodeFileOpsResult(t, result)
assert.Equal(t, "refused", resp["status"])
refusals, ok := resp["refusals"].([]any)
require.True(t, ok, "refusals should be a list")
require.NotEmpty(t, refusals)
}
}
func TestInlineSymbol_DeleteAfterFalse_KeepsCallee(t *testing.T) {
srv, dir := setupMoveInlineRepo(t, map[string]string{
"pkg/sq.go": `package pkg
func GetX(s *Box) int { return s.X }
`,
"pkg/types.go": `package pkg
type Box struct {
X int
}
`,
"pkg/use.go": `package pkg
func u() int { b := &Box{X: 9}; return GetX(b) }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/sq.go::GetX",
"delete_after": false,
})
require.False(t, result.IsError, "result error: %v", result.Content)
resp := decodeFileOpsResult(t, result)
assert.Equal(t, false, resp["callee_deleted"])
sq, _ := os.ReadFile(filepath.Join(dir, "pkg/sq.go"))
assert.Contains(t, string(sq), "func GetX", "callee should remain when delete_after=false")
use, _ := os.ReadFile(filepath.Join(dir, "pkg/use.go"))
assert.Contains(t, string(use), "b.X", "callsite should still be rewritten")
}
func TestInlineSymbol_RefusesDefer(t *testing.T) {
srv, _ := setupMoveInlineRepo(t, map[string]string{
"pkg/d.go": `package pkg
import "fmt"
func WithDefer() int {
defer fmt.Println("done")
return 1
}
func use() int { return WithDefer() }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/d.go::WithDefer",
})
require.True(t, result.IsError, "defer-bearing callees must be refused")
body := mustErrText(t, result)
assert.True(t,
strings.Contains(body, "defer") || strings.Contains(body, "single statement"),
"expected reason mentioning defer or single-statement constraint, got %q", body)
}
func TestInlineSymbol_RefusesMultipleReturns(t *testing.T) {
srv, _ := setupMoveInlineRepo(t, map[string]string{
"pkg/m.go": `package pkg
func TwoReturns() (int, int) { return 1, 2 }
func use() (int, int) { return TwoReturns() }
`,
})
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "pkg/m.go::TwoReturns",
})
require.True(t, result.IsError)
body := mustErrText(t, result)
assert.True(t,
strings.Contains(body, "multiple return values") || strings.Contains(body, "single"),
"expected multi-return refusal, got %q", body)
}
func TestInlineSymbol_UnsupportedLanguage(t *testing.T) {
srv, _ := setupMoveInlineRepo(t, map[string]string{
"py/foo.py": `def Foo(): return 1
`,
})
pyNode := &graph.Node{
ID: "py/foo.py::Foo",
Kind: graph.KindFunction,
Name: "Foo",
FilePath: "py/foo.py",
StartLine: 1,
EndLine: 1,
Language: "python",
}
srv.graph.AddNode(pyNode)
result := callTool(t, srv, "inline_symbol", map[string]any{
"id": "py/foo.py::Foo",
})
require.True(t, result.IsError)
body := mustErrText(t, result)
assert.Contains(t, body, "unsupported language")
}