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
488 lines
16 KiB
Go
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)
|
|
}
|
|
}
|