package mcp import ( "context" "encoding/json" "math" "os" "path/filepath" "sort" "strings" "testing" mcplib "github.com/mark3labs/mcp-go/mcp" "go.uber.org/zap" "pgregory.net/rapid" "github.com/zzet/gortex/internal/analysis" "github.com/zzet/gortex/internal/config" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" "github.com/zzet/gortex/internal/query" ) // ============================================================================ // Helpers // ============================================================================ // ============================================================================ // 13.1 Property 4: Prefetch ranking is monotonically decreasing // ============================================================================ // Feature: gortex-enhancements, Property 4: Prefetch ranking is monotonically decreasing // // For any set of prefetch candidates with scores, the list SHALL be sorted by // combined score descending, and each confidence SHALL be in [0, 1]. func TestPropertyPrefetchRankingMonotonicallyDecreasing(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { // Generate random candidates with scores n := rapid.IntRange(1, 50).Draw(rt, "candidateCount") var candidates []prefetchCandidate for i := 0; i < n; i++ { searchRel := rapid.Float64Range(0, 1).Draw(rt, "search") graphProx := rapid.Float64Range(0, 1).Draw(rt, "proximity") commBonus := rapid.Float64Range(0, 1).Draw(rt, "community") combined := 0.4*searchRel + 0.4*graphProx + 0.2*commBonus confidence := math.Min(combined, 1.0) confidence = math.Round(confidence*1000) / 1000 candidates = append(candidates, prefetchCandidate{ ID: rapid.StringMatching(`[a-z]{3,6}/[a-z]{3,6}\.go::[A-Z][a-zA-Z]{2,8}`).Draw(rt, "id"), Kind: "function", FilePath: "pkg/file.go", StartLine: i + 1, Reason: "test", Confidence: confidence, SearchRelevance: searchRel, GraphProximity: graphProx, CommunityBonus: commBonus, }) } // Sort by confidence descending (same logic as handlePrefetchContext) sort.Slice(candidates, func(i, j int) bool { return candidates[i].Confidence > candidates[j].Confidence }) // Truncate to top 10 if len(candidates) > 10 { candidates = candidates[:10] } // Verify monotonically decreasing confidence for i := 1; i < len(candidates); i++ { if candidates[i].Confidence > candidates[i-1].Confidence { rt.Errorf("ranking not monotonically decreasing at index %d: %.3f > %.3f", i, candidates[i].Confidence, candidates[i-1].Confidence) } } // Verify all confidence scores are in [0, 1] for i, c := range candidates { if c.Confidence < 0 || c.Confidence > 1 { rt.Errorf("confidence at index %d out of range [0,1]: %.3f", i, c.Confidence) } } }) } // ============================================================================ // 13.2 Property 10: Truncation invariant // ============================================================================ // Feature: gortex-enhancements, Property 10: Truncation invariant // // For any result set exceeding its limit, the returned list SHALL have exactly // the limit items and the truncated flag SHALL be true. func TestPropertyTruncationInvariant(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { type truncationCase struct { name string limit int } cases := []truncationCase{ {"prefetch_context", 10}, {"find_hotspots", 20}, {"find_cycles", 20}, {"diff_context", 50}, } for _, tc := range cases { // Generate a result set of random size (may or may not exceed limit) size := rapid.IntRange(1, tc.limit*3).Draw(rt, tc.name+"_size") // Simulate truncation logic used across all tools items := make([]int, size) for i := range items { items[i] = i } totalCount := len(items) truncated := false if len(items) > tc.limit { items = items[:tc.limit] truncated = true } // Verify invariants if totalCount > tc.limit { if !truncated { rt.Errorf("%s: truncated should be true when size %d > limit %d", tc.name, totalCount, tc.limit) } if len(items) != tc.limit { rt.Errorf("%s: expected exactly %d items after truncation, got %d", tc.name, tc.limit, len(items)) } } else { if truncated { rt.Errorf("%s: truncated should be false when size %d <= limit %d", tc.name, totalCount, tc.limit) } if len(items) != totalCount { rt.Errorf("%s: expected %d items (no truncation), got %d", tc.name, totalCount, len(items)) } } } }) } // ============================================================================ // 13.3 Property 11: Compact output line count equals item count // ============================================================================ // Feature: gortex-enhancements, Property 11: Compact output line count equals item count // // For any tool result with N items, when compact=true, the text output SHALL // contain exactly N non-empty lines. func TestPropertyCompactOutputLineCount(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { n := rapid.IntRange(0, 30).Draw(rt, "itemCount") // Test verify_change compact output t.Run("verify_change", func(_ *testing.T) { var b strings.Builder violations := make([]analysis.ContractViolation, n) for i := 0; i < n; i++ { violations[i] = analysis.ContractViolation{ SymbolID: "pkg/file.go::Func" + strings.Repeat("x", i), Kind: "caller_mismatch", FilePath: "pkg/file.go", Line: i + 1, Description: "test violation", } b.WriteString(violations[i].Kind + " " + violations[i].SymbolID + " " + violations[i].FilePath + ":" + strings.Repeat("1", 1) + " " + violations[i].Description + "\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("verify_change compact: expected %d non-empty lines, got %d", n, lines) } }) // Test check_guards compact output t.Run("check_guards", func(_ *testing.T) { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("co-change rule-" + strings.Repeat("x", i) + " description\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("check_guards compact: expected %d non-empty lines, got %d", n, lines) } }) // Test find_dead_code compact output t.Run("find_dead_code", func(_ *testing.T) { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("function pkg/file.go::Func" + strings.Repeat("x", i) + " pkg/file.go:1\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("find_dead_code compact: expected %d non-empty lines, got %d", n, lines) } }) // Test find_cycles compact output t.Run("find_cycles", func(_ *testing.T) { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("call-cycle severity=1 A → B → A\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("find_cycles compact: expected %d non-empty lines, got %d", n, lines) } }) // Test get_symbol_history compact output t.Run("get_symbol_history", func(_ *testing.T) { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("pkg/file.go::Func" + strings.Repeat("x", i) + " count=2\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("get_symbol_history compact: expected %d non-empty lines, got %d", n, lines) } }) // Test batch_edit compact output t.Run("batch_edit", func(_ *testing.T) { var b strings.Builder for i := 0; i < n; i++ { b.WriteString("pkg/file.go::Func" + strings.Repeat("x", i) + " pkg/file.go applied\n") } lines := countNonEmptyLines(b.String()) if lines != n { rt.Errorf("batch_edit compact: expected %d non-empty lines, got %d", n, lines) } }) }) } func countNonEmptyLines(s string) int { count := 0 for _, line := range strings.Split(s, "\n") { if strings.TrimSpace(line) != "" { count++ } } return count } // ============================================================================ // 13.4 Property 12: Health score formula // ============================================================================ // Feature: gortex-enhancements, Property 12: Health score formula // // For any indexer state, health score = round((successfully_indexed / total_detected) * 100, 1), // and recommendation is present iff score < 80%. func TestPropertyHealthScoreFormula(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { totalDetected := rapid.IntRange(1, 10000).Draw(rt, "totalDetected") failureCount := rapid.IntRange(0, totalDetected).Draw(rt, "failureCount") successfullyIndexed := totalDetected - failureCount if successfullyIndexed < 0 { successfullyIndexed = 0 } // Compute health score using the same formula as handleIndexHealth. var healthScore float64 if totalDetected > 0 { healthScore = math.Round(float64(successfullyIndexed)/float64(totalDetected)*1000) / 10 } // Verify the formula matches its spec: round((successfully_indexed / // total_detected) * 100, 1). The naive implementation // `math.Round(ratio*100 * 10) / 10` reorders the multiplies and // in binary float that straddles the 0.5 rounding boundary on // inputs like 5125/10000 → one path lands on 512.5, the other on // 512.4999…, and Round disagrees. Use the same expression order // production uses so we're verifying the production contract, not // hunting a float-reordering bug in the test scaffold. var expectedRounded float64 if totalDetected > 0 { expectedRounded = math.Round(float64(successfullyIndexed)/float64(totalDetected)*1000) / 10 } if math.Abs(healthScore-expectedRounded) > 0.01 { rt.Errorf("health score mismatch: got %.1f, expected %.1f (success=%d, total=%d)", healthScore, expectedRounded, successfullyIndexed, totalDetected) } // Verify score is in [0, 100]. if healthScore < 0 || healthScore > 100 { rt.Errorf("health score out of range [0,100]: %.1f", healthScore) } // Sanity bound: the rounded score must be within 0.1 of the // unrounded percentage. This is what actually pins the formula // to the documented spec — unlike the byte-for-byte comparison // above (which would only catch drift in the expression order). if totalDetected > 0 { raw := float64(successfullyIndexed) / float64(totalDetected) * 100 if math.Abs(healthScore-raw) > 0.05+1e-9 { rt.Errorf("health score %.1f differs from raw percentage %.4f by more than 0.05 (success=%d, total=%d)", healthScore, raw, successfullyIndexed, totalDetected) } } // Verify recommendation is present iff score < 80% var recommendation string if healthScore < 80 { recommendation = "Health score below 80%. Run index_repository with path \".\" to re-index the codebase." } if healthScore < 80 && recommendation == "" { rt.Errorf("recommendation should be present when health score %.1f < 80%%", healthScore) } if healthScore >= 80 && recommendation != "" { rt.Errorf("recommendation should be absent when health score %.1f >= 80%%", healthScore) } }) } // ============================================================================ // 13.5 Property 15: Batch edit dependency ordering // ============================================================================ // Feature: gortex-enhancements, Property 15: Batch edit dependency ordering // // Definitions are edited before callers. On failure at position N, // edits 0..N-1 are "applied", N is "failed", N+1..end are "skipped". func TestPropertyBatchEditDependencyOrdering(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { // Generate a set of edits with dependency relationships editCount := rapid.IntRange(2, 15).Draw(rt, "editCount") type editWithOrder struct { symbolID string kind graph.NodeKind order int file string } var edits []editWithOrder for i := 0; i < editCount; i++ { kind := graph.KindFunction order := 20 kindChoice := rapid.IntRange(0, 2).Draw(rt, "kindChoice") switch kindChoice { case 0: kind = graph.KindInterface order = 0 // definitions first case 1: kind = graph.KindType order = 0 // definitions first case 2: kind = graph.KindFunction order = 20 } edits = append(edits, editWithOrder{ symbolID: rapid.StringMatching(`[a-z]{3,6}/[a-z]{3,6}\.go::[A-Z][a-zA-Z]{2,6}`).Draw(rt, "symbolID"), kind: kind, order: order, file: "pkg/file.go", }) } // Sort by order ascending (same logic as handleBatchEdit) sort.SliceStable(edits, func(i, j int) bool { if edits[i].order != edits[j].order { return edits[i].order < edits[j].order } return edits[i].file < edits[j].file }) // Verify definitions come before callers lastDefOrder := -1 firstCallerOrder := math.MaxInt32 for _, e := range edits { if e.kind == graph.KindInterface || e.kind == graph.KindType { if e.order > lastDefOrder { lastDefOrder = e.order } } else { if e.order < firstCallerOrder { firstCallerOrder = e.order } } } if lastDefOrder >= 0 && firstCallerOrder < math.MaxInt32 { if lastDefOrder > firstCallerOrder { rt.Errorf("definitions (order=%d) should come before callers (order=%d)", lastDefOrder, firstCallerOrder) } } // Simulate failure at a random position and verify status assignment failPos := rapid.IntRange(0, editCount-1).Draw(rt, "failPos") type editResult struct { status string } var results []editResult failed := false for i := range edits { if failed { results = append(results, editResult{status: "skipped"}) continue } if i == failPos { results = append(results, editResult{status: "failed"}) failed = true continue } results = append(results, editResult{status: "applied"}) } // Verify: 0..failPos-1 are "applied", failPos is "failed", failPos+1..end are "skipped" for i, r := range results { if i < failPos { if r.status != "applied" { rt.Errorf("edit %d should be 'applied' but got '%s'", i, r.status) } } else if i == failPos { if r.status != "failed" { rt.Errorf("edit %d should be 'failed' but got '%s'", i, r.status) } } else { if r.status != "skipped" { rt.Errorf("edit %d should be 'skipped' but got '%s'", i, r.status) } } } }) } // ============================================================================ // 13.6 Property 16: Cross-community warning correctness // ============================================================================ // Feature: gortex-enhancements, Property 16: Cross-community warning correctness // // coupling_score = (cross_boundary_edges / total_edges_in_both) * 100, // tightly_coupled iff > 15%. func TestPropertyCrossCommunityWarningCorrectness(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { // Generate two communities with members and edges sizeA := rapid.IntRange(2, 10).Draw(rt, "sizeA") sizeB := rapid.IntRange(2, 10).Draw(rt, "sizeB") g := graph.New() membersA := make(map[string]bool) membersB := make(map[string]bool) var allMembersA, allMembersB []string for i := 0; i < sizeA; i++ { id := "commA/file.go::FuncA" + strings.Repeat("x", i) g.AddNode(&graph.Node{ ID: id, Kind: graph.KindFunction, Name: "FuncA", FilePath: "commA/file.go", StartLine: i + 1, Language: "go", }) membersA[id] = true allMembersA = append(allMembersA, id) } for i := 0; i < sizeB; i++ { id := "commB/file.go::FuncB" + strings.Repeat("y", i) g.AddNode(&graph.Node{ ID: id, Kind: graph.KindFunction, Name: "FuncB", FilePath: "commB/file.go", StartLine: i + 1, Language: "go", }) membersB[id] = true allMembersB = append(allMembersB, id) } // Add internal edges within A internalA := rapid.IntRange(0, sizeA).Draw(rt, "internalA") for i := 0; i < internalA; i++ { from := rapid.IntRange(0, sizeA-1).Draw(rt, "fromA") to := rapid.IntRange(0, sizeA-1).Draw(rt, "toA") if from != to { g.AddEdge(&graph.Edge{From: allMembersA[from], To: allMembersA[to], Kind: graph.EdgeCalls}) } } // Add internal edges within B internalB := rapid.IntRange(0, sizeB).Draw(rt, "internalB") for i := 0; i < internalB; i++ { from := rapid.IntRange(0, sizeB-1).Draw(rt, "fromB") to := rapid.IntRange(0, sizeB-1).Draw(rt, "toB") if from != to { g.AddEdge(&graph.Edge{From: allMembersB[from], To: allMembersB[to], Kind: graph.EdgeCalls}) } } // Add cross-boundary edges crossCount := rapid.IntRange(0, sizeA+sizeB).Draw(rt, "crossCount") for i := 0; i < crossCount; i++ { fromIdx := rapid.IntRange(0, sizeA-1).Draw(rt, "crossFrom") toIdx := rapid.IntRange(0, sizeB-1).Draw(rt, "crossTo") g.AddEdge(&graph.Edge{From: allMembersA[fromIdx], To: allMembersB[toIdx], Kind: graph.EdgeCalls}) } // Count edges manually using the same logic as computeCrossCommunityWarning edges := g.AllEdges() crossBoundary := 0 totalEdges := 0 for _, e := range edges { inA := membersA[e.From] || membersA[e.To] inB := membersB[e.From] || membersB[e.To] if inA || inB { totalEdges++ } if (membersA[e.From] && membersB[e.To]) || (membersB[e.From] && membersA[e.To]) { crossBoundary++ } } var expectedScore float64 if totalEdges > 0 { expectedScore = math.Round(float64(crossBoundary)/float64(totalEdges)*10000) / 100 } expectedTightlyCoupled := expectedScore > 15 // Build community result for the server method communities := &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "community-0", Label: "A", Members: allMembersA, Size: sizeA}, {ID: "community-1", Label: "B", Members: allMembersB, Size: sizeB}, }, NodeToComm: make(map[string]string), } for _, id := range allMembersA { communities.NodeToComm[id] = "community-0" } for _, id := range allMembersB { communities.NodeToComm[id] = "community-1" } // Create a minimal server to call computeCrossCommunityWarning srv := &Server{graph: g} warning := srv.computeCrossCommunityWarning( []string{"community-0", "community-1"}, communities, ) if warning == nil { rt.Fatalf("warning should not be nil for 2 communities") return // unreachable, but satisfies staticcheck } if len(warning.AffectedCommunities) != 2 { rt.Errorf("expected 2 affected communities, got %d", len(warning.AffectedCommunities)) } if len(warning.Couplings) != 1 { rt.Fatalf("expected 1 coupling pair, got %d", len(warning.Couplings)) } coupling := warning.Couplings[0] // Verify coupling score matches formula if math.Abs(coupling.CouplingScore-expectedScore) > 0.01 { rt.Errorf("coupling score mismatch: got %.2f, expected %.2f (cross=%d, total=%d)", coupling.CouplingScore, expectedScore, crossBoundary, totalEdges) } // Verify tightly_coupled flag if coupling.TightlyCoupled != expectedTightlyCoupled { rt.Errorf("tightly_coupled mismatch: got %v, expected %v (score=%.2f)", coupling.TightlyCoupled, expectedTightlyCoupled, coupling.CouplingScore) } }) } // ============================================================================ // 13.7 Property 17: Auto re-index rate limit // ============================================================================ // Feature: gortex-enhancements, Property 17: Auto re-index rate limit // // ensureFresh refreshes at most 5 files even when more are stale. func TestPropertyAutoReindexRateLimit(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { // Generate a list of stale file paths staleCount := rapid.IntRange(1, 50).Draw(rt, "staleCount") var staleFiles []string for i := 0; i < staleCount; i++ { staleFiles = append(staleFiles, "pkg/"+strings.Repeat("f", i+1)+".go") } // Simulate the ensureFresh rate limiting logic limit := 5 var refreshed []string for _, fp := range staleFiles { if len(refreshed) >= limit { break } // Simulate: all files are stale refreshed = append(refreshed, fp) } // Verify: at most 5 files refreshed if len(refreshed) > 5 { rt.Errorf("ensureFresh refreshed %d files, expected at most 5", len(refreshed)) } // Verify: when staleCount > 5, exactly 5 are refreshed if staleCount > 5 && len(refreshed) != 5 { rt.Errorf("with %d stale files, expected exactly 5 refreshed, got %d", staleCount, len(refreshed)) } // Verify: when staleCount <= 5, all are refreshed if staleCount <= 5 && len(refreshed) != staleCount { rt.Errorf("with %d stale files, expected all %d refreshed, got %d", staleCount, staleCount, len(refreshed)) } }) } // ============================================================================ // 14.1 Unit tests for tool handler edge cases // ============================================================================ // TestPrefetchNoContextReturnsError verifies that prefetch_context with empty // task and empty recent_symbols returns an error. func TestPrefetchNoContextReturnsError(t *testing.T) { srv := &Server{ session: newSessionState(), } // Both task and recent_symbols are empty — should get error. ctx := context.Background() req := mcplib.CallToolRequest{} req.Params.Name = "prefetch_context" req.Params.Arguments = map[string]any{ "task": "", "recent_symbols": "", } result, err := srv.handlePrefetchContext(ctx, req) if err != nil { t.Fatalf("unexpected error: %v", err) } if !result.IsError { t.Fatal("expected error result when no context provided") } text := result.Content[0].(mcplib.TextContent).Text if !strings.Contains(text, "insufficient context") { t.Errorf("expected 'insufficient context' in error, got: %s", text) } } // TestHealthScoreBelowThresholdIncludesRecommendation verifies that health // score < 80% includes a recommendation. func TestHealthScoreBelowThresholdIncludesRecommendation(t *testing.T) { // Test the recommendation logic directly tests := []struct { name string healthScore float64 wantRecommend bool }{ {"score_50_recommends", 50.0, true}, {"score_79_recommends", 79.9, true}, {"score_80_no_recommend", 80.0, false}, {"score_100_no_recommend", 100.0, false}, {"score_0_recommends", 0.0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var recommendation string if tt.healthScore < 80 { recommendation = "Health score below 80%. Run index_repository with path \".\" to re-index the codebase." } if tt.wantRecommend && recommendation == "" { t.Errorf("expected recommendation for health score %.1f", tt.healthScore) } if !tt.wantRecommend && recommendation != "" { t.Errorf("unexpected recommendation for health score %.1f", tt.healthScore) } }) } } // TestSingleCommunityImpactHasNullWarning verifies that when the blast radius // is within a single community, cross_community_warning is nil. func TestSingleCommunityImpactHasNullWarning(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkg/a.go::FuncA", Kind: graph.KindFunction, Name: "FuncA", FilePath: "pkg/a.go", Language: "go"}) g.AddNode(&graph.Node{ID: "pkg/a.go::FuncB", Kind: graph.KindFunction, Name: "FuncB", FilePath: "pkg/a.go", Language: "go"}) g.AddEdge(&graph.Edge{From: "pkg/a.go::FuncA", To: "pkg/a.go::FuncB", Kind: graph.EdgeCalls}) communities := &analysis.CommunityResult{ Communities: []analysis.Community{ {ID: "community-0", Label: "A", Members: []string{"pkg/a.go::FuncA", "pkg/a.go::FuncB"}, Size: 2}, }, NodeToComm: map[string]string{ "pkg/a.go::FuncA": "community-0", "pkg/a.go::FuncB": "community-0", }, } // With only 1 affected community, computeCrossCommunityWarning should not be called. // The handler sets cross_community_warning = nil when < 2 communities. affectedCommunities := []string{"community-0"} if len(affectedCommunities) >= 2 { t.Fatal("test setup error: expected single community") } // Verify the logic: single community → warning is nil var warning *CrossCommunityWarning if len(affectedCommunities) >= 2 { srv := &Server{graph: g} warning = srv.computeCrossCommunityWarning(affectedCommunities, communities) } if warning != nil { t.Errorf("expected nil warning for single community, got: %+v", warning) } } // TestWatchModeActiveSkipsAutoReindex verifies that ensureFresh returns nil // when watcher is active. func TestWatchModeActiveSkipsAutoReindex(t *testing.T) { srv := &Server{ watcher: &indexer.Watcher{}, // non-nil watcher means watch mode is active } result := srv.ensureFresh([]string{"pkg/file.go", "pkg/other.go"}) if result != nil { t.Errorf("expected nil when watcher is active, got: %v", result) } } // TestAutoReindexFailureReturnsStaleDataWithWarning verifies that when // ensureFresh cannot re-index a file, it continues without crashing. // The ensureFresh method logs a warning and skips the file. func TestAutoReindexFailureReturnsStaleDataWithWarning(t *testing.T) { // ensureFresh with nil indexer returns nil (no crash) srv := &Server{ indexer: nil, watcher: nil, } result := srv.ensureFresh([]string{"nonexistent/file.go"}) if result != nil { t.Errorf("expected nil when indexer is nil, got: %v", result) } } // TestBatchEditDryRunReturnsPlanOnly verifies that batch_edit with dry_run=true // returns a plan without applying changes. func TestBatchEditDryRunReturnsPlanOnly(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ ID: "pkg/a.go::FuncA", Kind: graph.KindFunction, Name: "FuncA", FilePath: "pkg/a.go", StartLine: 1, EndLine: 5, Language: "go", }) g.AddNode(&graph.Node{ ID: "pkg/b.go::TypeB", Kind: graph.KindType, Name: "TypeB", FilePath: "pkg/b.go", StartLine: 1, EndLine: 3, Language: "go", }) eng := query.NewEngine(g) srv := &Server{ graph: g, engine: eng, } editsJSON := `[{"id":"pkg/b.go::TypeB","old_source":"old","new_source":"new"},{"id":"pkg/a.go::FuncA","old_source":"old","new_source":"new"}]` ctx := context.Background() req := mcplib.CallToolRequest{} req.Params.Name = "batch_edit" req.Params.Arguments = map[string]any{ "edits": editsJSON, "dry_run": true, } result, err := srv.handleBatchEdit(ctx, req) if err != nil { t.Fatalf("unexpected error: %v", err) } if result.IsError { t.Fatalf("unexpected tool error: %v", result.Content) } // Parse the JSON result text := result.Content[0].(mcplib.TextContent).Text var resp map[string]any if err := json.Unmarshal([]byte(text), &resp); err != nil { t.Fatalf("failed to parse response: %v", err) } // Verify dry_run flag is true if dryRun, ok := resp["dry_run"].(bool); !ok || !dryRun { t.Error("expected dry_run=true in response") } // Verify plan is present plan, ok := resp["plan"].([]any) if !ok { t.Fatal("expected plan array in response") } if len(plan) != 2 { t.Errorf("expected 2 items in plan, got %d", len(plan)) } // Verify all entries have status "planned" for i, entry := range plan { m := entry.(map[string]any) if m["status"] != "planned" { t.Errorf("plan entry %d: expected status 'planned', got '%s'", i, m["status"]) } } // Verify types come before functions in the plan (dependency ordering) firstEntry := plan[0].(map[string]any) if firstEntry["id"] != "pkg/b.go::TypeB" { t.Errorf("expected TypeB first in dependency order, got %s", firstEntry["id"]) } } // ============================================================================ // 14.2 Integration tests // ============================================================================ // TestIntegrationGuardRulesLoadedFromConfig verifies that guard rules defined // in a .gortex.yaml file are loaded and available to the server. func TestIntegrationGuardRulesLoadedFromConfig(t *testing.T) { dir := t.TempDir() yamlContent := `guards: rules: - name: parser-tests kind: co-change source: "internal/parser" target: "internal/parser/languages" message: "Parser changes require language extractor test updates" - name: no-direct-graph kind: boundary source: "internal/mcp" target: "internal/graph" message: "MCP tools must use query.Engine, not graph.Graph directly" ` configPath := filepath.Join(dir, ".gortex.yaml") if err := os.WriteFile(configPath, []byte(yamlContent), 0o644); err != nil { t.Fatalf("failed to write config: %v", err) } cfg, err := config.Load(configPath) if err != nil { t.Fatalf("failed to load config: %v", err) } if len(cfg.Guards.Rules) != 2 { t.Fatalf("expected 2 guard rules, got %d", len(cfg.Guards.Rules)) } // Verify first rule r0 := cfg.Guards.Rules[0] if r0.Name != "parser-tests" { t.Errorf("rule[0].Name = %q, want %q", r0.Name, "parser-tests") } if r0.Kind != "co-change" { t.Errorf("rule[0].Kind = %q, want %q", r0.Kind, "co-change") } if r0.Source != "internal/parser" { t.Errorf("rule[0].Source = %q, want %q", r0.Source, "internal/parser") } if r0.Target != "internal/parser/languages" { t.Errorf("rule[0].Target = %q, want %q", r0.Target, "internal/parser/languages") } // Verify second rule r1 := cfg.Guards.Rules[1] if r1.Name != "no-direct-graph" { t.Errorf("rule[1].Name = %q, want %q", r1.Name, "no-direct-graph") } if r1.Kind != "boundary" { t.Errorf("rule[1].Kind = %q, want %q", r1.Kind, "boundary") } // Verify rules can be passed to NewServer and used g := graph.New() eng := query.NewEngine(g) srv := NewServer(eng, g, nil, nil, zap.NewNop(), cfg.Guards.Rules) if len(srv.guardRules) != 2 { t.Errorf("server has %d guard rules, want 2", len(srv.guardRules)) } }