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

488 lines
16 KiB
Go

package mcp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"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/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/query"
"github.com/zzet/gortex/internal/config"
)
// indexFixture indexes the supplied directory and returns the
// running Server. Reused by every cascade test that relies on the
// natural graph the indexer produces.
func indexFixture(t *testing.T, dir string) *Server {
t.Helper()
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
}
// callSafeDeleteCascade invokes the safe_delete_symbol handler
// directly. safe_delete_symbol is a deferred (lazy) tool, so
// MCPServer().GetTool returns nil until the tools_search promotion
// has run; the bare-handler path matches what the existing
// tools_safe_delete_test.go uses and exercises the same logic.
func callSafeDeleteCascade(t *testing.T, srv *Server, args map[string]any) *mcplib.CallToolResult {
t.Helper()
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{
Name: "safe_delete_symbol",
Arguments: args,
}}
res, err := srv.handleSafeDeleteSymbol(context.Background(), req)
require.NoError(t, err)
return res
}
// readCascadeJSON unmarshals the tool result's first text content
// into a map for assertion.
func readCascadeJSON(t *testing.T, res *mcplib.CallToolResult) map[string]any {
t.Helper()
require.False(t, res.IsError, "tool returned error: %#v", res.Content)
require.NotEmpty(t, res.Content)
text := res.Content[0].(mcplib.TextContent).Text
var out map[string]any
require.NoError(t, json.Unmarshal([]byte(text), &out))
return out
}
// closureIDs extracts the IDs of every entry in a cascade_closure
// payload. Used for set comparison in assertions.
func closureIDs(resp map[string]any) []string {
raw, ok := resp["cascade_closure"].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
entry, ok := item.(map[string]any)
if !ok {
continue
}
if id, ok := entry["id"].(string); ok {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// deletedIDs extracts the cascade_deleted payload, similarly sorted.
func deletedIDs(resp map[string]any) []string {
raw, ok := resp["cascade_deleted"].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if id, ok := item.(string); ok {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// readSource is a small helper around os.ReadFile that returns the
// file contents as a string so callers can assertContains it.
func readSource(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err)
return string(b)
}
// ---------------------------------------------------------------
// Tests
// ---------------------------------------------------------------
// TestCascade_LinearChain — a → b → c, delete a with cascade:apply,
// verify b and c are also deleted from disk and the closure carries
// both IDs.
func TestCascade_LinearChain(t *testing.T) {
dir := t.TempDir()
src := "package chain\n\n" +
"func a() { b() }\n\n" +
"func b() { c() }\n\n" +
"func c() {}\n"
srcPath := filepath.Join(dir, "chain.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
// Sanity: all three symbols should be indexed.
require.NotNil(t, srv.graph.GetNode("chain.go::a"))
require.NotNil(t, srv.graph.GetNode("chain.go::b"))
require.NotNil(t, srv.graph.GetNode("chain.go::c"))
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "chain.go::a",
"cascade": "apply",
"dry_run": false,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
assert.Equal(t, "apply", resp["cascade_mode"])
closure := closureIDs(resp)
assert.ElementsMatch(t, []string{"chain.go::b", "chain.go::c"}, closure,
"closure must include b and c (transitively orphaned)")
deleted := deletedIDs(resp)
assert.ElementsMatch(t, []string{"chain.go::a", "chain.go::b", "chain.go::c"}, deleted,
"cascade_deleted must list every removed symbol including target")
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(")
assert.NotContains(t, out, "func b(")
assert.NotContains(t, out, "func c(")
}
// TestCascade_Branching — a → b; a → c; d → b. Deleting a should
// drop c (only a→c) but leave b alone (d still references it).
func TestCascade_Branching(t *testing.T) {
dir := t.TempDir()
src := "package branch\n\n" +
"func a() { b(); c() }\n\n" +
"func b() {}\n\n" +
"func c() {}\n\n" +
"func d() { b() }\n"
srcPath := filepath.Join(dir, "branch.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "branch.go::a",
"cascade": "apply",
"dry_run": false,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
closure := closureIDs(resp)
assert.Equal(t, []string{"branch.go::c"}, closure,
"closure must contain only c — b is still used by d")
deleted := deletedIDs(resp)
assert.ElementsMatch(t, []string{"branch.go::a", "branch.go::c"}, deleted)
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(")
assert.NotContains(t, out, "func c(")
assert.Contains(t, out, "func b(", "b must remain — referenced by d")
assert.Contains(t, out, "func d(", "d must remain — external to closure")
}
// TestCascade_CycleWithinClosure — two functions calling each other
// with no external callers. cascade:apply should treat the cycle as
// part of the closure (self-references inside D do not count as
// external) and delete both.
//
// The Go indexer naturally produces the mutual call edges; the only
// trick is to ensure neither function has an external caller. We
// keep a helper function in the file that calls neither so the
// package is still buildable for the indexer's purposes.
func TestCascade_CycleWithinClosure(t *testing.T) {
dir := t.TempDir()
src := "package cyc\n\n" +
"func a() { b() }\n\n" +
"func b() { a() }\n\n" +
"func entry() {}\n"
srcPath := filepath.Join(dir, "cyc.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "cyc.go::a",
"cascade": "apply",
"dry_run": false,
// b is called from a; that single in-edge is the one we'd
// normally guard against. force=true bypasses the initial
// reference gate (a's in-edges from b) so the cascade pass
// can take over.
"force": true,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
closure := closureIDs(resp)
assert.Equal(t, []string{"cyc.go::b"}, closure,
"cycle peer b must enter the closure even though a calls into it")
deleted := deletedIDs(resp)
assert.ElementsMatch(t, []string{"cyc.go::a", "cyc.go::b"}, deleted)
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(")
assert.NotContains(t, out, "func b(")
assert.Contains(t, out, "func entry(", "unrelated symbols stay")
}
// TestCascade_CrossRepoGuard — manually inject a synthetic caller
// whose WorkspaceID differs from the target's. The cross-workspace
// reference must DISQUALIFY the candidate from the closure.
func TestCascade_CrossRepoGuard(t *testing.T) {
dir := t.TempDir()
src := "package xrepo\n\n" +
"func a() { b() }\n\n" +
"func b() {}\n"
srcPath := filepath.Join(dir, "xrepo.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
// Locate the target node and stamp a WorkspaceID so the
// comparison is well-defined.
aNode := srv.graph.GetNode("xrepo.go::a")
require.NotNil(t, aNode)
bNode := srv.graph.GetNode("xrepo.go::b")
require.NotNil(t, bNode)
aNode.WorkspaceID = "local"
bNode.WorkspaceID = "local"
// Inject a synthetic foreign caller of b in a different
// workspace. The cascade pass must treat this as an external
// reference and refuse to include b.
foreignID := "ext-repo/foo.go::Caller"
srv.graph.AddNode(&graph.Node{
ID: foreignID,
Kind: graph.KindFunction,
Name: "Caller",
FilePath: "ext-repo/foo.go",
StartLine: 1,
EndLine: 3,
Language: "go",
RepoPrefix: "ext-repo",
WorkspaceID: "ext-repo",
})
srv.graph.AddEdge(&graph.Edge{
From: foreignID,
To: "xrepo.go::b",
Kind: graph.EdgeCrossRepoCalls,
FilePath: "ext-repo/foo.go",
Line: 2,
})
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "xrepo.go::a",
"cascade": "apply",
"dry_run": false,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
closure := closureIDs(resp)
assert.Empty(t, closure, "cross-workspace caller must disqualify b from the closure")
deleted := deletedIDs(resp)
assert.ElementsMatch(t, []string{"xrepo.go::a"}, deleted)
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(")
assert.Contains(t, out, "func b(", "b must remain — it has a cross-repo caller")
}
// TestCascade_TestsOnly — a calls b; a test-file _test.go also
// calls b. By default the cascade must leave b alone (production
// stopped using it but a test still does). With
// cascade_into_tests:true the cascade must delete b along with a.
func TestCascade_TestsOnly(t *testing.T) {
dir := t.TempDir()
src := "package tonly\n\n" +
"func a() { b() }\n\n" +
"func b() {}\n"
srcPath := filepath.Join(dir, "tonly.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
testSrc := "package tonly\n\n" +
"import \"testing\"\n\n" +
"func TestB(t *testing.T) { b() }\n"
testPath := filepath.Join(dir, "tonly_test.go")
require.NoError(t, os.WriteFile(testPath, []byte(testSrc), 0o644))
// Case 1: default cascade — must NOT delete b.
srv := indexFixture(t, dir)
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "tonly.go::a",
"cascade": "apply",
"dry_run": false,
// a has no external callers, so no force needed.
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
closure := closureIDs(resp)
assert.Empty(t, closure, "test-only caller must disqualify b without cascade_into_tests")
deleted := deletedIDs(resp)
assert.ElementsMatch(t, []string{"tonly.go::a"}, deleted)
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(")
assert.Contains(t, out, "func b(", "b must remain when only tests reference it")
// Case 2: cascade_into_tests:true — must delete b too. Fresh
// fixture so the file is back to its original state.
dir2 := t.TempDir()
srcPath2 := filepath.Join(dir2, "tonly.go")
require.NoError(t, os.WriteFile(srcPath2, []byte(src), 0o644))
testPath2 := filepath.Join(dir2, "tonly_test.go")
require.NoError(t, os.WriteFile(testPath2, []byte(testSrc), 0o644))
srv2 := indexFixture(t, dir2)
res2 := callSafeDeleteCascade(t, srv2, map[string]any{
"id": "tonly.go::a",
"cascade": "apply",
"cascade_into_tests": true,
"dry_run": false,
})
resp2 := readCascadeJSON(t, res2)
assert.Equal(t, "deleted", resp2["status"])
closure2 := closureIDs(resp2)
assert.Equal(t, []string{"tonly.go::b"}, closure2,
"cascade_into_tests:true must include b in the closure")
out2 := readSource(t, srcPath2)
assert.NotContains(t, out2, "func b(", "b must be deleted when cascade_into_tests is true")
}
// TestCascade_PreviewMode — cascade:"preview" must report the
// closure but leave every symbol on disk untouched, including the
// target itself when dry_run=false.
func TestCascade_PreviewMode(t *testing.T) {
dir := t.TempDir()
src := "package prev\n\n" +
"func a() { b() }\n\n" +
"func b() {}\n"
srcPath := filepath.Join(dir, "prev.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
// Preview with dry_run=true returns the planned delete but
// makes no on-disk changes.
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "prev.go::a",
"cascade": "preview",
"dry_run": true,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "preview", resp["status"])
assert.Equal(t, "preview", resp["cascade_mode"])
closure := closureIDs(resp)
assert.Equal(t, []string{"prev.go::b"}, closure,
"preview must still compute and surface the closure")
_, ok := resp["cascade_deleted"]
assert.False(t, ok, "preview must not carry a cascade_deleted list")
out := readSource(t, srcPath)
assert.Contains(t, out, "func a(", "preview must not modify disk")
assert.Contains(t, out, "func b(", "preview must not modify disk")
// Commit (dry_run=false) with cascade:preview should delete
// the target only — not the closure.
res2 := callSafeDeleteCascade(t, srv, map[string]any{
"id": "prev.go::a",
"cascade": "preview",
"dry_run": false,
})
resp2 := readCascadeJSON(t, res2)
assert.Equal(t, "deleted", resp2["status"])
assert.Equal(t, "preview", resp2["cascade_mode"])
closure2 := closureIDs(resp2)
assert.Equal(t, []string{"prev.go::b"}, closure2,
"preview mode must still report the closure on commit")
_, ok = resp2["cascade_deleted"]
assert.False(t, ok, "preview mode must not delete the closure even on commit")
out2 := readSource(t, srcPath)
assert.NotContains(t, out2, "func a(", "target itself must still be deleted")
assert.Contains(t, out2, "func b(", "preview mode must not delete the orphan")
}
// TestCascade_BackwardCompat_Off — omitting cascade (or passing
// "off") preserves today's single-symbol delete: no closure work,
// no cascade fields in the response that change existing behaviour.
func TestCascade_BackwardCompat_Off(t *testing.T) {
dir := t.TempDir()
src := "package compat\n\n" +
"func a() { b() }\n\n" +
"func b() {}\n"
srcPath := filepath.Join(dir, "compat.go")
require.NoError(t, os.WriteFile(srcPath, []byte(src), 0o644))
srv := indexFixture(t, dir)
// cascade omitted entirely.
res := callSafeDeleteCascade(t, srv, map[string]any{
"id": "compat.go::a",
"dry_run": false,
})
resp := readCascadeJSON(t, res)
assert.Equal(t, "deleted", resp["status"])
assert.Equal(t, "off", resp["cascade_mode"], "default cascade_mode must be off")
_, hasClosure := resp["cascade_closure"]
assert.False(t, hasClosure, "off mode must not surface a closure list")
_, hasDeleted := resp["cascade_deleted"]
assert.False(t, hasDeleted, "off mode must not surface a deleted list")
out := readSource(t, srcPath)
assert.NotContains(t, out, "func a(", "target must still be deleted")
assert.Contains(t, out, "func b(", "off mode leaves the orphan in place")
// Explicit cascade:"off" behaves the same.
dir2 := t.TempDir()
srcPath2 := filepath.Join(dir2, "compat.go")
require.NoError(t, os.WriteFile(srcPath2, []byte(src), 0o644))
srv2 := indexFixture(t, dir2)
res2 := callSafeDeleteCascade(t, srv2, map[string]any{
"id": "compat.go::a",
"cascade": "off",
"dry_run": false,
})
resp2 := readCascadeJSON(t, res2)
assert.Equal(t, "deleted", resp2["status"])
assert.Equal(t, "off", resp2["cascade_mode"])
out2 := readSource(t, srcPath2)
assert.NotContains(t, out2, "func a(")
assert.Contains(t, out2, "func b(")
}
// TestCascade_NormaliseMode — unit-tests the input normaliser so the
// tool gracefully handles unknown / mixed-case input from clients.
func TestCascade_NormaliseMode(t *testing.T) {
tests := []struct {
in string
want string
}{
{"", cascadeModeOff},
{"off", cascadeModeOff},
{"OFF", cascadeModeOff},
{"preview", cascadeModePreview},
{"Preview", cascadeModePreview},
{" apply ", cascadeModeApply},
{"bogus", cascadeModeOff},
}
for _, tt := range tests {
assert.Equal(t, tt.want, normaliseCascadeMode(tt.in),
"normaliseCascadeMode(%q)", tt.in)
}
}