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

455 lines
18 KiB
Go

package indexer
import (
"context"
"path/filepath"
"sync"
"testing"
"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/contracts"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
)
// mkBridgeLink builds one matched CrossLink between hand-rolled
// provider/consumer contracts for the unit-level materialisation tests.
func mkBridgeLink(groupID string, provider, consumer contracts.Contract) contracts.CrossLink {
return contracts.CrossLink{
ContractID: groupID,
Provider: provider,
Consumer: consumer,
CrossRepo: provider.RepoPrefix != consumer.RepoPrefix,
}
}
func collectBridgeNodes(g graph.Store) []*graph.Node {
var out []*graph.Node
for n := range g.NodesByKind(graph.KindContractBridge) {
out = append(out, n)
}
return out
}
func collectBridgeEdges(g graph.Store) []*graph.Edge {
var out []*graph.Edge
for e := range g.EdgesByKind(graph.EdgeBridges) {
out = append(out, e)
}
return out
}
// TestMaterializeContractBridges_GroupsAndSides covers the core
// materialisation contract: one bridge node per matched group, repo
// spread + counts in Meta, and EdgeBridges fan-out with side meta —
// including the "both" collapse when an exact-ID match shares one
// contract node across roles.
func TestMaterializeContractBridges_GroupsAndSides(t *testing.T) {
g := graph.New()
httpProvider := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "svc-a/routes.go::listUsers", FilePath: "svc-a/routes.go", Line: 10,
RepoPrefix: "svc-a", WorkspaceID: "acme", ProjectID: "users",
}
httpConsumer := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
SymbolID: "svc-b/client.go::fetchUsers", FilePath: "svc-b/client.go", Line: 7,
RepoPrefix: "svc-b", WorkspaceID: "acme", ProjectID: "users",
}
grpcIDL := contracts.Contract{
ID: "grpc::Users::GetUser", Type: contracts.ContractGRPC, Role: contracts.RoleProvider,
FilePath: "svc-a/users.proto", Line: 5,
RepoPrefix: "svc-a", WorkspaceID: "acme", ProjectID: "users",
Meta: map[string]any{"service": "Users", "method": "GetUser"},
}
grpcStub := contracts.Contract{
ID: "grpc::Users::getUser", Type: contracts.ContractGRPC, Role: contracts.RoleConsumer,
SymbolID: "web/api.ts::loadUser", FilePath: "web/api.ts", Line: 3,
RepoPrefix: "webapp", WorkspaceID: "acme", ProjectID: "users",
Meta: map[string]any{"service": "Users", "method": "getUser"},
}
// Contract nodes as commitContracts would mint them.
for _, id := range []string{"http::GET::/api/users", "grpc::Users::GetUser", "grpc::Users::getUser"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindContract, Name: id, Language: "contract"})
}
matched := []contracts.CrossLink{
mkBridgeLink("http::GET::/api/users", httpProvider, httpConsumer),
mkBridgeLink("grpc::Users::GetUser", grpcIDL, grpcStub),
}
minted := MaterializeContractBridges(g, matched)
require.Equal(t, 2, minted, "one bridge per matched group")
httpBridge := g.GetNode("bridge::acme::users::http::GET::/api/users")
require.NotNil(t, httpBridge, "http bridge node missing")
assert.Equal(t, graph.KindContractBridge, httpBridge.Kind)
assert.Equal(t, ContractBridgeFilePath, httpBridge.FilePath)
assert.Equal(t, "GET /api/users", httpBridge.Meta["canonical_key"])
assert.Equal(t, "http", httpBridge.Meta["contract_type"])
assert.Equal(t, []string{"svc-a", "svc-b"}, httpBridge.Meta["repos"])
assert.Equal(t, 1, httpBridge.Meta["provider_count"])
assert.Equal(t, 1, httpBridge.Meta["consumer_count"])
assert.Equal(t, true, httpBridge.Meta["cross_repo"])
assert.Equal(t, "svc-a", httpBridge.RepoPrefix, "bridge owner is the provider repo")
assert.Equal(t, "acme", httpBridge.WorkspaceID)
// Exact-ID match: provider and consumer collapse into one contract
// node — a single side="both" edge.
httpEdges := g.GetOutEdges(httpBridge.ID)
require.Len(t, httpEdges, 1)
assert.Equal(t, graph.EdgeBridges, httpEdges[0].Kind)
assert.Equal(t, "http::GET::/api/users", httpEdges[0].To)
assert.Equal(t, "both", httpEdges[0].Meta["side"])
// Canonical join: provider and consumer keep distinct contract
// nodes — one edge per side.
grpcBridge := g.GetNode("bridge::acme::users::grpc::Users::GetUser")
require.NotNil(t, grpcBridge, "grpc bridge node missing")
assert.Equal(t, "Users.GetUser", grpcBridge.Meta["canonical_key"])
sides := map[string]string{}
for _, e := range g.GetOutEdges(grpcBridge.ID) {
side, _ := e.Meta["side"].(string)
sides[e.To] = side
}
assert.Equal(t, map[string]string{
"grpc::Users::GetUser": "provider",
"grpc::Users::getUser": "consumer",
}, sides)
}
// TestMaterializeContractBridges_IdempotentAndEvicting: re-running
// with the same matches replaces the prior generation 1:1; running
// with a shrunken match set drops the stale bridge; running with no
// matches clears the subgraph entirely.
func TestMaterializeContractBridges_IdempotentAndEvicting(t *testing.T) {
g := graph.New()
provider := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "a/routes.go::listUsers", FilePath: "a/routes.go", RepoPrefix: "a",
}
consumer := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
SymbolID: "b/client.go::fetchUsers", FilePath: "b/client.go", RepoPrefix: "b",
}
provider2 := contracts.Contract{
ID: "topic::kafka::orders", Type: contracts.ContractTopic, Role: contracts.RoleProvider,
SymbolID: "a/pub.go::publish", FilePath: "a/pub.go", RepoPrefix: "a",
}
consumer2 := contracts.Contract{
ID: "topic::kafka::orders", Type: contracts.ContractTopic, Role: contracts.RoleConsumer,
SymbolID: "b/sub.go::consume", FilePath: "b/sub.go", RepoPrefix: "b",
}
full := []contracts.CrossLink{
mkBridgeLink("http::GET::/api/users", provider, consumer),
mkBridgeLink("topic::kafka::orders", provider2, consumer2),
}
require.Equal(t, 2, MaterializeContractBridges(g, full))
require.Len(t, collectBridgeNodes(g), 2)
edgesBefore := len(collectBridgeEdges(g))
require.Greater(t, edgesBefore, 0)
// Idempotent re-run: same input, same persisted state.
require.Equal(t, 2, MaterializeContractBridges(g, full))
assert.Len(t, collectBridgeNodes(g), 2, "re-run must not duplicate bridge nodes")
assert.Equal(t, edgesBefore, len(collectBridgeEdges(g)), "re-run must not duplicate bridge edges")
// The topic group disappears (e.g. its file was deleted): its
// bridge must go with it.
require.Equal(t, 1, MaterializeContractBridges(g, full[:1]))
assert.Nil(t, g.GetNode("bridge::a::a::topic::kafka::orders"), "stale bridge must be evicted")
assert.NotNil(t, g.GetNode("bridge::a::a::http::GET::/api/users"))
// Everything disappears.
require.Equal(t, 0, MaterializeContractBridges(g, nil))
assert.Empty(t, collectBridgeNodes(g))
assert.Empty(t, collectBridgeEdges(g))
}
// TestContractBridge_TwoRepoIntegration drives the full pipeline over
// two tracked repos sharing one workspace: a .proto IDL provider repo
// (with a Go server registration) and a Go stub-consumer repo. The
// reconcile pass must persist one bridge spanning both repos, and a
// second reconcile must leave the subgraph unchanged.
func TestContractBridge_TwoRepoIntegration(t *testing.T) {
providerRoot := setupGRPCProtoProviderRepo(t, "auth-service")
consumerRoot := setupGRPCGoConsumerRepo(t, "client-svc")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: providerRoot, Name: "auth-service"},
{Path: consumerRoot, Name: "client-svc"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newMultiLangRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err)
}
bridge := g.GetNode("bridge::shared-test::shared-test::grpc::Users::GetUser")
require.NotNil(t, bridge,
"expected persisted bridge for the matched gRPC group; bridges: %v", bridgeIDs(g))
assert.Equal(t, graph.KindContractBridge, bridge.Kind)
assert.Equal(t, "grpc", bridge.Meta["contract_type"])
assert.Equal(t, "Users.GetUser", bridge.Meta["canonical_key"])
assert.Equal(t, true, bridge.Meta["cross_repo"])
repos, _ := bridge.Meta["repos"].([]string)
assert.Equal(t, []string{"auth-service", "client-svc"}, repos,
"bridge must span both participating repos")
// EdgeBridges fan-out: the shared-ID contract node carries both
// roles; the registration-site provider contract (grpc::Users,
// joined canonically) rides as a separate provider edge.
sides := map[string]string{}
for _, e := range g.GetOutEdges(bridge.ID) {
require.Equal(t, graph.EdgeBridges, e.Kind)
side, _ := e.Meta["side"].(string)
sides[e.To] = side
}
assert.Equal(t, "both", sides["grpc::Users::GetUser"],
"exact-ID provider+consumer collapse into one contract node: %v", sides)
assert.Equal(t, "provider", sides["grpc::Users"],
"registration-site provider must join the group: %v", sides)
// Idempotency across reconciles: re-running the pass replaces the
// generation in place.
nodesBefore := len(collectBridgeNodes(g))
edgesBefore := len(collectBridgeEdges(g))
mi.ReconcileContractEdges()
assert.Equal(t, nodesBefore, len(collectBridgeNodes(g)), "reconcile must not duplicate bridges")
assert.Equal(t, edgesBefore, len(collectBridgeEdges(g)), "reconcile must not duplicate bridge edges")
// Untracking the consumer dissolves the group: the next reconcile
// rebuilds bridges from the remaining contracts only.
mi.UntrackRepo("client-svc")
assert.Nil(t, g.GetNode("bridge::shared-test::shared-test::grpc::Users::GetUser"),
"bridge must dissolve when the consumer repo is untracked; bridges: %v", bridgeIDs(g))
}
// TestMaterializeContractBridges_BoundaryIsolation: two unrelated
// workspaces that each serve the same contract (`GET /api/users`) must
// materialise as TWO distinct bridge nodes, never one merged bridge.
// The matcher pairs only inside a (workspace, project) boundary, so a
// bridge keyed on the bare contract ID would assert a provider_count /
// cross-repo blast radius the matcher never produced.
func TestMaterializeContractBridges_BoundaryIsolation(t *testing.T) {
g := graph.New()
// Workspace "acme": one repo internally serving + consuming the route.
acmeProv := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "acme-api/routes.go::list", FilePath: "acme-api/routes.go", Line: 10,
RepoPrefix: "acme-api", WorkspaceID: "acme", ProjectID: "acme",
}
acmeCons := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
SymbolID: "acme-api/client.go::fetch", FilePath: "acme-api/client.go", Line: 4,
RepoPrefix: "acme-api", WorkspaceID: "acme", ProjectID: "acme",
}
// Workspace "globex": a completely unrelated service that happens to
// expose and consume the identical route string.
globexProv := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleProvider,
SymbolID: "globex-api/routes.go::list", FilePath: "globex-api/routes.go", Line: 22,
RepoPrefix: "globex-api", WorkspaceID: "globex", ProjectID: "globex",
}
globexCons := contracts.Contract{
ID: "http::GET::/api/users", Type: contracts.ContractHTTP, Role: contracts.RoleConsumer,
SymbolID: "globex-api/client.go::fetch", FilePath: "globex-api/client.go", Line: 8,
RepoPrefix: "globex-api", WorkspaceID: "globex", ProjectID: "globex",
}
for _, id := range []string{"http::GET::/api/users"} {
g.AddNode(&graph.Node{ID: id, Kind: graph.KindContract, Name: id, Language: "contract"})
}
matched := []contracts.CrossLink{
mkBridgeLink("http::GET::/api/users", acmeProv, acmeCons),
mkBridgeLink("http::GET::/api/users", globexProv, globexCons),
}
minted := MaterializeContractBridges(g, matched)
require.Equal(t, 2, minted, "two unrelated workspaces must not collapse into one bridge")
acmeBridge := g.GetNode("bridge::acme::acme::http::GET::/api/users")
require.NotNil(t, acmeBridge, "acme bridge missing; bridges: %v", bridgeIDs(g))
globexBridge := g.GetNode("bridge::globex::globex::http::GET::/api/users")
require.NotNil(t, globexBridge, "globex bridge missing; bridges: %v", bridgeIDs(g))
// Each bridge counts only its own workspace's provider — never the
// summed count a merged bridge would assert.
assert.Equal(t, 1, acmeBridge.Meta["provider_count"])
assert.Equal(t, 1, globexBridge.Meta["provider_count"])
assert.Equal(t, "acme-api", acmeBridge.RepoPrefix)
assert.Equal(t, "globex-api", globexBridge.RepoPrefix)
assert.Equal(t, "acme", acmeBridge.Meta["workspace"])
assert.Equal(t, "globex", globexBridge.Meta["workspace"])
// Neither is cross-repo: each pairs inside its own single repo.
assert.Equal(t, false, acmeBridge.Meta["cross_repo"])
assert.Equal(t, false, globexBridge.Meta["cross_repo"])
}
// TestMaterializeContractBridges_StartLineIsOrderIndependent: a group
// with multiple provider records at different lines must pin its bridge
// StartLine to the true minimum regardless of the (map-ordered) match
// iteration, so reconciles stay byte-stable instead of flapping the
// persisted field.
func TestMaterializeContractBridges_StartLineIsOrderIndependent(t *testing.T) {
provLow := contracts.Contract{
ID: "grpc::Users::GetUser", Type: contracts.ContractGRPC, Role: contracts.RoleProvider,
SymbolID: "svc/a.go::A", FilePath: "svc/a.go", Line: 5,
RepoPrefix: "svc", WorkspaceID: "w", ProjectID: "p",
Meta: map[string]any{"service": "Users", "method": "GetUser"},
}
provHigh := contracts.Contract{
ID: "grpc::Users::GetUser", Type: contracts.ContractGRPC, Role: contracts.RoleProvider,
SymbolID: "svc/b.go::B", FilePath: "svc/b.go", Line: 99,
RepoPrefix: "svc", WorkspaceID: "w", ProjectID: "p",
Meta: map[string]any{"service": "Users", "method": "GetUser"},
}
consumer := contracts.Contract{
ID: "grpc::Users::GetUser", Type: contracts.ContractGRPC, Role: contracts.RoleConsumer,
SymbolID: "web/api.ts::load", FilePath: "web/api.ts", Line: 3,
RepoPrefix: "web", WorkspaceID: "w", ProjectID: "p",
Meta: map[string]any{"service": "Users", "method": "GetUser"},
}
// Two orderings of the same matched group: high-line link first, then
// low-line link first. Both must yield StartLine = 5 (the true min).
orderings := [][]contracts.CrossLink{
{
mkBridgeLink("grpc::Users::GetUser", provHigh, consumer),
mkBridgeLink("grpc::Users::GetUser", provLow, consumer),
},
{
mkBridgeLink("grpc::Users::GetUser", provLow, consumer),
mkBridgeLink("grpc::Users::GetUser", provHigh, consumer),
},
}
for i, matched := range orderings {
g := graph.New()
require.Equal(t, 1, MaterializeContractBridges(g, matched), "ordering %d", i)
bridge := g.GetNode("bridge::w::p::grpc::Users::GetUser")
require.NotNil(t, bridge, "ordering %d: bridge missing", i)
assert.Equal(t, 5, bridge.StartLine,
"ordering %d: StartLine must be the true minimum provider line, order-independent", i)
}
}
// TestReconcileContractEdges_ConcurrentNoRaceOrTear stresses the
// serialisation that ReconcileContractEdges needs. The janitor, the
// file-watcher, and MCP-triggered track/index all drive it on
// independent goroutines, and the pass evicts the prior EdgeMatches /
// topic / bridge generation and mints a fresh one across many
// non-atomic store writes. Concurrent runs whose registry snapshots
// disagree (one mid-flight while another track/untrack mutates the
// registry) can interleave an evict over the other's freshly-minted
// bridge and persist a stale generation. Here a writer goroutine
// repeatedly untracks and re-tracks the consumer repo — flipping the
// matched set between "bridge present" and "bridge absent" — while many
// reader goroutines reconcile concurrently. Under -race this surfaces
// any unsynchronised access; the terminal state (both repos tracked)
// must hold the single complete bridge generation, never a torn one.
func TestReconcileContractEdges_ConcurrentNoRaceOrTear(t *testing.T) {
providerRoot := setupGRPCProtoProviderRepo(t, "auth-service")
consumerRoot := setupGRPCGoConsumerRepo(t, "client-svc")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
consumerEntry := config.RepoEntry{Path: consumerRoot, Name: "client-svc"}
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: providerRoot, Name: "auth-service"},
consumerEntry,
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newMultiLangRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err)
}
bridgeID := "bridge::shared-test::shared-test::grpc::Users::GetUser"
require.NotNil(t, g.GetNode(bridgeID), "baseline bridge missing; bridges: %v", bridgeIDs(g))
var wg sync.WaitGroup
// Writer: flip the matched set under the readers.
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 12; i++ {
mi.UntrackRepo("client-svc")
_, _ = mi.TrackRepoCtx(context.Background(), consumerEntry)
}
}()
// Readers: reconcile concurrently with the flips.
const readers = 12
wg.Add(readers)
for i := 0; i < readers; i++ {
go func() {
defer wg.Done()
for j := 0; j < 8; j++ {
mi.ReconcileContractEdges()
}
}()
}
wg.Wait()
// Settle on the final registry state with one last reconcile, then
// assert the terminal generation is complete and not duplicated.
mi.ReconcileContractEdges()
got := bridgeIDs(g)
count := 0
for _, id := range got {
if id == bridgeID {
count++
}
}
require.Equal(t, 1, count, "expected exactly one bridge after concurrent reconciles; bridges: %v", got)
// The fan-out must be complete: the exact-ID provider+consumer node
// plus the registration-site provider. A torn generation would miss
// one of these.
sides := map[string]string{}
for _, e := range g.GetOutEdges(bridgeID) {
side, _ := e.Meta["side"].(string)
sides[e.To] = side
}
assert.Equal(t, "both", sides["grpc::Users::GetUser"], "edge set torn by interleave: %v", sides)
assert.Equal(t, "provider", sides["grpc::Users"], "edge set torn by interleave: %v", sides)
}
func bridgeIDs(g graph.Store) []string {
var out []string
for _, n := range collectBridgeNodes(g) {
out = append(out, n.ID)
}
return out
}