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

311 lines
11 KiB
Go

package mcp
import (
"context"
"encoding/json"
"strings"
"testing"
mcplib "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// callPRReviewContext invokes the registered pr_review_context tool through
// the full dispatch path and asserts no transport error.
func callPRReviewContext(t *testing.T, srv *Server, ctx context.Context, args map[string]any) *mcplib.CallToolResult {
t.Helper()
return callToolByName(t, srv, ctx, "pr_review_context", args)
}
// prReviewOut is the decoded JSON envelope.
type prReviewOut struct {
Verdict string `json:"verdict"`
ChangedSymbols int `json:"changed_symbols"`
ChangedFiles []string `json:"changed_files"`
Gates []struct {
Name string `json:"name"`
Status string `json:"status"`
Detail string `json:"detail"`
} `json:"gates"`
DiffContext []struct {
ID string `json:"id"`
Risk string `json:"risk"`
Callers []string `json:"callers"`
} `json:"diff_context"`
Verify *struct {
Clean bool `json:"clean"`
Violations []struct {
SymbolID string `json:"symbol_id"`
Kind string `json:"kind"`
} `json:"violations"`
} `json:"verify"`
Simulation *struct {
Ran bool `json:"ran"`
Note string `json:"note"`
GraphUntouched bool `json:"graph_untouched"`
TotalSteps int `json:"total_steps"`
SessionID string `json:"session_id"`
} `json:"simulation"`
ConfigAudit map[string]any `json:"config_audit"`
}
func decodePRReview(t *testing.T, res *mcplib.CallToolResult) prReviewOut {
t.Helper()
require.False(t, res.IsError, "errored: %s", toolText(res))
var out prReviewOut
require.NoError(t, json.Unmarshal([]byte(toolText(res)), &out))
return out
}
func gateStatus(out prReviewOut, name string) (string, bool) {
for _, g := range out.Gates {
if g.Name == name {
return g.Status, true
}
}
return "", false
}
// TestPRReviewContext_ToolRegistered confirms the tool wired up.
func TestPRReviewContext_ToolRegistered(t *testing.T) {
dir, _, _, _ := siblingDiffGitRepo(t)
srv := indexedSiblingServer(t, dir)
require.NotNil(t, srv.MCPServer().GetTool("pr_review_context"),
"pr_review_context must be registered")
}
// TestPRReviewContext_AllSections runs the rollup over a real changeset: the
// diff_context section is populated, the audit section is present, and the
// verdict is PASS (the body-only edits break no callers).
func TestPRReviewContext_AllSections(t *testing.T) {
dir, fileA, _, _ := siblingDiffGitRepo(t)
srv := indexedSiblingServer(t, dir)
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"base": "base-ref",
}))
require.Greater(t, out.ChangedSymbols, 0, "the changeset has changed symbols")
require.NotEmpty(t, out.DiffContext, "diff_context section is populated")
require.NotEmpty(t, out.ChangedFiles)
require.Contains(t, out.ChangedFiles, fileA)
// verify_change ran (signature-bearing symbols) and found nothing broken.
require.NotNil(t, out.Verify, "verify section present for signature-bearing changes")
require.True(t, out.Verify.Clean, "body-only edits break no callers")
// audit section is always present (gate row + config_audit block).
_, hasAudit := gateStatus(out, "audit_agent_config")
require.True(t, hasAudit, "audit gate present")
// diff_context gate present.
_, hasDC := gateStatus(out, "diff_context")
require.True(t, hasDC)
require.Equal(t, prReviewPass, out.Verdict, "clean changeset is PASS")
}
// TestPRReviewContext_EmptyDiff: a working tree with no diff yields a PASS
// verdict and zero changed symbols.
func TestPRReviewContext_EmptyDiff(t *testing.T) {
dir, _, _, _ := siblingDiffGitRepo(t)
srv := indexedSiblingServer(t, dir)
// scope=staged with nothing staged => empty diff.
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"scope": "staged",
}))
require.Equal(t, 0, out.ChangedSymbols)
require.Equal(t, prReviewPass, out.Verdict)
}
// TestPRReviewContext_SectionsFilter: a `sections` filter drops the
// unrequested blocks.
func TestPRReviewContext_SectionsFilter(t *testing.T) {
dir, _, _, _ := siblingDiffGitRepo(t)
srv := indexedSiblingServer(t, dir)
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"base": "base-ref",
"sections": "diff_context",
}))
require.NotEmpty(t, out.DiffContext, "requested diff_context present")
require.Nil(t, out.Verify, "verify omitted when not in sections")
require.Nil(t, out.ConfigAudit, "config_audit omitted when not in sections")
_, hasVerifyGate := gateStatus(out, "verify_change")
require.False(t, hasVerifyGate, "no verify gate when section filtered out")
}
// buildInterfaceBreakGraph builds a graph with an interface Speaker and two
// implementors Dog (Speak(loud bool)) and Cat (Speak() — mismatching arity).
// Returns the server and the changed method's ID (Dog.Speak). Verifying
// Dog.Speak's surface must flag Cat.Speak as a broken implementor.
func buildInterfaceBreakGraph(t *testing.T) (*Server, string) {
t.Helper()
g := graph.New()
add := func(id, name string, kind graph.NodeKind, sig string) {
n := &graph.Node{ID: id, Name: name, Kind: kind, FilePath: pathOf(id), Language: "go"}
if sig != "" {
n.Meta = map[string]any{"signature": sig}
}
g.AddNode(n)
}
add("pkg/iface.go::Speaker", "Speaker", graph.KindInterface, "")
add("pkg/dog.go::Dog", "Dog", graph.KindType, "")
add("pkg/cat.go::Cat", "Cat", graph.KindType, "")
add("pkg/dog.go::Dog.Speak", "Speak", graph.KindMethod, "func (d Dog) Speak(loud bool)")
add("pkg/cat.go::Cat.Speak", "Speak", graph.KindMethod, "func (c Cat) Speak()")
// method → type (member_of); type → interface (implements).
g.AddEdge(&graph.Edge{From: "pkg/dog.go::Dog.Speak", To: "pkg/dog.go::Dog", Kind: graph.EdgeMemberOf})
g.AddEdge(&graph.Edge{From: "pkg/cat.go::Cat.Speak", To: "pkg/cat.go::Cat", Kind: graph.EdgeMemberOf})
g.AddEdge(&graph.Edge{From: "pkg/dog.go::Dog", To: "pkg/iface.go::Speaker", Kind: graph.EdgeImplements})
g.AddEdge(&graph.Edge{From: "pkg/cat.go::Cat", To: "pkg/iface.go::Speaker", Kind: graph.EdgeImplements})
srv := NewServer(query.NewEngine(g), g, nil, nil, zap.NewNop(), nil)
return srv, "pkg/dog.go::Dog.Speak"
}
func pathOf(id string) string {
if i := strings.Index(id, "::"); i >= 0 {
return id[:i]
}
return id
}
// TestPRReviewContext_VerifyBrokenImplementor: when a changed interface
// method's surface no longer matches a sibling implementor, the verify
// section reports the broken implementor and the verdict is BLOCK. This
// exercises VerifyChanges threaded with s.engine.
func TestPRReviewContext_VerifyBrokenImplementor(t *testing.T) {
srv, changedID := buildInterfaceBreakGraph(t)
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"ids": changedID,
"audit_config": false, // no working tree
}))
require.NotNil(t, out.Verify, "verify section present")
require.False(t, out.Verify.Clean, "a broken implementor makes the verify result dirty")
require.NotEmpty(t, out.Verify.Violations, "Cat.Speak is a broken implementor")
var sawCat bool
for _, v := range out.Verify.Violations {
if v.SymbolID == "pkg/cat.go::Cat.Speak" {
sawCat = true
}
}
require.True(t, sawCat, "the mismatching sibling implementor is reported")
status, ok := gateStatus(out, "verify_change")
require.True(t, ok)
require.Equal(t, prReviewBlock, status)
require.Equal(t, prReviewBlock, out.Verdict, "a broken implementor blocks")
}
// TestPRReviewContext_SimulateOmittedWithoutSession: edits supplied but no
// overlay session id => the simulation section is omitted-with-note (no
// panic), and the base graph is left untouched.
func TestPRReviewContext_SimulateOmittedWithoutSession(t *testing.T) {
srv, dir, targetFile, _ := setupOverlayServer(t)
before := baseNodeIDs(srv)
edits := "[" + buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\nfunc Added() {}\n") + "]"
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"repo": dir,
"scope": "staged", // empty diff is fine; we exercise the simulate gate
"edits": edits,
"sections": "simulate",
"audit_config": false,
}))
require.NotNil(t, out.Simulation, "simulation section present (omitted-with-note form)")
require.False(t, out.Simulation.Ran, "no session => simulation does not run")
require.NotEmpty(t, out.Simulation.Note, "a clear note explains the omission")
require.Contains(t, out.Simulation.Note, "session")
require.True(t, out.Simulation.GraphUntouched)
status, ok := gateStatus(out, "simulate_chain")
require.True(t, ok)
require.Equal(t, prReviewPass, status, "a skipped simulation does not block")
require.Equal(t, before, baseNodeIDs(srv),
"base graph node set is unchanged by the (skipped) simulation")
}
// TestPRReviewContext_SimulateRunsWithSession: with an explicit overlay
// session id the simulation runs, and the base graph is still left
// untouched.
func TestPRReviewContext_SimulateRunsWithSession(t *testing.T) {
srv, dir, targetFile, _ := setupOverlayServer(t)
before := baseNodeIDs(srv)
sessID := "pr-review-sim"
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
edits := "[" + buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\nfunc Added() {}\n") + "]"
// Drive via the explicit session_id param (not the context) to prove the
// param is the gate.
out := decodePRReview(t, callPRReviewContext(t, srv, context.Background(), map[string]any{
"repo": dir,
"scope": "staged",
"edits": edits,
"session_id": sessID,
"sections": "simulate",
"audit_config": false,
}))
require.NotNil(t, out.Simulation)
require.True(t, out.Simulation.Ran, "an explicit session id enables the simulation")
require.Equal(t, sessID, out.Simulation.SessionID)
require.GreaterOrEqual(t, out.Simulation.TotalSteps, 1)
require.True(t, out.Simulation.GraphUntouched)
require.Equal(t, before, baseNodeIDs(srv),
"base graph node set is unchanged after a kept-false simulation")
}
// TestPRReviewContext_GCXAndBudget: the gcx, toon, and max_bytes paths all
// round-trip.
func TestPRReviewContext_GCXAndBudget(t *testing.T) {
dir, _, _, _ := siblingDiffGitRepo(t)
srv := indexedSiblingServer(t, dir)
t.Run("gcx", func(t *testing.T) {
res := callPRReviewContext(t, srv, context.Background(), map[string]any{
"base": "base-ref",
"format": "gcx",
})
require.False(t, res.IsError, "%s", toolText(res))
txt := toolText(res)
require.Contains(t, txt, "pr_review_context.summary")
require.Contains(t, txt, "pr_review_context.gates")
})
t.Run("toon", func(t *testing.T) {
res := callPRReviewContext(t, srv, context.Background(), map[string]any{
"base": "base-ref",
"format": "toon",
})
require.False(t, res.IsError, "%s", toolText(res))
require.NotEmpty(t, toolText(res))
})
t.Run("max_bytes", func(t *testing.T) {
res := callPRReviewContext(t, srv, context.Background(), map[string]any{
"base": "base-ref",
"max_bytes": 400,
})
require.False(t, res.IsError, "%s", toolText(res))
// A tight budget still returns a parseable result (verdict is on the
// first/summary block, never trimmed).
require.NotEmpty(t, toolText(res))
})
}