package graph import ( "sync" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "pgregory.net/rapid" ) func makeNode(id, name string, kind NodeKind, file, lang string) *Node { return &Node{ ID: id, Kind: kind, Name: name, QualName: "pkg." + name, FilePath: file, StartLine: 1, EndLine: 10, Language: lang, } } func TestAddAndGetNode(t *testing.T) { g := New() n := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go") g.AddNode(n) assert.Equal(t, n, g.GetNode("a.go::Foo")) assert.Equal(t, n, g.GetNodeByQualName("pkg.Foo")) assert.Equal(t, []*Node{n}, g.FindNodesByName("Foo")) assert.Equal(t, []*Node{n}, g.GetFileNodes("a.go")) assert.Nil(t, g.GetNode("nonexistent")) } func TestAddAndGetEdge(t *testing.T) { g := New() n1 := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go") n2 := makeNode("b.go::Bar", "Bar", KindFunction, "b.go", "go") g.AddNode(n1) g.AddNode(n2) e := &Edge{From: n1.ID, To: n2.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 5} g.AddEdge(e) out := g.GetOutEdges(n1.ID) require.Len(t, out, 1) assert.Equal(t, EdgeCalls, out[0].Kind) in := g.GetInEdges(n2.ID) require.Len(t, in, 1) assert.Equal(t, n1.ID, in[0].From) } func TestEvictFile(t *testing.T) { g := New() n1 := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go") n2 := makeNode("a.go::Bar", "Bar", KindFunction, "a.go", "go") n3 := makeNode("b.go::Baz", "Baz", KindFunction, "b.go", "go") g.AddNode(n1) g.AddNode(n2) g.AddNode(n3) g.AddEdge(&Edge{From: n1.ID, To: n3.ID, Kind: EdgeCalls, FilePath: "a.go", Line: 1}) g.AddEdge(&Edge{From: n3.ID, To: n2.ID, Kind: EdgeCalls, FilePath: "b.go", Line: 2}) nodesRm, edgesRm := g.EvictFile("a.go") assert.Equal(t, 2, nodesRm) assert.Equal(t, 2, edgesRm) // both edges reference evicted node IDs assert.Nil(t, g.GetNode("a.go::Foo")) assert.Nil(t, g.GetNode("a.go::Bar")) assert.NotNil(t, g.GetNode("b.go::Baz")) // Edge from b.go to a.go::Bar should also be cleaned from inEdges. assert.Empty(t, g.GetOutEdges("a.go::Foo")) } func TestEvictFile_NoNodes(t *testing.T) { g := New() n, e := g.EvictFile("nonexistent.go") assert.Equal(t, 0, n) assert.Equal(t, 0, e) } func TestNodeAndEdgeCount(t *testing.T) { g := New() g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go")) g.AddNode(makeNode("b.go::B", "B", KindType, "b.go", "go")) g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeReferences, FilePath: "a.go", Line: 1}) assert.Equal(t, 2, g.NodeCount()) assert.Equal(t, 1, g.EdgeCount()) } func TestStats(t *testing.T) { g := New() g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go")) g.AddNode(makeNode("b.go::B", "B", KindType, "b.go", "go")) g.AddNode(makeNode("c.ts::C", "C", KindFunction, "c.ts", "typescript")) g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeReferences, FilePath: "a.go", Line: 1}) s := g.Stats() assert.Equal(t, 3, s.TotalNodes) assert.Equal(t, 1, s.TotalEdges) assert.Equal(t, 2, s.ByKind["function"]) assert.Equal(t, 1, s.ByKind["type"]) assert.Equal(t, 2, s.ByLanguage["go"]) assert.Equal(t, 1, s.ByLanguage["typescript"]) } func TestAllNodesAndEdges(t *testing.T) { g := New() g.AddNode(makeNode("a.go::A", "A", KindFunction, "a.go", "go")) g.AddNode(makeNode("b.go::B", "B", KindFunction, "b.go", "go")) g.AddEdge(&Edge{From: "a.go::A", To: "b.go::B", Kind: EdgeCalls, FilePath: "a.go", Line: 1}) assert.Len(t, g.AllNodes(), 2) assert.Len(t, g.AllEdges(), 1) } func TestConcurrency(t *testing.T) { g := New() var wg sync.WaitGroup // Concurrent writers. for i := range 50 { wg.Add(1) go func(i int) { defer wg.Done() id := "file.go::" + string(rune('A'+i)) n := makeNode(id, string(rune('A'+i)), KindFunction, "file.go", "go") n.QualName = "" // avoid collision g.AddNode(n) }(i) } // Concurrent readers. for range 50 { wg.Add(1) go func() { defer wg.Done() _ = g.NodeCount() _ = g.GetFileNodes("file.go") _ = g.Stats() }() } wg.Wait() } func TestNodeBrief(t *testing.T) { n := &Node{ ID: "a.go::Foo", Kind: KindFunction, Name: "Foo", QualName: "pkg.Foo", FilePath: "a.go", StartLine: 10, EndLine: 20, Language: "go", Meta: map[string]any{"signature": "func Foo()"}, } b := n.Brief() assert.Equal(t, "a.go::Foo", b["id"]) assert.Equal(t, "Foo", b["name"]) assert.Equal(t, NodeKind("function"), b["kind"]) assert.Equal(t, "a.go", b["file_path"]) assert.Equal(t, 10, b["start_line"]) // Should NOT contain meta, qual_name, end_line, language. _, hasMeta := b["meta"] assert.False(t, hasMeta) } func TestValidNodeKind(t *testing.T) { assert.True(t, ValidNodeKind(KindFunction)) assert.True(t, ValidNodeKind(KindFile)) assert.False(t, ValidNodeKind(NodeKind("unknown"))) } func makeRepoNode(id, name string, kind NodeKind, file, lang, repo string) *Node { return &Node{ ID: id, Kind: kind, Name: name, QualName: repo + "." + name, FilePath: file, StartLine: 1, EndLine: 10, Language: lang, RepoPrefix: repo, } } func TestAddNode_ByRepoIndex(t *testing.T) { g := New() n1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") n2 := makeRepoNode("repoB/b.go::Bar", "Bar", KindFunction, "repoB/b.go", "go", "repoB") n3 := makeRepoNode("repoA/c.go::Baz", "Baz", KindType, "repoA/c.go", "go", "repoA") g.AddNode(n1) g.AddNode(n2) g.AddNode(n3) repoANodes := g.GetRepoNodes("repoA") assert.Len(t, repoANodes, 2) repoBNodes := g.GetRepoNodes("repoB") assert.Len(t, repoBNodes, 1) assert.Equal(t, "Bar", repoBNodes[0].Name) } func TestAddNode_EmptyRepoPrefix(t *testing.T) { g := New() n := makeNode("a.go::Foo", "Foo", KindFunction, "a.go", "go") g.AddNode(n) // Nodes without RepoPrefix should not appear in byRepo. assert.Empty(t, g.GetRepoNodes("")) assert.Empty(t, g.RepoPrefixes()) } func TestGetRepoNodes_ReturnsCopy(t *testing.T) { g := New() n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") g.AddNode(n) nodes := g.GetRepoNodes("repoA") nodes[0] = nil // mutate the returned slice assert.NotNil(t, g.GetRepoNodes("repoA")[0], "GetRepoNodes should return a copy") } func TestGetRepoNodes_NotFound(t *testing.T) { g := New() assert.Empty(t, g.GetRepoNodes("nonexistent")) } func TestEvictRepo(t *testing.T) { g := New() nA1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") nA2 := makeRepoNode("repoA/a.go::Bar", "Bar", KindFunction, "repoA/a.go", "go", "repoA") nB1 := makeRepoNode("repoB/b.go::Baz", "Baz", KindFunction, "repoB/b.go", "go", "repoB") g.AddNode(nA1) g.AddNode(nA2) g.AddNode(nB1) // Edges: intra-repoA, cross-repo A→B, intra-repoB self-ref g.AddEdge(&Edge{From: nA1.ID, To: nA2.ID, Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 1}) g.AddEdge(&Edge{From: nA1.ID, To: nB1.ID, Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 2, CrossRepo: true}) g.AddEdge(&Edge{From: nB1.ID, To: nA2.ID, Kind: EdgeCalls, FilePath: "repoB/b.go", Line: 3, CrossRepo: true}) nodesRm, edgesRm := g.EvictRepo("repoA") assert.Equal(t, 2, nodesRm) assert.Equal(t, 3, edgesRm) // all 3 edges reference repoA nodes // repoA nodes gone. assert.Nil(t, g.GetNode("repoA/a.go::Foo")) assert.Nil(t, g.GetNode("repoA/a.go::Bar")) assert.Empty(t, g.GetRepoNodes("repoA")) // repoB node still present. assert.NotNil(t, g.GetNode("repoB/b.go::Baz")) assert.Len(t, g.GetRepoNodes("repoB"), 1) // byName cleaned for evicted nodes. assert.Empty(t, g.FindNodesByName("Foo")) assert.Empty(t, g.FindNodesByName("Bar")) assert.Len(t, g.FindNodesByName("Baz"), 1) // byFile cleaned for evicted nodes. assert.Empty(t, g.GetFileNodes("repoA/a.go")) assert.Len(t, g.GetFileNodes("repoB/b.go"), 1) } func TestEvictRepo_NoNodes(t *testing.T) { g := New() n, e := g.EvictRepo("nonexistent") assert.Equal(t, 0, n) assert.Equal(t, 0, e) } func TestEvictRepo_QualNameCleaned(t *testing.T) { g := New() n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") g.AddNode(n) assert.NotNil(t, g.GetNodeByQualName("repoA.Foo")) g.EvictRepo("repoA") assert.Nil(t, g.GetNodeByQualName("repoA.Foo")) } func TestRepoStats(t *testing.T) { g := New() g.AddNode(makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")) g.AddNode(makeRepoNode("repoA/b.go::Bar", "Bar", KindType, "repoA/b.go", "go", "repoA")) g.AddNode(makeRepoNode("repoB/c.ts::Baz", "Baz", KindFunction, "repoB/c.ts", "typescript", "repoB")) // Edge from repoA node. g.AddEdge(&Edge{From: "repoA/a.go::Foo", To: "repoA/b.go::Bar", Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 1}) // Cross-repo edge from repoA to repoB. g.AddEdge(&Edge{From: "repoA/a.go::Foo", To: "repoB/c.ts::Baz", Kind: EdgeCalls, FilePath: "repoA/a.go", Line: 2, CrossRepo: true}) stats := g.RepoStats() require.Len(t, stats, 2) sA := stats["repoA"] assert.Equal(t, 2, sA.TotalNodes) assert.Equal(t, 2, sA.TotalEdges) // both edges originate from repoA assert.Equal(t, 1, sA.ByKind["function"]) assert.Equal(t, 1, sA.ByKind["type"]) assert.Equal(t, 2, sA.ByLanguage["go"]) sB := stats["repoB"] assert.Equal(t, 1, sB.TotalNodes) assert.Equal(t, 0, sB.TotalEdges) // no edges originate from repoB assert.Equal(t, 1, sB.ByKind["function"]) assert.Equal(t, 1, sB.ByLanguage["typescript"]) } func TestRepoStats_Empty(t *testing.T) { g := New() stats := g.RepoStats() assert.Empty(t, stats) } func TestRepoPrefixes(t *testing.T) { g := New() g.AddNode(makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA")) g.AddNode(makeRepoNode("repoB/b.go::Bar", "Bar", KindFunction, "repoB/b.go", "go", "repoB")) g.AddNode(makeRepoNode("repoA/c.go::Baz", "Baz", KindFunction, "repoA/c.go", "go", "repoA")) prefixes := g.RepoPrefixes() assert.Len(t, prefixes, 2) assert.ElementsMatch(t, []string{"repoA", "repoB"}, prefixes) } func TestRepoPrefixes_Empty(t *testing.T) { g := New() assert.Empty(t, g.RepoPrefixes()) } func TestEvictFile_CleansByRepoIndex(t *testing.T) { g := New() n1 := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") n2 := makeRepoNode("repoA/b.go::Bar", "Bar", KindFunction, "repoA/b.go", "go", "repoA") g.AddNode(n1) g.AddNode(n2) assert.Len(t, g.GetRepoNodes("repoA"), 2) g.EvictFile("repoA/a.go") // Only n2 should remain in byRepo. repoNodes := g.GetRepoNodes("repoA") require.Len(t, repoNodes, 1) assert.Equal(t, "Bar", repoNodes[0].Name) } func TestEvictFile_CleansByRepoIndex_LastNode(t *testing.T) { g := New() n := makeRepoNode("repoA/a.go::Foo", "Foo", KindFunction, "repoA/a.go", "go", "repoA") g.AddNode(n) g.EvictFile("repoA/a.go") assert.Empty(t, g.GetRepoNodes("repoA")) assert.Empty(t, g.RepoPrefixes()) } // Feature: multi-repo-support, Property 7: Per-repo index correctness // genRepoPrefix generates a short repo prefix like "repoA", "repoB", etc. func genRepoPrefix() *rapid.Generator[string] { return rapid.Custom(func(t *rapid.T) string { return "repo" + rapid.StringMatching(`[A-Z][a-z]{0,4}`).Draw(t, "suffix") }) } // genMultiRepoGraph builds a graph with 2-5 repo prefixes and 1-10 nodes per repo, // plus cross-repo edges. Returns the graph and a map of prefix → expected node IDs. func genMultiRepoGraph(t *rapid.T) (*Graph, map[string][]string) { g := New() numRepos := rapid.IntRange(2, 5).Draw(t, "numRepos") // Generate distinct prefixes. prefixSet := make(map[string]bool) var prefixes []string for len(prefixes) < numRepos { p := genRepoPrefix().Draw(t, "prefix") if !prefixSet[p] { prefixSet[p] = true prefixes = append(prefixes, p) } } expected := make(map[string][]string) // prefix → node IDs var allNodeIDs []string kinds := []NodeKind{KindFunction, KindType, KindMethod, KindVariable} for _, prefix := range prefixes { numNodes := rapid.IntRange(1, 10).Draw(t, "numNodes_"+prefix) for j := 0; j < numNodes; j++ { name := rapid.StringMatching(`[A-Z][a-zA-Z0-9]{1,10}`).Draw(t, "name") file := prefix + "/" + rapid.StringMatching(`[a-z]{1,8}`).Draw(t, "file") + ".go" id := file + "::" + name kind := kinds[rapid.IntRange(0, len(kinds)-1).Draw(t, "kind")] n := makeRepoNode(id, name, kind, file, "go", prefix) g.AddNode(n) expected[prefix] = append(expected[prefix], id) allNodeIDs = append(allNodeIDs, id) } } // Add some edges including cross-repo ones. if len(allNodeIDs) >= 2 { numEdges := rapid.IntRange(1, len(allNodeIDs)).Draw(t, "numEdges") for i := 0; i < numEdges; i++ { fromIdx := rapid.IntRange(0, len(allNodeIDs)-1).Draw(t, "fromIdx") toIdx := rapid.IntRange(0, len(allNodeIDs)-1).Draw(t, "toIdx") if fromIdx == toIdx { continue } fromNode := g.GetNode(allNodeIDs[fromIdx]) toNode := g.GetNode(allNodeIDs[toIdx]) crossRepo := fromNode.RepoPrefix != toNode.RepoPrefix g.AddEdge(&Edge{ From: allNodeIDs[fromIdx], To: allNodeIDs[toIdx], Kind: EdgeCalls, FilePath: fromNode.FilePath, Line: i + 1, CrossRepo: crossRepo, }) } } return g, expected } // TestPropertyPerRepoIndexCorrectness verifies that GetRepoNodes returns exactly // the nodes with matching RepoPrefix, and the union of all GetRepoNodes equals // all nodes that have a RepoPrefix. func TestPropertyPerRepoIndexCorrectness(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { g, expected := genMultiRepoGraph(rt) // For each prefix, GetRepoNodes must return exactly the matching nodes. for prefix, wantIDs := range expected { got := g.GetRepoNodes(prefix) gotIDs := make([]string, len(got)) for i, n := range got { gotIDs[i] = n.ID // Every returned node must have the correct RepoPrefix. assert.Equal(rt, prefix, n.RepoPrefix, "GetRepoNodes(%q) returned node %q with wrong RepoPrefix %q", prefix, n.ID, n.RepoPrefix) } assert.ElementsMatch(rt, wantIDs, gotIDs, "GetRepoNodes(%q) returned wrong set of node IDs", prefix) } // Union of all GetRepoNodes for all prefixes must equal all nodes with a RepoPrefix. prefixes := g.RepoPrefixes() unionIDs := make(map[string]bool) for _, p := range prefixes { for _, n := range g.GetRepoNodes(p) { unionIDs[n.ID] = true } } allNodes := g.AllNodes() repoNodeIDs := make(map[string]bool) for _, n := range allNodes { if n.RepoPrefix != "" { repoNodeIDs[n.ID] = true } } assert.Equal(rt, repoNodeIDs, unionIDs, "Union of GetRepoNodes for all prefixes must equal all nodes with a RepoPrefix") }) } // Feature: multi-repo-support, Property 8: Repo eviction completeness // TestPropertyRepoEvictionCompleteness verifies that after EvictRepo, zero nodes/edges // remain for the evicted prefix, and other repos are unchanged. func TestPropertyRepoEvictionCompleteness(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { g, expected := genMultiRepoGraph(rt) // Pick a repo to evict. prefixes := g.RepoPrefixes() require.NotEmpty(rt, prefixes, "graph must have at least one repo prefix") evictPrefix := prefixes[rapid.IntRange(0, len(prefixes)-1).Draw(rt, "evictIdx")] // Record pre-eviction state for other repos. otherCounts := make(map[string]int) for _, p := range prefixes { if p != evictPrefix { otherCounts[p] = len(g.GetRepoNodes(p)) } } // Evict. nodesRm, _ := g.EvictRepo(evictPrefix) assert.Equal(rt, len(expected[evictPrefix]), nodesRm, "EvictRepo should remove exactly the expected number of nodes") // Verify GetRepoNodes returns empty for evicted prefix. assert.Empty(rt, g.GetRepoNodes(evictPrefix), "GetRepoNodes(%q) must be empty after eviction", evictPrefix) // Verify no nodes in AllNodes have the evicted prefix. for _, n := range g.AllNodes() { assert.NotEqual(rt, evictPrefix, n.RepoPrefix, "AllNodes() still contains node %q with evicted prefix %q", n.ID, evictPrefix) } // Verify no edges reference evicted node IDs. evictedIDs := make(map[string]bool) for _, id := range expected[evictPrefix] { evictedIDs[id] = true } for _, e := range g.AllEdges() { assert.False(rt, evictedIDs[e.From], "Edge from %q → %q still references evicted node", e.From, e.To) assert.False(rt, evictedIDs[e.To], "Edge from %q → %q still references evicted node", e.From, e.To) } // Verify other repos' node counts are unchanged. for p, wantCount := range otherCounts { gotCount := len(g.GetRepoNodes(p)) assert.Equal(rt, wantCount, gotCount, "Repo %q node count changed after evicting %q", p, evictPrefix) } }) }