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

548 lines
24 KiB
Go

package mcp
import (
"context"
"encoding/json"
"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/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/query"
)
// concurrencyServer wires a Server around a hand-built graph so the
// tests can pin exactly which spawn / send / write / call edges
// exist. The fixture is intentionally graph-level (not source-level)
// because the analyzers are language-agnostic and run on edges, not
// on parser output.
func concurrencyServer(t *testing.T) *Server {
t.Helper()
g := graph.New()
reg := testRegistry()
cfg := config.Default()
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
eng := query.NewEngine(g)
return NewServer(eng, g, idx, nil, zap.NewNop(), nil)
}
func addFn(g graph.Store, id, name, path string) {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindFunction, Name: name, FilePath: path, Language: "go"})
}
func addField(g graph.Store, id, name, path string) {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindField, Name: name, FilePath: path, Language: "go"})
}
func addEdge(g graph.Store, from, to string, kind graph.EdgeKind, path string, line int) {
g.AddEdge(&graph.Edge{From: from, To: to, Kind: kind, FilePath: path, Line: line, Confidence: 1})
}
// TestRaceWrites_FlagsUnguardedFieldWriteFromGoroutine: the
// canonical positive case. spawnRoot runs in a goroutine and writes
// to a field with no detected lock guard.
func TestRaceWrites_FlagsUnguardedFieldWriteFromGoroutine(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addFn(srv.graph, "main.go::Worker", "Worker", "main.go")
addField(srv.graph, "main.go::State.counter", "counter", "main.go")
addEdge(srv.graph, "main.go::Main", "main.go::Worker", graph.EdgeSpawns, "main.go", 10)
addEdge(srv.graph, "main.go::Worker", "main.go::State.counter", graph.EdgeWrites, "main.go", 20)
res, err := srv.handleAnalyzeRaceWrites(context.Background(), mcplib.CallToolRequest{})
require.NoError(t, err)
require.False(t, res.IsError)
text := res.Content[0].(mcplib.TextContent).Text
var payload struct {
Total int `json:"total"`
RaceWrites []struct {
Field string `json:"field"`
Writer string `json:"writer"`
FilePath string `json:"file_path"`
Line int `json:"line"`
} `json:"race_writes"`
}
require.NoError(t, json.Unmarshal([]byte(text), &payload))
require.Equal(t, 1, payload.Total)
assert.Equal(t, "main.go::State.counter", payload.RaceWrites[0].Field)
assert.Equal(t, "main.go::Worker", payload.RaceWrites[0].Writer)
assert.Equal(t, 20, payload.RaceWrites[0].Line)
}
// TestRaceWrites_SuppressedByLockGuard: the writer calls Lock() in
// the same function, so the analyzer must not flag the write.
// Catches the guard-cache logic AND the per-name lock detection.
func TestRaceWrites_SuppressedByLockGuard(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addFn(srv.graph, "main.go::Worker", "Worker", "main.go")
addField(srv.graph, "main.go::State.counter", "counter", "main.go")
// Pretend Worker calls (*sync.Mutex).Lock — the call edge's
// target name is "Lock", which the lock-guard heuristic catches.
srv.graph.AddNode(&graph.Node{ID: "sync::Mutex.Lock", Kind: graph.KindMethod, Name: "Lock", FilePath: "sync/mutex.go"})
addEdge(srv.graph, "main.go::Main", "main.go::Worker", graph.EdgeSpawns, "main.go", 10)
addEdge(srv.graph, "main.go::Worker", "sync::Mutex.Lock", graph.EdgeCalls, "main.go", 18)
addEdge(srv.graph, "main.go::Worker", "main.go::State.counter", graph.EdgeWrites, "main.go", 20)
res, _ := srv.handleAnalyzeRaceWrites(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
assert.Equal(t, 0, payload.Total, "Lock()-guarded write must not be flagged")
}
// TestRaceWrites_IgnoresMainThreadWrites: a write that happens
// outside any goroutine-reachable function is not racy — must not
// appear in the report.
func TestRaceWrites_IgnoresMainThreadWrites(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addField(srv.graph, "main.go::State.counter", "counter", "main.go")
addEdge(srv.graph, "main.go::Main", "main.go::State.counter", graph.EdgeWrites, "main.go", 10)
res, _ := srv.handleAnalyzeRaceWrites(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
assert.Equal(t, 0, payload.Total, "main-thread writes must not appear in race_writes")
}
// TestRaceWrites_TransitiveGoroutineReach: the writer is reached
// via EdgeCalls from a goroutine — must still flag, proving the
// reach closure isn't limited to the immediate spawn target.
func TestRaceWrites_TransitiveGoroutineReach(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addFn(srv.graph, "main.go::Worker", "Worker", "main.go")
addFn(srv.graph, "main.go::Helper", "Helper", "main.go")
addField(srv.graph, "main.go::State.counter", "counter", "main.go")
addEdge(srv.graph, "main.go::Main", "main.go::Worker", graph.EdgeSpawns, "main.go", 5)
addEdge(srv.graph, "main.go::Worker", "main.go::Helper", graph.EdgeCalls, "main.go", 7)
addEdge(srv.graph, "main.go::Helper", "main.go::State.counter", graph.EdgeWrites, "main.go", 12)
res, _ := srv.handleAnalyzeRaceWrites(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
RaceWrites []struct {
Writer string `json:"writer"`
} `json:"race_writes"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
require.Equal(t, 1, payload.Total, "transitively-reached writer must still be flagged")
assert.Equal(t, "main.go::Helper", payload.RaceWrites[0].Writer)
}
// TestRaceWrites_OnlyKindFieldTargets: a write to a local variable
// or a non-field node must not appear in the report.
func TestRaceWrites_OnlyKindFieldTargets(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addFn(srv.graph, "main.go::Worker", "Worker", "main.go")
srv.graph.AddNode(&graph.Node{
ID: "main.go::localVar", Kind: graph.KindVariable, Name: "localVar",
FilePath: "main.go", Language: "go",
})
addEdge(srv.graph, "main.go::Main", "main.go::Worker", graph.EdgeSpawns, "main.go", 5)
addEdge(srv.graph, "main.go::Worker", "main.go::localVar", graph.EdgeWrites, "main.go", 8)
res, _ := srv.handleAnalyzeRaceWrites(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
assert.Equal(t, 0, payload.Total, "variable writes must not appear in race_writes")
}
// TestUnclosedChannels_FlagsMultiSenderFanInWithoutClose: the
// canonical positive case — a fan-in channel with multiple senders
// and a ranging receiver, nobody calls close().
func TestUnclosedChannels_FlagsMultiSenderFanInWithoutClose(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::SenderA", "SenderA", "main.go")
addFn(srv.graph, "main.go::SenderB", "SenderB", "main.go")
addFn(srv.graph, "main.go::Receiver", "Receiver", "main.go")
addEdge(srv.graph, "main.go::SenderA", "main.go::resultsCh", graph.EdgeSends, "main.go", 10)
addEdge(srv.graph, "main.go::SenderB", "main.go::resultsCh", graph.EdgeSends, "main.go", 15)
addEdge(srv.graph, "main.go::Receiver", "main.go::resultsCh", graph.EdgeRecvs, "main.go", 20)
res, _ := srv.handleAnalyzeUnclosedChannels(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
UnclosedChannels []struct {
Channel string `json:"channel"`
Sends int `json:"sends"`
Senders int `json:"senders"`
Recvs int `json:"recvs"`
Risk string `json:"risk"`
} `json:"unclosed_channels"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
require.Equal(t, 1, payload.Total)
row := payload.UnclosedChannels[0]
assert.Equal(t, "main.go::resultsCh", row.Channel)
assert.Equal(t, 2, row.Sends)
assert.Equal(t, 2, row.Senders)
assert.Equal(t, 1, row.Recvs)
assert.Equal(t, "high", row.Risk)
}
// TestUnclosedChannels_SuppressedByCloseCall: the sender's function
// also calls close(), so the channel is "covered" and must not be
// flagged.
func TestUnclosedChannels_SuppressedByCloseCall(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Sender", "Sender", "main.go")
srv.graph.AddNode(&graph.Node{ID: "builtin::close", Kind: graph.KindFunction, Name: "close", FilePath: "builtin"})
addEdge(srv.graph, "main.go::Sender", "main.go::ch", graph.EdgeSends, "main.go", 5)
addEdge(srv.graph, "main.go::Sender", "builtin::close", graph.EdgeCalls, "main.go", 6)
res, _ := srv.handleAnalyzeUnclosedChannels(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
assert.Equal(t, 0, payload.Total, "channel with close()-calling sender must not be flagged")
}
// TestUnclosedChannels_PureReceiveSkipped: a channel that only
// receives (no sends in this scope) is not the analyzer's
// responsibility — closing falls to the sender side.
func TestUnclosedChannels_PureReceiveSkipped(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Receiver", "Receiver", "main.go")
addEdge(srv.graph, "main.go::Receiver", "extern::ch", graph.EdgeRecvs, "main.go", 5)
res, _ := srv.handleAnalyzeUnclosedChannels(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
assert.Equal(t, 0, payload.Total, "pure-receive channel must not be flagged")
}
// TestUnclosedChannels_RiskClassification: validates the
// high/medium/low risk assignment matches the classifyUnclosed rules.
func TestUnclosedChannels_RiskClassification(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::S1", "S1", "main.go")
addFn(srv.graph, "main.go::S2", "S2", "main.go")
addFn(srv.graph, "main.go::R", "R", "main.go")
// HIGH: 2 senders + 1 receiver, no close.
addEdge(srv.graph, "main.go::S1", "main.go::highCh", graph.EdgeSends, "main.go", 1)
addEdge(srv.graph, "main.go::S2", "main.go::highCh", graph.EdgeSends, "main.go", 2)
addEdge(srv.graph, "main.go::R", "main.go::highCh", graph.EdgeRecvs, "main.go", 3)
// MEDIUM: 1 sender + 1 receiver, no close.
addEdge(srv.graph, "main.go::S1", "main.go::medCh", graph.EdgeSends, "main.go", 10)
addEdge(srv.graph, "main.go::R", "main.go::medCh", graph.EdgeRecvs, "main.go", 11)
// LOW: 1 sender, no receiver (fire-and-forget signal).
addEdge(srv.graph, "main.go::S2", "main.go::lowCh", graph.EdgeSends, "main.go", 20)
res, _ := srv.handleAnalyzeUnclosedChannels(context.Background(), mcplib.CallToolRequest{})
require.False(t, res.IsError)
var payload struct {
Total int `json:"total"`
UnclosedChannels []struct {
Channel string `json:"channel"`
Risk string `json:"risk"`
} `json:"unclosed_channels"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
require.Equal(t, 3, payload.Total)
byCh := map[string]string{}
for _, r := range payload.UnclosedChannels {
byCh[r.Channel] = r.Risk
}
assert.Equal(t, "high", byCh["main.go::highCh"])
assert.Equal(t, "medium", byCh["main.go::medCh"])
assert.Equal(t, "low", byCh["main.go::lowCh"])
// High-risk row must sort first under risk-rank ordering.
assert.Equal(t, "main.go::highCh", payload.UnclosedChannels[0].Channel,
"high-risk rows must sort first")
}
// TestAnalyzeDispatcher_RoutesNewKinds asserts the analyze switch
// statement accepts kind=race_writes and kind=unclosed_channels.
// Regression-protects the dispatcher wiring against a stray rename.
func TestAnalyzeDispatcher_RoutesNewKinds(t *testing.T) {
srv, _ := setupTestServer(t)
for _, kind := range []string{"race_writes", "unclosed_channels"} {
t.Run(kind, func(t *testing.T) {
req := mcplib.CallToolRequest{}
req.Params.Name = "analyze"
req.Params.Arguments = map[string]any{"kind": kind}
res, err := srv.handleAnalyze(context.Background(), req)
require.NoError(t, err)
require.False(t, res.IsError,
"dispatcher must route kind=%s without error; got %v", kind, res)
})
}
}
// TestAnalyzeRaceWrites_GCXEncodesRow asserts the GCX1 wire output
// includes the race_writes column header. Wire-format clients
// (gcx-go / gcx-ts) decode by header so a missing column breaks
// downstream consumers silently.
func TestAnalyzeRaceWrites_GCXEncodesRow(t *testing.T) {
srv := concurrencyServer(t)
addFn(srv.graph, "main.go::Main", "Main", "main.go")
addFn(srv.graph, "main.go::Worker", "Worker", "main.go")
addField(srv.graph, "main.go::State.counter", "counter", "main.go")
addEdge(srv.graph, "main.go::Main", "main.go::Worker", graph.EdgeSpawns, "main.go", 5)
addEdge(srv.graph, "main.go::Worker", "main.go::State.counter", graph.EdgeWrites, "main.go", 10)
req := mcplib.CallToolRequest{}
req.Params.Name = "analyze"
req.Params.Arguments = map[string]any{"format": "gcx"}
res, err := srv.handleAnalyzeRaceWrites(context.Background(), req)
require.NoError(t, err)
require.False(t, res.IsError)
text := res.Content[0].(mcplib.TextContent).Text
assert.Contains(t, text, "analyze.race_writes")
assert.Contains(t, text, "field")
assert.Contains(t, text, "writer")
assert.Contains(t, text, "reason")
}
// addMethod / addType / addTypedField build the node shapes the
// concurrency classifier reads: a method linked to its receiver type
// via EdgeMemberOf, and a typed field linked to its owning type.
func addMethod(g graph.Store, id, name, path string) {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindMethod, Name: name, FilePath: path, Language: "go"})
}
func addType(g graph.Store, id, name, path string) {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindType, Name: name, FilePath: path, Language: "go"})
}
func addTypedField(g graph.Store, id, name, fieldType, path string) {
g.AddNode(&graph.Node{
ID: id, Kind: graph.KindField, Name: name, FilePath: path, Language: "go",
Meta: map[string]any{"field_type": fieldType},
})
}
// callerNote is the JSON shape of one get_callers caller_notes entry.
type callerNote struct {
SyncGuarded bool `json:"sync_guarded"`
SyncGuardedWhy string `json:"sync_guarded_why"`
CrossConcurrent bool `json:"cross_concurrent"`
CrossConcurrentWhy string `json:"cross_concurrent_why"`
}
// getCallersWithConcurrency builds a graph with two callers of a target
// function — one a method on a mutex-holding type, one a method on a
// lock-free type — plus a goroutine-launched caller, then returns the
// caller_notes map from a get_callers call.
//
// Graph:
//
// Guarded.Do --calls--> Target (Guarded holds a sync.Mutex)
// Free.Do --calls--> Target (Free holds only an int)
// Spawned --calls--> Target (Spawned is itself spawned)
// someEntry --spawns--> Spawned
func getCallersWithConcurrency(t *testing.T) map[string]callerNote {
t.Helper()
srv := concurrencyServer(t)
g := srv.graph
addFn(g, "main.go::Target", "Target", "main.go")
// Mutex-holding type with a method that calls Target.
addType(g, "main.go::Guarded", "Guarded", "main.go")
addTypedField(g, "main.go::Guarded.mu", "mu", "sync.Mutex", "main.go")
addEdge(g, "main.go::Guarded.mu", "main.go::Guarded", graph.EdgeMemberOf, "main.go", 2)
addMethod(g, "main.go::Guarded.Do", "Do", "main.go")
addEdge(g, "main.go::Guarded.Do", "main.go::Guarded", graph.EdgeMemberOf, "main.go", 5)
addEdge(g, "main.go::Guarded.Do", "main.go::Target", graph.EdgeCalls, "main.go", 6)
// Lock-free type with a method that calls Target.
addType(g, "main.go::Free", "Free", "main.go")
addTypedField(g, "main.go::Free.n", "n", "int", "main.go")
addEdge(g, "main.go::Free.n", "main.go::Free", graph.EdgeMemberOf, "main.go", 12)
addMethod(g, "main.go::Free.Do", "Do", "main.go")
addEdge(g, "main.go::Free.Do", "main.go::Free", graph.EdgeMemberOf, "main.go", 15)
addEdge(g, "main.go::Free.Do", "main.go::Target", graph.EdgeCalls, "main.go", 16)
// A caller that is itself launched as a goroutine.
addFn(g, "main.go::Spawned", "Spawned", "main.go")
addFn(g, "main.go::someEntry", "someEntry", "main.go")
g.AddEdge(&graph.Edge{
From: "main.go::someEntry", To: "main.go::Spawned", Kind: graph.EdgeSpawns,
FilePath: "main.go", Line: 20, Confidence: 1, Meta: map[string]any{"mode": "goroutine"},
})
addEdge(g, "main.go::Spawned", "main.go::Target", graph.EdgeCalls, "main.go", 22)
res := callTool(t, srv, "get_callers", map[string]any{"id": "main.go::Target"})
require.False(t, res.IsError)
var sg struct {
CallerNotes map[string]callerNote `json:"caller_notes"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &sg))
return sg.CallerNotes
}
// TestGetCallers_SyncGuardedAnnotation: a caller method on a
// mutex-holding type is flagged sync_guarded; a caller method on a
// lock-free type is not.
func TestGetCallers_SyncGuardedAnnotation(t *testing.T) {
notes := getCallersWithConcurrency(t)
guarded, ok := notes["main.go::Guarded.Do"]
require.True(t, ok, "caller on a mutex-holding type must carry a caller_notes entry")
assert.True(t, guarded.SyncGuarded, "method on a sync.Mutex-holding type must be sync_guarded")
assert.NotEmpty(t, guarded.SyncGuardedWhy, "sync_guarded must carry an explanation")
// A caller on a lock-free type carries no flag, so it is absent
// from caller_notes entirely.
free, present := notes["main.go::Free.Do"]
if present {
assert.False(t, free.SyncGuarded, "method on a lock-free type must not be sync_guarded")
}
}
// TestGetCallers_CrossConcurrentAnnotation: a caller that is itself
// spawned as a goroutine is flagged cross_concurrent; a plainly-called
// caller is not.
func TestGetCallers_CrossConcurrentAnnotation(t *testing.T) {
notes := getCallersWithConcurrency(t)
spawned, ok := notes["main.go::Spawned"]
require.True(t, ok, "goroutine-launched caller must carry a caller_notes entry")
assert.True(t, spawned.CrossConcurrent, "goroutine-launched caller must be cross_concurrent")
assert.NotEmpty(t, spawned.CrossConcurrentWhy, "cross_concurrent must carry an explanation")
// Guarded.Do is called plainly (not spawned) — must not be flagged
// cross_concurrent even though it does carry sync_guarded.
guarded := notes["main.go::Guarded.Do"]
assert.False(t, guarded.CrossConcurrent,
"a plainly-called caller must not be cross_concurrent")
}
// TestGetCallers_GCXEncodesCallerNotes asserts the GCX1 wire output
// carries the caller_notes section header and its columns so
// wire-format clients can decode the annotation.
func TestGetCallers_GCXEncodesCallerNotes(t *testing.T) {
srv := concurrencyServer(t)
g := srv.graph
addFn(g, "main.go::Target", "Target", "main.go")
addType(g, "main.go::Guarded", "Guarded", "main.go")
addTypedField(g, "main.go::Guarded.mu", "mu", "sync.RWMutex", "main.go")
addEdge(g, "main.go::Guarded.mu", "main.go::Guarded", graph.EdgeMemberOf, "main.go", 2)
addMethod(g, "main.go::Guarded.Do", "Do", "main.go")
addEdge(g, "main.go::Guarded.Do", "main.go::Guarded", graph.EdgeMemberOf, "main.go", 5)
addEdge(g, "main.go::Guarded.Do", "main.go::Target", graph.EdgeCalls, "main.go", 6)
req := mcplib.CallToolRequest{}
req.Params.Name = "get_callers"
req.Params.Arguments = map[string]any{"id": "main.go::Target", "format": "gcx"}
res, err := srv.handleGetCallers(context.Background(), req)
require.NoError(t, err)
require.False(t, res.IsError)
text := res.Content[0].(mcplib.TextContent).Text
assert.Contains(t, text, "get_callers.caller_notes")
assert.Contains(t, text, "sync_guarded")
assert.Contains(t, text, "cross_concurrent")
}
// TestGetCallers_NoConcurrencyOmitsCallerNotes: when no caller carries
// a concurrency flag, the caller_notes field is absent from the
// response — the annotation is opt-in, not noise on every result.
func TestGetCallers_NoConcurrencyOmitsCallerNotes(t *testing.T) {
srv := concurrencyServer(t)
g := srv.graph
addFn(g, "main.go::Target", "Target", "main.go")
addFn(g, "main.go::Plain", "Plain", "main.go")
addEdge(g, "main.go::Plain", "main.go::Target", graph.EdgeCalls, "main.go", 6)
res := callTool(t, srv, "get_callers", map[string]any{"id": "main.go::Target"})
require.False(t, res.IsError)
text := res.Content[0].(mcplib.TextContent).Text
assert.NotContains(t, text, "caller_notes",
"caller_notes must be omitted when no caller carries a concurrency flag")
}
// TestAnalyzeGoroutineSpawns_ConcurrencyAnnotation: the goroutine_spawns
// analyzer surface carries the shared classification on each spawn
// target — sync_guarded when the goroutine body is a method on a
// lock-holding type, cross_concurrent because it is a spawn target.
func TestAnalyzeGoroutineSpawns_ConcurrencyAnnotation(t *testing.T) {
srv := concurrencyServer(t)
g := srv.graph
// A goroutine body that is a method on a mutex-holding type.
addType(g, "main.go::Worker", "Worker", "main.go")
addTypedField(g, "main.go::Worker.mu", "mu", "sync.Mutex", "main.go")
addEdge(g, "main.go::Worker.mu", "main.go::Worker", graph.EdgeMemberOf, "main.go", 2)
addMethod(g, "main.go::Worker.Run", "Run", "main.go")
addEdge(g, "main.go::Worker.Run", "main.go::Worker", graph.EdgeMemberOf, "main.go", 5)
addFn(g, "main.go::Main", "Main", "main.go")
g.AddEdge(&graph.Edge{
From: "main.go::Main", To: "main.go::Worker.Run", Kind: graph.EdgeSpawns,
FilePath: "main.go", Line: 9, Confidence: 1, Meta: map[string]any{"mode": "goroutine"},
})
req := mcplib.CallToolRequest{}
req.Params.Name = "analyze"
req.Params.Arguments = map[string]any{"kind": "goroutine_spawns"}
res, err := srv.handleAnalyzeGoroutineSpawns(context.Background(), req)
require.NoError(t, err)
require.False(t, res.IsError)
var payload struct {
Spawns []struct {
Target string `json:"target"`
Concurrency *callerNote `json:"concurrency"`
} `json:"spawns"`
}
require.NoError(t, json.Unmarshal([]byte(res.Content[0].(mcplib.TextContent).Text), &payload))
require.Len(t, payload.Spawns, 1)
row := payload.Spawns[0]
assert.Equal(t, "main.go::Worker.Run", row.Target)
require.NotNil(t, row.Concurrency, "spawn target on a mutex type must carry a concurrency block")
assert.True(t, row.Concurrency.SyncGuarded, "goroutine body on a mutex type must be sync_guarded")
assert.True(t, row.Concurrency.CrossConcurrent, "a spawn target must be cross_concurrent")
}
// TestAnalyzeGoroutineSpawns_GCXEncodesConcurrency asserts the GCX1
// wire output for goroutine_spawns carries the concurrency columns.
func TestAnalyzeGoroutineSpawns_GCXEncodesConcurrency(t *testing.T) {
srv := concurrencyServer(t)
g := srv.graph
addFn(g, "main.go::Main", "Main", "main.go")
addFn(g, "main.go::bg", "bg", "main.go")
g.AddEdge(&graph.Edge{
From: "main.go::Main", To: "main.go::bg", Kind: graph.EdgeSpawns,
FilePath: "main.go", Line: 9, Confidence: 1, Meta: map[string]any{"mode": "goroutine"},
})
req := mcplib.CallToolRequest{}
req.Params.Name = "analyze"
req.Params.Arguments = map[string]any{"format": "gcx"}
res, err := srv.handleAnalyzeGoroutineSpawns(context.Background(), req)
require.NoError(t, err)
require.False(t, res.IsError)
text := res.Content[0].(mcplib.TextContent).Text
assert.Contains(t, text, "analyze.goroutine_spawns")
assert.Contains(t, text, "sync_guarded")
assert.Contains(t, text, "cross_concurrent")
}