Files
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

761 lines
26 KiB
Go

package mcp
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
wire "github.com/gortexhq/gcx-go"
"github.com/zzet/gortex/internal/analysis"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
func newTestNode(id, name string, kind graph.NodeKind, path string, line int) *graph.Node {
return &graph.Node{
ID: id,
Name: name,
Kind: kind,
FilePath: path,
StartLine: line,
EndLine: line + 5,
Meta: map[string]any{"signature": "func " + name + "()"},
}
}
func TestEncodeSearchSymbols_HeaderAndRows(t *testing.T) {
nodes := []*graph.Node{
newTestNode("a.go::Foo", "Foo", graph.KindFunction, "a.go", 10),
newTestNode("b.go::Decoder.Bar", "Bar", graph.KindMethod, "b.go", 20),
}
nodes[0].Meta["type_flavor"] = "struct"
nodes[0].Meta["ui_component"] = "react"
payload, err := encodeSearchSymbols(nodes, 2, 10)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "search_symbols", h.Tool)
require.Equal(t, []string{"id", "kind", "name", "path", "path_abs", "line", "sig", "enclosing", "is_test", "test_role", "test_runner", "type_flavor", "ui_component"}, h.Fields)
require.Equal(t, "2", h.Meta["total"])
require.Equal(t, "false", h.Meta["truncated"])
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 2)
require.Equal(t, "a.go::Foo", rows[0]["id"])
require.Equal(t, "function", rows[0]["kind"])
require.Equal(t, "Foo", rows[0]["name"])
require.Equal(t, "10", rows[0]["line"])
require.Equal(t, "func Foo()", rows[0]["sig"])
require.Equal(t, "", rows[0]["path_abs"], "path_abs column present, empty when the node carries no resolved absolute path")
require.Equal(t, "false", rows[0]["is_test"])
require.Equal(t, "", rows[0]["test_role"])
// A top-level function has no enclosing owner; a method does.
require.Equal(t, "", rows[0]["enclosing"], "a top-level function has no enclosing owner")
require.Equal(t, "Decoder", rows[1]["enclosing"], "a method reports its receiver type as the enclosing owner")
// New structural-flavor columns surface the node's own type_flavor /
// ui_component, empty when the node carries neither.
require.Equal(t, "struct", rows[0]["type_flavor"])
require.Equal(t, "react", rows[0]["ui_component"])
require.Equal(t, "", rows[1]["type_flavor"])
require.Equal(t, "", rows[1]["ui_component"])
}
func TestEncodeSearchSymbols_RespectsLimitAndTruncation(t *testing.T) {
nodes := make([]*graph.Node, 5)
for i := range nodes {
nodes[i] = newTestNode("x.go::N", "N", graph.KindFunction, "x.go", i)
}
payload, err := encodeSearchSymbols(nodes, 5, 3)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, _ := dec.Header()
require.Equal(t, "true", h.Meta["truncated"])
rows, _ := dec.All()
require.Len(t, rows, 3)
}
func TestEncodeSearchSymbols_SkipsFileAndImport(t *testing.T) {
nodes := []*graph.Node{
newTestNode("f.go", "f.go", graph.KindFile, "f.go", 1),
newTestNode("f.go::Foo", "Foo", graph.KindFunction, "f.go", 5),
newTestNode("f.go::imp", "imp", graph.KindImport, "f.go", 2),
}
payload, err := encodeSearchSymbols(nodes, 3, 10)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
_, _ = dec.Header()
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "Foo", rows[0]["name"])
}
func TestEncodeGetSymbolSource_EmbeddedNewlinesRoundTrip(t *testing.T) {
node := newTestNode("f.go::Foo", "Foo", graph.KindFunction, "f.go", 10)
src := "func Foo() {\n\tfmt.Println(\"x\\ty\")\n}"
payload, err := encodeGetSymbolSource(node, src, 9, "etag123", "")
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "get_symbol_source", h.Tool)
require.Equal(t, "etag123", h.Meta["etag"])
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, src, rows[0]["source"])
require.Equal(t, "9", rows[0]["from_line"])
require.Equal(t, "etag123", rows[0]["etag"])
}
func TestEncodeBatchSymbols_IncludeSource(t *testing.T) {
rows := []map[string]any{
{
"id": "a.go::Foo",
"kind": graph.KindFunction,
"name": "Foo",
"file_path": "a.go",
"start_line": 10,
"end_line": 20,
"signature": "func Foo()",
"source": "func Foo() {}",
},
{
"id": "x.go::Missing",
"error": "symbol not found",
},
}
payload, err := encodeBatchSymbols(rows, true)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Contains(t, h.Fields, "source")
require.Contains(t, h.Fields, "error")
got, err := dec.All()
require.NoError(t, err)
require.Len(t, got, 2)
require.Equal(t, "func Foo()", got[0]["sig"])
require.Equal(t, "symbol not found", got[1]["error"])
}
// TestEncodeSubGraph_DistinctCallSitesNotDeduped pins the regression
// for the walk_graph / get_callers duplicate-row complaint: two
// distinct call sites between the same caller and callee must appear
// as two rows the agent can tell apart by line + file_path, not
// collapse into identical wire rows.
func TestEncodeSubGraph_DistinctCallSitesNotDeduped(t *testing.T) {
sg := &query.SubGraph{
Nodes: []*graph.Node{
newTestNode("a.go::Caller", "Caller", graph.KindFunction, "a.go", 10),
newTestNode("b.go::Target", "Target", graph.KindFunction, "b.go", 20),
},
Edges: []*graph.Edge{
{From: "a.go::Caller", To: "b.go::Target", Kind: "calls", Confidence: 0.9, Origin: "ast_resolved", FilePath: "a.go", Line: 27},
{From: "a.go::Caller", To: "b.go::Target", Kind: "calls", Confidence: 0.9, Origin: "ast_resolved", FilePath: "a.go", Line: 42},
},
TotalNodes: 2,
}
payload, err := encodeSubGraph("walk_graph", sg)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
_, err = dec.Header()
require.NoError(t, err)
_, err = dec.All()
require.NoError(t, err)
h2, err := dec.NextSection()
require.NoError(t, err)
require.Equal(t, "walk_graph.edges", h2.Tool)
require.Contains(t, h2.Fields, "line")
require.Contains(t, h2.Fields, "file_path")
edges, err := dec.All()
require.NoError(t, err)
require.Len(t, edges, 2)
require.Equal(t, "27", edges[0]["line"])
require.Equal(t, "42", edges[1]["line"])
}
func TestEncodeSubGraph_NodesAndEdgesSections(t *testing.T) {
sg := &query.SubGraph{
Nodes: []*graph.Node{
newTestNode("a.go::Foo", "Foo", graph.KindFunction, "a.go", 10),
newTestNode("b.go::Bar", "Bar", graph.KindFunction, "b.go", 20),
},
Edges: []*graph.Edge{
{From: "a.go::Foo", To: "b.go::Bar", Kind: "calls", Confidence: 0.9, Origin: "ast_resolved"},
},
TotalNodes: 2,
}
payload, err := encodeSubGraph("get_callers", sg)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h1, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "get_callers.nodes", h1.Tool)
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 2)
h2, err := dec.NextSection()
require.NoError(t, err)
require.Equal(t, "get_callers.edges", h2.Tool)
edges, err := dec.All()
require.NoError(t, err)
require.Len(t, edges, 1)
require.Equal(t, "calls", edges[0]["kind"])
require.Equal(t, "ast_resolved", edges[0]["origin"])
}
func TestEncodeFindUsages_OneRowPerEdge(t *testing.T) {
sg := &query.SubGraph{
Nodes: []*graph.Node{
newTestNode("a.go::Caller", "Caller", graph.KindFunction, "a.go", 10),
newTestNode("b.go::Target", "Target", graph.KindFunction, "b.go", 20),
},
Edges: []*graph.Edge{
{From: "a.go::Caller", To: "b.go::Target", Kind: "calls", Origin: "lsp_resolved", Confidence: 1.0},
},
}
payload, err := encodeFindUsages(sg, nil)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
_, err = dec.Header()
require.NoError(t, err)
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, "a.go::Caller", rows[0]["from"])
require.Equal(t, "b.go::Target", rows[0]["to"])
require.Equal(t, "Caller", rows[0]["from_name"])
// Edge with no Line falls back to the caller's start line.
require.Equal(t, "10", rows[0]["from_line"])
}
// TestEncodeFindUsages_FromLineIsCallSite pins the call-site-line
// behaviour: when an edge carries its own Line (the actual offset of
// the `Target(...)` call expression), find_usages must surface that
// — not the enclosing caller's start line. Two calls from the same
// caller used to collapse onto the caller's first line, which made
// it impossible to tell duplicate-looking rows apart.
func TestEncodeFindUsages_FromLineIsCallSite(t *testing.T) {
sg := &query.SubGraph{
Nodes: []*graph.Node{
newTestNode("a.go::Caller", "Caller", graph.KindFunction, "a.go", 10),
newTestNode("b.go::Target", "Target", graph.KindFunction, "b.go", 20),
},
Edges: []*graph.Edge{
{From: "a.go::Caller", To: "b.go::Target", Kind: "calls", Origin: "lsp_resolved", Confidence: 1.0, FilePath: "a.go", Line: 27},
{From: "a.go::Caller", To: "b.go::Target", Kind: "calls", Origin: "lsp_resolved", Confidence: 1.0, FilePath: "a.go", Line: 42},
},
}
payload, err := encodeFindUsages(sg, nil)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
_, err = dec.Header()
require.NoError(t, err)
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 2)
require.Equal(t, "27", rows[0]["from_line"])
require.Equal(t, "42", rows[1]["from_line"])
}
// TestEncodeFindUsages_FromFlavor pins the owner-resolution surfacing:
// the from_type_flavor column reports the FROM site's enclosing owner
// type's flavor, and from_ui_component the FROM node's own framework.
func TestEncodeFindUsages_FromFlavor(t *testing.T) {
g := graph.New()
owner := newTestNode("b.go::Svc", "Svc", graph.KindType, "b.go", 5)
owner.Meta["type_flavor"] = "struct"
caller := newTestNode("b.go::Svc.Run", "Run", graph.KindMethod, "b.go", 10)
caller.Meta["ui_component"] = "react"
target := newTestNode("c.go::Target", "Target", graph.KindFunction, "c.go", 20)
g.AddNode(owner)
g.AddNode(caller)
g.AddNode(target)
sg := &query.SubGraph{
Nodes: []*graph.Node{caller, target},
Edges: []*graph.Edge{
{From: "b.go::Svc.Run", To: "c.go::Target", Kind: "calls", Origin: "ast_resolved", Confidence: 1.0, Line: 12},
},
}
payload, err := encodeFindUsages(sg, g)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
_, err = dec.Header()
require.NoError(t, err)
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, "struct", rows[0]["from_type_flavor"], "owner type's flavor")
require.Equal(t, "react", rows[0]["from_ui_component"], "caller's own ui_component")
}
func TestEncodeAnalyze_DeadCode(t *testing.T) {
items := []deadCodeItem{
{ID: "a.go::Unused", Kind: "function", Name: "Unused", Path: "a.go", Line: 42, Reason: "no incoming edges"},
}
payload, err := encodeAnalyze("dead_code", items)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, _ := dec.Header()
require.Equal(t, "analyze.dead_code", h.Tool)
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "Unused", rows[0]["name"])
require.Equal(t, "no incoming edges", rows[0]["reason"])
}
func TestEncodeAnalyze_UnknownKindFallsBackToGeneric(t *testing.T) {
payload, err := encodeAnalyze("weird", map[string]any{"x": 1})
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "analyze.weird", h.Tool)
}
func TestEncodeContractsList_FlattensByRepoAndPromotesMethodPath(t *testing.T) {
rows := []contracts.Contract{
{
ID: "http::GET::/search",
Type: contracts.ContractHTTP,
Role: contracts.RoleProvider,
SymbolID: "cmd/api/main.go::realMain",
FilePath: "sapi-backend/cmd/api/main.go",
Line: 531,
RepoPrefix: "sapi-backend",
Confidence: 0.9,
Meta: map[string]any{"method": "GET", "path": "/search", "framework": "gin/echo/chi"},
},
{
ID: "dep::github.com/FindHotel/raa-sdk",
Type: contracts.ContractDependency,
Role: contracts.RoleConsumer,
FilePath: "sapi-backend/go.mod",
Line: 21,
RepoPrefix: "sapi-backend",
Confidence: 1,
Meta: map[string]any{"module": "github.com/FindHotel/raa-sdk", "version": "v0.102.0"},
},
}
payload, err := encodeContractsList(rows, len(rows))
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "contracts.list", h.Tool)
require.Equal(t, "2", h.Meta["total"])
require.Equal(t, contractFields, h.Fields)
got, err := dec.All()
require.NoError(t, err)
require.Len(t, got, 2)
require.Equal(t, "http", got[0]["type"])
require.Equal(t, "provider", got[0]["role"])
require.Equal(t, "sapi-backend", got[0]["repo"])
require.Equal(t, "GET", got[0]["method"])
require.Equal(t, "/search", got[0]["path"])
require.Equal(t, "531", got[0]["line"])
require.Equal(t, "framework=gin/echo/chi", got[0]["meta"], "method/path must be excluded from meta column")
require.Equal(t, "dependency", got[1]["type"])
require.Equal(t, "", got[1]["method"])
require.Equal(t, "", got[1]["path"])
require.Equal(t, "module=github.com/FindHotel/raa-sdk;version=v0.102.0", got[1]["meta"])
}
func TestEncodeContractsCheck_EmitsThreeSections(t *testing.T) {
provider := contracts.Contract{
ID: "http::GET::/x", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
FilePath: "a/provider.go", Line: 10, RepoPrefix: "a",
}
consumer := contracts.Contract{
ID: "http::GET::/x", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
FilePath: "b/consumer.go", Line: 20, RepoPrefix: "b",
}
orphanProv := contracts.Contract{
ID: "http::GET::/dead", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
FilePath: "a/dead.go", Line: 5, RepoPrefix: "a", Meta: map[string]any{"method": "GET", "path": "/dead"},
}
orphanCons := contracts.Contract{
ID: "http::GET::/nowhere", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
FilePath: "c/lost.go", Line: 8, RepoPrefix: "c",
}
result := contracts.MatchResult{
Matched: []contracts.CrossLink{
{ContractID: "http::GET::/x", Provider: provider, Consumer: consumer, CrossRepo: true},
},
OrphanProviders: []contracts.Contract{orphanProv},
OrphanConsumers: []contracts.Contract{orphanCons},
}
payload, err := encodeContractsCheck(result)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h1, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "contracts.check.matched", h1.Tool)
require.Equal(t, "1", h1.Meta["count"])
matched, err := dec.All()
require.NoError(t, err)
require.Len(t, matched, 1)
require.Equal(t, "http::GET::/x", matched[0]["contract_id"])
require.Equal(t, "true", matched[0]["cross_repo"])
require.Equal(t, "a", matched[0]["provider_repo"])
require.Equal(t, "b", matched[0]["consumer_repo"])
h2, err := dec.NextSection()
require.NoError(t, err)
require.Equal(t, "contracts.check.orphan_providers", h2.Tool)
orphans, err := dec.All()
require.NoError(t, err)
require.Len(t, orphans, 1)
require.Equal(t, "GET", orphans[0]["method"])
require.Equal(t, "/dead", orphans[0]["path"])
h3, err := dec.NextSection()
require.NoError(t, err)
require.Equal(t, "contracts.check.orphan_consumers", h3.Tool)
cons, err := dec.All()
require.NoError(t, err)
require.Len(t, cons, 1)
require.Equal(t, "c", cons[0]["repo"])
}
func TestEncodeEditingContext_FourSectionsWithFileMeta(t *testing.T) {
file := map[string]any{"id": "pkg/foo.go", "language": "go"}
defines := []map[string]any{
{"id": "pkg/foo.go::Foo", "kind": "function", "name": "Foo", "start_line": 10, "signature": "func Foo()"},
}
imports := []map[string]any{
{"id": "external::fmt", "external": true},
}
calledBy := []map[string]any{
{"id": "pkg/bar.go::Bar", "name": "Bar", "file_path": "pkg/bar.go", "start_line": 5},
}
calls := []map[string]any{
{"id": "pkg/baz.go::Baz", "name": "Baz", "file_path": "pkg/baz.go", "start_line": 3},
}
payload, err := encodeEditingContext(file, defines, imports, calledBy, calls, "etag-xyz", "")
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "get_editing_context.defines", h.Tool)
require.Equal(t, "etag-xyz", h.Meta["etag"])
require.Equal(t, "go", h.Meta["language"])
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "func Foo()", rows[0]["sig"])
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "get_editing_context.imports", h.Tool)
rows, _ = dec.All()
require.Len(t, rows, 1)
require.Equal(t, "true", rows[0]["external"])
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "get_editing_context.called_by", h.Tool)
rows, _ = dec.All()
require.Len(t, rows, 1)
require.Equal(t, "pkg/bar.go", rows[0]["path"])
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "get_editing_context.calls", h.Tool)
rows, _ = dec.All()
require.Len(t, rows, 1)
require.Equal(t, "Baz", rows[0]["name"])
}
func TestEncodeSmartContext_OmitsEmptySections(t *testing.T) {
result := map[string]any{
"task": "add a tool",
"relevant_symbols": []map[string]any{
{"id": "a.go::Foo", "kind": "function", "name": "Foo", "file_path": "a.go", "start_line": 10, "signature": "func Foo()"},
},
"related_test_files": []string{"a_test.go"},
"files_to_edit": []string{"a.go", "a_test.go"},
}
payload, err := encodeSmartContext(result)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "smart_context.symbols", h.Tool)
require.Equal(t, "1", h.Meta["count"])
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "Foo", rows[0]["name"])
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "smart_context.tests", h.Tool, "cross_repo/entry_file/callers/callees must be skipped when empty")
rows, _ = dec.All()
require.Len(t, rows, 1)
require.Equal(t, "a_test.go", rows[0]["path"])
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "smart_context.files", h.Tool)
rows, _ = dec.All()
require.Len(t, rows, 2)
}
func TestEncodeSmartContext_IncludesAllSectionsWhenPopulated(t *testing.T) {
result := map[string]any{
"task": "trace auth",
"relevant_symbols": []map[string]any{
{"id": "auth.go::Check", "kind": "function", "name": "Check", "file_path": "auth.go", "start_line": 20},
},
"cross_repo_dependencies": []map[string]any{
{"id": "sdk.go::Auth", "kind": "type", "name": "Auth", "file_path": "sdk/auth.go", "repo_prefix": "sdk", "edge_kind": "calls"},
},
"entry_file_symbols": []string{"function Check (line 20)"},
"callers": []string{"main.go::main"},
"callees": []string{"auth.go::verify"},
"related_test_files": []string{"auth_test.go"},
"files_to_edit": []string{"auth.go"},
}
payload, err := encodeSmartContext(result)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
tools := []string{}
if h, err := dec.Header(); err == nil {
tools = append(tools, h.Tool)
_, _ = dec.All()
for {
h2, err := dec.NextSection()
if err != nil {
break
}
tools = append(tools, h2.Tool)
_, _ = dec.All()
}
}
require.Equal(t, []string{
"smart_context.symbols",
"smart_context.cross_repo",
"smart_context.entry_file",
"smart_context.callers",
"smart_context.callees",
"smart_context.tests",
"smart_context.files",
}, tools)
}
func TestRequestedFormat_CoversCompactAndFormatArgs(t *testing.T) {
f := wire.ParseFormat("gcx")
require.Equal(t, wire.FormatGCX, f)
require.Equal(t, wire.FormatText, wire.ParseFormat("compact"))
require.Equal(t, wire.FormatJSON, wire.ParseFormat(""))
}
func TestEncodePrefetchContext_RoundTrip(t *testing.T) {
n := newTestNode("a.go::Foo", "Foo", graph.KindFunction, "a.go", 10)
cands := []prefetchCandidate{{
Node: n,
ID: n.ID,
Kind: string(n.Kind),
FilePath: n.FilePath,
StartLine: n.StartLine,
Reason: "matches task keyword",
Confidence: 0.825,
SearchRelevance: 0.9,
GraphProximity: 0.5,
CommunityBonus: 0.0,
}}
payload, err := encodePrefetchContext(cands, 1, false, false)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, err := dec.Header()
require.NoError(t, err)
require.Equal(t, "prefetch_context", h.Tool)
require.Equal(t, "1", h.Meta["total"])
require.Equal(t, "false", h.Meta["truncated"])
rows, err := dec.All()
require.NoError(t, err)
require.Len(t, rows, 1)
require.Equal(t, "a.go::Foo", rows[0]["id"])
require.Equal(t, "matches task keyword", rows[0]["reason"])
require.Equal(t, "0.825", rows[0]["confidence"])
}
// TestEncodePrefetchContext_IncludeSourceAddsField pins the schema
// flip when include_source is set — the encoder must add a `source`
// column rather than emit it as a meta field, so a decoder iterating
// rows sees the source inline.
func TestEncodePrefetchContext_IncludeSourceAddsField(t *testing.T) {
n := newTestNode("a.go::Foo", "Foo", graph.KindFunction, "a.go", 10)
cands := []prefetchCandidate{{
Node: n,
ID: n.ID,
Kind: string(n.Kind),
FilePath: n.FilePath,
StartLine: n.StartLine,
Source: "func Foo() {}\n",
}}
payload, err := encodePrefetchContext(cands, 1, false, true)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, _ := dec.Header()
require.Contains(t, h.Fields, "source")
rows, _ := dec.All()
require.Equal(t, "func Foo() {}\n", rows[0]["source"])
}
func TestEncodeChangeImpact_SummaryAndEntries(t *testing.T) {
result := map[string]any{
"risk": analysis.RiskHigh,
"summary": "high blast radius",
"total_affected": 2,
"cross_repo_impact": false,
"affected_processes": []string{"checkout", "billing"},
"affected_communities": []string{"core"},
"test_files": []string{"foo_test.go"},
"by_depth": map[int][]analysis.ImpactEntry{
1: {{ID: "a.go::Foo", Name: "Foo", Kind: "function", FilePath: "a.go", Line: 10, EdgeConfidence: 0.95, ConfidenceLabel: "EXTRACTED"}},
2: {{ID: "b.go::Bar", Name: "Bar", Kind: "method", FilePath: "b.go", Line: 22}},
},
"cross_community_warning": "",
"community_note": "change is community-local",
}
payload, err := encodeChangeImpact(result)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
// Section 1 — summary.
h, _ := dec.Header()
require.Equal(t, "explain_change_impact.summary", h.Tool)
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, string(analysis.RiskHigh), rows[0]["risk"])
require.Equal(t, "checkout,billing", rows[0]["processes"])
// Section 2 — entries.
h, err = dec.NextSection()
require.NoError(t, err)
require.Equal(t, "explain_change_impact.entries", h.Tool)
rows, _ = dec.All()
require.Len(t, rows, 2)
require.Equal(t, "1", rows[0]["depth"])
require.Equal(t, "a.go::Foo", rows[0]["id"])
require.Equal(t, "2", rows[1]["depth"])
}
// TestEncodeChangeImpact_ContractsSection emits the contract-impact
// counters when the JSON path attaches `contract_impact`. Skipping
// this section when the field is absent is also pinned here.
func TestEncodeChangeImpact_ContractsSection(t *testing.T) {
result := map[string]any{
"risk": analysis.RiskMedium,
"summary": "ok",
"total_affected": 0,
"by_depth": map[int][]analysis.ImpactEntry{},
"contract_impact": &contractImpact{
Affected: []contractImpactEntry{{ContractID: "x"}},
Breaking: 1,
Warning: 2,
Info: 3,
},
}
payload, err := encodeChangeImpact(result)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
// Read summary, then advance past entries to reach contracts.
_, _ = dec.Header()
_, _ = dec.All()
_, err = dec.NextSection() // entries
require.NoError(t, err)
_, _ = dec.All()
h, err := dec.NextSection() // contracts
require.NoError(t, err)
require.Equal(t, "explain_change_impact.contracts", h.Tool)
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "1", rows[0]["breaking"])
require.Equal(t, "1", rows[0]["affected"])
}
func TestEncodeCheckGuards_RowsAndMeta(t *testing.T) {
violations := []analysis.GuardViolation{
{RuleName: "no_cross_layer", Kind: "boundary", Description: "mcp imports daemon"},
}
payload, err := encodeCheckGuards(violations, false)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
h, _ := dec.Header()
require.Equal(t, "check_guards", h.Tool)
require.Equal(t, "1", h.Meta["total"])
rows, _ := dec.All()
require.Len(t, rows, 1)
require.Equal(t, "no_cross_layer", rows[0]["rule_name"])
// Description is a row value (tab-delimited) so it can carry
// spaces; pin that here so a future schema rev can't accidentally
// move it into meta.
require.Equal(t, "mcp imports daemon", rows[0]["description"])
// No-rules path: status is a single-token meta flag (the wire
// header parser splits on raw spaces, so multi-word values would
// corrupt the header).
payload, err = encodeCheckGuards(nil, true)
require.NoError(t, err)
dec = wire.NewDecoder(strings.NewReader(string(payload)))
h, err = dec.Header()
require.NoError(t, err)
require.Equal(t, "0", h.Meta["total"])
require.Equal(t, "no_rules_configured", h.Meta["status"])
}
func TestEncodeFeedbackQuery_AllSectionsEmittedEvenWhenEmpty(t *testing.T) {
stats := map[string]any{
"total_entries": 5,
"accuracy": 0.8,
"most_useful": []any{},
"most_missed": []any{},
"most_demoted": []any{},
}
payload, err := encodeFeedbackQuery(stats)
require.NoError(t, err)
dec := wire.NewDecoder(strings.NewReader(string(payload)))
tools := []string{}
h, err := dec.Header()
require.NoError(t, err)
tools = append(tools, h.Tool)
_, _ = dec.All()
for {
h, err := dec.NextSection()
if err != nil {
break
}
tools = append(tools, h.Tool)
_, _ = dec.All()
}
require.Equal(t, []string{
"feedback.summary",
"feedback.most_useful",
"feedback.most_missed",
"feedback.most_demoted",
}, tools)
}