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
548 lines
24 KiB
Go
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")
|
|
}
|