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

4007 lines
141 KiB
Go

// Package storetest provides a conformance test suite that every
// graph.Store implementation MUST pass. Each backend (in-memory,
// bbolt-on-disk, SQLite-on-disk, remote-network-client) has a thin
// _test.go that calls RunConformance(t, factory) and inherits the
// full battery.
//
// The contract this package encodes is the union of behaviour the
// rest of gortex depends on from *graph.Graph today. New Store
// implementations are expected to satisfy every test before they can
// be considered a drop-in replacement.
package storetest
import (
"fmt"
"sort"
"sync"
"testing"
"github.com/zzet/gortex/internal/graph"
)
// Factory constructs a fresh, empty Store. RunConformance calls it
// many times across subtests; each invocation must yield an
// independent store with no leakage from previous runs. Backends with
// on-disk state should use t.TempDir() internally to isolate.
type Factory func(t *testing.T) graph.Store
// RunConformance runs the full conformance suite against the Store
// produced by factory. Backends invoke it from a _test.go in their
// own package.
func RunConformance(t *testing.T, factory Factory) {
t.Helper()
t.Run("AddGetNode", func(t *testing.T) { testAddGetNode(t, factory) })
t.Run("AddGetEdge", func(t *testing.T) { testAddGetEdge(t, factory) })
t.Run("AddNodeIdempotent", func(t *testing.T) { testAddNodeIdempotent(t, factory) })
t.Run("AddEdgeIdempotent", func(t *testing.T) { testAddEdgeIdempotent(t, factory) })
t.Run("AddEdgeLineDisambiguates", func(t *testing.T) { testAddEdgeLineDisambiguates(t, factory) })
t.Run("AddBatch", func(t *testing.T) { testAddBatch(t, factory) })
t.Run("RemoveEdge", func(t *testing.T) { testRemoveEdge(t, factory) })
t.Run("EvictFile", func(t *testing.T) { testEvictFile(t, factory) })
t.Run("EvictFile_NoNodes", func(t *testing.T) { testEvictFileNoNodes(t, factory) })
t.Run("EvictRepo", func(t *testing.T) { testEvictRepo(t, factory) })
t.Run("EvictRepo_NoNodes", func(t *testing.T) { testEvictRepoNoNodes(t, factory) })
t.Run("NodeAndEdgeCount", func(t *testing.T) { testNodeAndEdgeCount(t, factory) })
t.Run("AllNodesAndEdges", func(t *testing.T) { testAllNodesAndEdges(t, factory) })
t.Run("FindNodesByName", func(t *testing.T) { testFindNodesByName(t, factory) })
t.Run("FindNodesByNameInRepo", func(t *testing.T) { testFindNodesByNameInRepo(t, factory) })
t.Run("FindNodesByNameContaining", func(t *testing.T) { testFindNodesByNameContaining(t, factory) })
t.Run("GetFileNodes", func(t *testing.T) { testGetFileNodes(t, factory) })
t.Run("GetRepoNodes", func(t *testing.T) { testGetRepoNodes(t, factory) })
t.Run("GetRepoEdges", func(t *testing.T) { testGetRepoEdges(t, factory) })
t.Run("GetNodeByQualName", func(t *testing.T) { testGetNodeByQualName(t, factory) })
t.Run("Stats", func(t *testing.T) { testStats(t, factory) })
t.Run("RepoStats", func(t *testing.T) { testRepoStats(t, factory) })
t.Run("RepoPrefixes", func(t *testing.T) { testRepoPrefixes(t, factory) })
t.Run("SetEdgeProvenance", func(t *testing.T) { testSetEdgeProvenance(t, factory) })
t.Run("SetEdgeProvenanceBatch", func(t *testing.T) { testSetEdgeProvenanceBatch(t, factory) })
t.Run("ReindexEdge", func(t *testing.T) { testReindexEdge(t, factory) })
t.Run("ReindexEdges", func(t *testing.T) { testReindexEdges(t, factory) })
t.Run("Concurrency", func(t *testing.T) { testConcurrency(t, factory) })
t.Run("EdgeIdentityRevisions", func(t *testing.T) { testEdgeIdentityRevisions(t, factory) })
t.Run("VerifyEdgeIdentities", func(t *testing.T) { testVerifyEdgeIdentities(t, factory) })
t.Run("RepoMemoryEstimate", func(t *testing.T) { testRepoMemoryEstimate(t, factory) })
t.Run("AllRepoMemoryEstimates", func(t *testing.T) { testAllRepoMemoryEstimates(t, factory) })
t.Run("MetaPreserved", func(t *testing.T) { testMetaPreserved(t, factory) })
t.Run("EmptyStore", func(t *testing.T) { testEmptyStore(t, factory) })
t.Run("EdgesByKind", func(t *testing.T) { testEdgesByKind(t, factory) })
t.Run("NodesByKind", func(t *testing.T) { testNodesByKind(t, factory) })
t.Run("EdgesWithUnresolvedTarget", func(t *testing.T) { testEdgesWithUnresolvedTarget(t, factory) })
t.Run("FnValuePlaceholderEdges", func(t *testing.T) { testFnValuePlaceholderEdges(t, factory) })
t.Run("LightEdgeScanner", func(t *testing.T) { testLightEdgeScanner(t, factory) })
t.Run("GetNodesByIDs", func(t *testing.T) { testGetNodesByIDs(t, factory) })
t.Run("FindNodesByNames", func(t *testing.T) { testFindNodesByNames(t, factory) })
t.Run("GetEdgesByNodeIDs", func(t *testing.T) { testGetEdgesByNodeIDs(t, factory) })
t.Run("SymbolBundleSearcher", func(t *testing.T) { testSymbolBundleSearcher(t, factory) })
t.Run("DeadCodeCandidator", func(t *testing.T) { testDeadCodeCandidator(t, factory) })
t.Run("IfaceImplementsScanner", func(t *testing.T) { testIfaceImplementsScanner(t, factory) })
t.Run("NodeDegreeAggregator", func(t *testing.T) { testNodeDegreeAggregator(t, factory) })
t.Run("NodeFanAggregator", func(t *testing.T) { testNodeFanAggregator(t, factory) })
t.Run("FileImporters", func(t *testing.T) { testFileImporters(t, factory) })
t.Run("InEdgeCounter", func(t *testing.T) { testInEdgeCounter(t, factory) })
t.Run("NodesInFilesByKindFinder", func(t *testing.T) { testNodesInFilesByKindFinder(t, factory) })
t.Run("EdgesByKindsScanner", func(t *testing.T) { testEdgesByKindsScanner(t, factory) })
t.Run("NodesByKindsScanner", func(t *testing.T) { testNodesByKindsScanner(t, factory) })
t.Run("EdgeKindCounter", func(t *testing.T) { testEdgeKindCounter(t, factory) })
t.Run("CrossRepoEdgeAggregator", func(t *testing.T) { testCrossRepoEdgeAggregator(t, factory) })
t.Run("FileImportAggregator", func(t *testing.T) { testFileImportAggregator(t, factory) })
t.Run("InDegreeForNodes", func(t *testing.T) { testInDegreeForNodes(t, factory) })
t.Run("ReachableForwardByKinds", func(t *testing.T) { testReachableForwardByKinds(t, factory) })
t.Run("BFS", func(t *testing.T) { testBFS(t, factory) })
t.Run("ThrowerErrorSurfacer", func(t *testing.T) { testThrowerErrorSurfacer(t, factory) })
t.Run("EdgeAdjacencyForKinds", func(t *testing.T) { testEdgeAdjacencyForKinds(t, factory) })
t.Run("CommunityCrossingsByKind", func(t *testing.T) { testCommunityCrossingsByKind(t, factory) })
t.Run("NodeIDsByKinds", func(t *testing.T) { testNodeIDsByKinds(t, factory) })
t.Run("MemberMethodsByType", func(t *testing.T) { testMemberMethodsByType(t, factory) })
t.Run("StructuralParentEdges", func(t *testing.T) { testStructuralParentEdges(t, factory) })
t.Run("CrossRepoCandidates", func(t *testing.T) { testCrossRepoCandidates(t, factory) })
t.Run("ExtractCandidates", func(t *testing.T) { testExtractCandidates(t, factory) })
t.Run("FileSymbolNamesByPaths", func(t *testing.T) { testFileSymbolNamesByPaths(t, factory) })
t.Run("ClassHierarchyTraverser", func(t *testing.T) { testClassHierarchyTraverser(t, factory) })
t.Run("FileEditingContext", func(t *testing.T) { testFileEditingContext(t, factory) })
t.Run("NodeDegreeByKinds", func(t *testing.T) { testNodeDegreeByKinds(t, factory) })
t.Run("CloneShingleSidecar", func(t *testing.T) { testCloneShingleSidecar(t, factory) })
t.Run("ChurnEnrichmentSidecar", func(t *testing.T) { testChurnEnrichmentSidecar(t, factory) })
t.Run("CoverageEnrichmentSidecar", func(t *testing.T) { testCoverageEnrichmentSidecar(t, factory) })
t.Run("ReleaseEnrichmentSidecar", func(t *testing.T) { testReleaseEnrichmentSidecar(t, factory) })
t.Run("BlameEnrichmentSidecar", func(t *testing.T) { testBlameEnrichmentSidecar(t, factory) })
t.Run("ContractBridgeRoundTrip", func(t *testing.T) { testContractBridgeRoundTrip(t, factory) })
}
// -- fixture helpers ---------------------------------------------------
func mkNode(id, name, file string, kind graph.NodeKind) *graph.Node {
return &graph.Node{
ID: id,
Kind: kind,
Name: name,
FilePath: file,
StartLine: 1,
EndLine: 10,
Language: "go",
}
}
func mkRepoNode(id, name, file, repo string, kind graph.NodeKind) *graph.Node {
n := mkNode(id, name, file, kind)
n.RepoPrefix = repo
return n
}
func mkEdge(from, to string, kind graph.EdgeKind) *graph.Edge {
return &graph.Edge{
From: from, To: to, Kind: kind,
FilePath: "test.go", Line: 1,
Confidence: 1.0,
Origin: graph.OriginASTResolved,
}
}
func sortNodeIDs(nodes []*graph.Node) []string {
ids := make([]string, 0, len(nodes))
for _, n := range nodes {
if n != nil {
ids = append(ids, n.ID)
}
}
sort.Strings(ids)
return ids
}
func sortEdgeKeys(edges []*graph.Edge) []string {
keys := make([]string, 0, len(edges))
for _, e := range edges {
if e != nil {
keys = append(keys, fmt.Sprintf("%s|%s|%s|%d", e.From, e.To, e.Kind, e.Line))
}
}
sort.Strings(keys)
return keys
}
// -- individual subtests ----------------------------------------------
func testAddGetNode(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n := mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction)
s.AddNode(n)
got := s.GetNode("a.go::Foo")
if got == nil {
t.Fatalf("GetNode returned nil for inserted node")
return
}
if got.Name != "Foo" || got.FilePath != "a.go" || got.Kind != graph.KindFunction {
t.Fatalf("round-trip mismatch: %+v", got)
}
if s.GetNode("missing") != nil {
t.Fatalf("GetNode should return nil for missing key")
}
}
func testAddGetEdge(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddEdge(mkEdge("a", "b", graph.EdgeCalls))
out := s.GetOutEdges("a")
if len(out) != 1 || out[0].To != "b" {
t.Fatalf("GetOutEdges(a) = %+v, want one edge to b", out)
}
in := s.GetInEdges("b")
if len(in) != 1 || in[0].From != "a" {
t.Fatalf("GetInEdges(b) = %+v, want one edge from a", in)
}
}
func testAddNodeIdempotent(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n := mkNode("dup", "Dup", "x.go", graph.KindFunction)
s.AddNode(n)
s.AddNode(n)
s.AddNode(n)
if s.NodeCount() != 1 {
t.Fatalf("NodeCount after 3x add = %d, want 1", s.NodeCount())
}
}
func testAddEdgeIdempotent(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e := mkEdge("a", "b", graph.EdgeCalls)
s.AddEdge(e)
s.AddEdge(e)
s.AddEdge(e)
if got := len(s.GetOutEdges("a")); got != 1 {
t.Fatalf("OutEdges after 3x add = %d, want 1", got)
}
}
func testAddEdgeLineDisambiguates(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e1 := mkEdge("a", "b", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("a", "b", graph.EdgeCalls)
e2.Line = 5
s.AddEdge(e1)
s.AddEdge(e2)
if got := len(s.GetOutEdges("a")); got != 2 {
t.Fatalf("OutEdges with different lines = %d, want 2", got)
}
}
func testAddBatch(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
nodes := []*graph.Node{
mkNode("a", "A", "x.go", graph.KindFunction),
mkNode("b", "B", "x.go", graph.KindFunction),
mkNode("c", "C", "y.go", graph.KindType),
}
edges := []*graph.Edge{
mkEdge("a", "b", graph.EdgeCalls),
mkEdge("b", "c", graph.EdgeReferences),
}
s.AddBatch(nodes, edges)
if s.NodeCount() != 3 {
t.Fatalf("NodeCount after AddBatch = %d, want 3", s.NodeCount())
}
if s.EdgeCount() != 2 {
t.Fatalf("EdgeCount after AddBatch = %d, want 2", s.EdgeCount())
}
}
func testRemoveEdge(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e := mkEdge("a", "b", graph.EdgeCalls)
s.AddEdge(e)
if !s.RemoveEdge("a", "b", graph.EdgeCalls) {
t.Fatalf("RemoveEdge returned false for existing edge")
}
if len(s.GetOutEdges("a")) != 0 {
t.Fatalf("OutEdges after RemoveEdge = nonzero")
}
if len(s.GetInEdges("b")) != 0 {
t.Fatalf("InEdges after RemoveEdge = nonzero")
}
// Removing non-existent should report false but not panic.
if s.RemoveEdge("a", "b", graph.EdgeCalls) {
t.Fatalf("RemoveEdge returned true for missing edge")
}
}
func testEvictFile(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("a.go::Bar", "Bar", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::Baz", "Baz", "b.go", graph.KindFunction))
s.AddEdge(mkEdge("a.go::Foo", "a.go::Bar", graph.EdgeCalls))
s.AddEdge(mkEdge("a.go::Bar", "b.go::Baz", graph.EdgeCalls))
nodesRemoved, edgesRemoved := s.EvictFile("a.go")
if nodesRemoved != 2 {
t.Fatalf("EvictFile nodesRemoved = %d, want 2", nodesRemoved)
}
if edgesRemoved == 0 {
t.Fatalf("EvictFile edgesRemoved should be > 0")
}
if s.GetNode("a.go::Foo") != nil {
t.Fatalf("evicted node still present")
}
if s.GetNode("b.go::Baz") == nil {
t.Fatalf("unrelated node was evicted")
}
}
func testEvictFileNoNodes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n, e := s.EvictFile("nonexistent.go")
if n != 0 || e != 0 {
t.Fatalf("EvictFile on empty file returned (%d, %d), want (0, 0)", n, e)
}
}
func testEvictRepo(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r1/b.go::Bar", "Bar", "r1/b.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindFunction))
s.AddEdge(mkEdge("r1/a.go::Foo", "r1/b.go::Bar", graph.EdgeCalls))
nodesRemoved, edgesRemoved := s.EvictRepo("r1")
if nodesRemoved != 2 {
t.Fatalf("EvictRepo nodesRemoved = %d, want 2", nodesRemoved)
}
if edgesRemoved == 0 {
t.Fatalf("EvictRepo edgesRemoved should be > 0")
}
if s.GetNode("r1/a.go::Foo") != nil {
t.Fatalf("r1 node still present")
}
if s.GetNode("r2/x.go::Baz") == nil {
t.Fatalf("r2 node was evicted")
}
}
func testEvictRepoNoNodes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n, e := s.EvictRepo("nonexistent-repo")
if n != 0 || e != 0 {
t.Fatalf("EvictRepo on missing repo returned (%d, %d), want (0, 0)", n, e)
}
}
func testNodeAndEdgeCount(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
if s.NodeCount() != 0 || s.EdgeCount() != 0 {
t.Fatalf("empty store reports nonzero counts")
}
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddEdge(mkEdge("a", "b", graph.EdgeCalls))
if s.NodeCount() != 2 {
t.Fatalf("NodeCount = %d, want 2", s.NodeCount())
}
if s.EdgeCount() != 1 {
t.Fatalf("EdgeCount = %d, want 1", s.EdgeCount())
}
}
func testAllNodesAndEdges(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "y.go", graph.KindType))
s.AddEdge(mkEdge("a", "b", graph.EdgeReferences))
gotN := sortNodeIDs(s.AllNodes())
wantN := []string{"a", "b"}
if fmt.Sprint(gotN) != fmt.Sprint(wantN) {
t.Fatalf("AllNodes = %v, want %v", gotN, wantN)
}
gotE := sortEdgeKeys(s.AllEdges())
if len(gotE) != 1 {
t.Fatalf("AllEdges = %v, want one entry", gotE)
}
}
func testFindNodesByName(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::Foo", "Foo", "b.go", graph.KindFunction))
s.AddNode(mkNode("c.go::Bar", "Bar", "c.go", graph.KindFunction))
got := sortNodeIDs(s.FindNodesByName("Foo"))
want := []string{"a.go::Foo", "b.go::Foo"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("FindNodesByName(Foo) = %v, want %v", got, want)
}
if len(s.FindNodesByName("MissingName")) != 0 {
t.Fatalf("FindNodesByName for missing name should be empty")
}
}
func testFindNodesByNameInRepo(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/a.go::Foo", "Foo", "r2/a.go", "r2", graph.KindFunction))
got := sortNodeIDs(s.FindNodesByNameInRepo("Foo", "r1"))
want := []string{"r1/a.go::Foo"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("FindNodesByNameInRepo(Foo, r1) = %v, want %v", got, want)
}
}
func testFindNodesByNameContaining(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
// Three "log"-containing names + one unrelated.
s.AddNode(mkNode("a.go::Login", "Login", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::LoginHandler", "LoginHandler", "b.go", graph.KindFunction))
s.AddNode(mkNode("c.go::Logout", "Logout", "c.go", graph.KindFunction))
s.AddNode(mkNode("d.go::Unrelated", "Unrelated", "d.go", graph.KindFunction))
// Case-insensitive substring match should return exactly the 3
// "log"-bearing nodes.
got := sortNodeIDs(s.FindNodesByNameContaining("log", 10))
want := []string{"a.go::Login", "b.go::LoginHandler", "c.go::Logout"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("FindNodesByNameContaining(log, 10) = %v, want %v", got, want)
}
// Mixed-case query — must still match (case-insensitive).
gotUpper := sortNodeIDs(s.FindNodesByNameContaining("LOG", 10))
if fmt.Sprint(gotUpper) != fmt.Sprint(want) {
t.Fatalf("FindNodesByNameContaining(LOG, 10) = %v, want %v", gotUpper, want)
}
// Limit is honoured. Asking for 2 must return at most 2.
gotLimited := s.FindNodesByNameContaining("log", 2)
if len(gotLimited) != 2 {
t.Fatalf("FindNodesByNameContaining(log, 2) returned %d, want 2", len(gotLimited))
}
// Empty needle returns nothing — never the whole graph.
if got := s.FindNodesByNameContaining("", 10); len(got) != 0 {
t.Fatalf("FindNodesByNameContaining(\"\") returned %d, want 0", len(got))
}
// No match — empty slice.
if got := s.FindNodesByNameContaining("nonexistent_substring_xyz", 10); len(got) != 0 {
t.Fatalf("FindNodesByNameContaining(no-match) returned %d, want 0", len(got))
}
}
func testGetFileNodes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("a.go::Bar", "Bar", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::Baz", "Baz", "b.go", graph.KindFunction))
got := sortNodeIDs(s.GetFileNodes("a.go"))
want := []string{"a.go::Bar", "a.go::Foo"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("GetFileNodes(a.go) = %v, want %v", got, want)
}
}
func testGetRepoNodes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r1/b.go::Bar", "Bar", "r1/b.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindFunction))
got := sortNodeIDs(s.GetRepoNodes("r1"))
want := []string{"r1/a.go::Foo", "r1/b.go::Bar"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("GetRepoNodes(r1) = %v, want %v", got, want)
}
}
// testGetRepoEdges asserts that GetRepoEdges returns every edge whose
// SOURCE node carries the requested RepoPrefix, regardless of where
// the target lives — same-repo intra edges, cross-repo edges (source
// in r1 → target in r2), AND unresolved::* targets all count. Edges
// whose source is in a different repo (or unscoped) MUST NOT appear.
// Empty prefix returns nil so callers don't accidentally fall through
// to a full-graph scan.
func testGetRepoEdges(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
// r1 has two nodes that originate outgoing edges; r2 has a target
// node and one of its own source nodes.
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r1/b.go::Bar", "Bar", "r1/b.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindFunction))
s.AddNode(mkRepoNode("r2/y.go::Qux", "Qux", "r2/y.go", "r2", graph.KindFunction))
// r1-intra (Foo → Bar) — same repo.
s.AddEdge(mkEdge("r1/a.go::Foo", "r1/b.go::Bar", graph.EdgeCalls))
// r1 → r2 cross-repo (Foo → Baz).
s.AddEdge(mkEdge("r1/a.go::Foo", "r2/x.go::Baz", graph.EdgeCalls))
// r1 → unresolved (Bar → unresolved::Missing) — counts because
// source is in r1.
s.AddEdge(mkEdge("r1/b.go::Bar", "unresolved::Missing", graph.EdgeCalls))
// r2-intra (Qux → Baz) — MUST NOT appear in r1's slice.
s.AddEdge(mkEdge("r2/y.go::Qux", "r2/x.go::Baz", graph.EdgeCalls))
// r2 → r1 cross-repo (Qux → Foo) — MUST NOT appear in r1's slice
// because the source is in r2.
s.AddEdge(mkEdge("r2/y.go::Qux", "r1/a.go::Foo", graph.EdgeCalls))
gotR1 := sortEdgeKeys(s.GetRepoEdges("r1"))
wantR1 := sortEdgeKeys([]*graph.Edge{
mkEdge("r1/a.go::Foo", "r1/b.go::Bar", graph.EdgeCalls),
mkEdge("r1/a.go::Foo", "r2/x.go::Baz", graph.EdgeCalls),
mkEdge("r1/b.go::Bar", "unresolved::Missing", graph.EdgeCalls),
})
if fmt.Sprint(gotR1) != fmt.Sprint(wantR1) {
t.Fatalf("GetRepoEdges(r1) =\n %v\nwant\n %v", gotR1, wantR1)
}
gotR2 := sortEdgeKeys(s.GetRepoEdges("r2"))
wantR2 := sortEdgeKeys([]*graph.Edge{
mkEdge("r2/y.go::Qux", "r2/x.go::Baz", graph.EdgeCalls),
mkEdge("r2/y.go::Qux", "r1/a.go::Foo", graph.EdgeCalls),
})
if fmt.Sprint(gotR2) != fmt.Sprint(wantR2) {
t.Fatalf("GetRepoEdges(r2) =\n %v\nwant\n %v", gotR2, wantR2)
}
// Empty prefix MUST return nothing (use AllEdges for the global
// view). Disk backends must not fall through to a full scan.
if got := s.GetRepoEdges(""); len(got) != 0 {
t.Fatalf("GetRepoEdges(\"\") = %d edges, want 0", len(got))
}
// Unknown prefix MUST return empty (no panic, no fallthrough).
if got := s.GetRepoEdges("nope"); len(got) != 0 {
t.Fatalf("GetRepoEdges(nope) = %d edges, want 0", len(got))
}
}
func testGetNodeByQualName(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n := mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction)
n.QualName = "pkg.Foo"
s.AddNode(n)
got := s.GetNodeByQualName("pkg.Foo")
if got == nil || got.ID != "a.go::Foo" {
t.Fatalf("GetNodeByQualName(pkg.Foo) = %v, want a.go::Foo", got)
}
if s.GetNodeByQualName("missing.Qual") != nil {
t.Fatalf("GetNodeByQualName missing should be nil")
}
}
func testStats(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "y.go", graph.KindType))
s.AddEdge(mkEdge("a", "b", graph.EdgeReferences))
st := s.Stats()
if st.TotalNodes != 2 || st.TotalEdges != 1 {
t.Fatalf("Stats = %+v, want TotalNodes=2, TotalEdges=1", st)
}
if st.ByKind[string(graph.KindFunction)] != 1 || st.ByKind[string(graph.KindType)] != 1 {
t.Fatalf("Stats.ByKind = %v, want one each", st.ByKind)
}
}
func testRepoStats(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindType))
st := s.RepoStats()
if len(st) != 2 {
t.Fatalf("RepoStats has %d entries, want 2", len(st))
}
if st["r1"].TotalNodes != 1 {
t.Fatalf("RepoStats[r1].TotalNodes = %d, want 1", st["r1"].TotalNodes)
}
}
func testRepoPrefixes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindType))
got := s.RepoPrefixes()
sort.Strings(got)
want := []string{"r1", "r2"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("RepoPrefixes = %v, want %v", got, want)
}
}
func testSetEdgeProvenance(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e := mkEdge("a", "b", graph.EdgeCalls)
e.Origin = graph.OriginTextMatched
s.AddEdge(e)
bumped := s.SetEdgeProvenance(e, graph.OriginLSPResolved)
if !bumped {
t.Fatalf("SetEdgeProvenance returned false for real upgrade")
}
out := s.GetOutEdges("a")
if len(out) != 1 || out[0].Origin != graph.OriginLSPResolved {
t.Fatalf("Origin did not propagate: %+v", out)
}
}
func testReindexEdges(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
// Build a small graph with three out-edges from "a" pointing at
// three different targets, then re-bind all three to a fourth
// target in one batched call.
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "C", "x.go", graph.KindFunction))
s.AddNode(mkNode("d", "D", "x.go", graph.KindFunction))
s.AddNode(mkNode("z", "Z", "x.go", graph.KindFunction))
e1 := mkEdge("a", "b", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("a", "c", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("a", "d", graph.EdgeCalls)
e3.Line = 3
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
// Mutate each edge's To, then hand the batch over. After the
// call, all three edges must show as in-edges of z; none of the
// originals must remain.
e1.To, e2.To, e3.To = "z", "z", "z"
s.ReindexEdges([]graph.EdgeReindex{
{Edge: e1, OldTo: "b"},
{Edge: e2, OldTo: "c"},
{Edge: e3, OldTo: "d"},
})
for _, oldID := range []string{"b", "c", "d"} {
if got := len(s.GetInEdges(oldID)); got != 0 {
t.Fatalf("GetInEdges(%q) after batch reindex = %d, want 0", oldID, got)
}
}
if got := len(s.GetInEdges("z")); got != 3 {
t.Fatalf("GetInEdges(z) after batch reindex = %d, want 3", got)
}
if got := len(s.GetOutEdges("a")); got != 3 {
t.Fatalf("GetOutEdges(a) after batch reindex = %d, want 3", got)
}
// Empty batch is a no-op.
s.ReindexEdges(nil)
s.ReindexEdges([]graph.EdgeReindex{})
}
func testSetEdgeProvenanceBatch(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e1 := mkEdge("a", "b", graph.EdgeCalls)
e1.Line = 1
e1.Origin = graph.OriginTextMatched
e2 := mkEdge("a", "b", graph.EdgeCalls)
e2.Line = 2
e2.Origin = graph.OriginTextMatched
e3 := mkEdge("a", "b", graph.EdgeCalls)
e3.Line = 3
e3.Origin = graph.OriginLSPResolved // already at target tier — should be no-op
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
changed := s.SetEdgeProvenanceBatch([]graph.EdgeProvenanceUpdate{
{Edge: e1, NewOrigin: graph.OriginLSPResolved},
{Edge: e2, NewOrigin: graph.OriginLSPResolved},
{Edge: e3, NewOrigin: graph.OriginLSPResolved},
})
if changed != 2 {
t.Fatalf("SetEdgeProvenanceBatch reported %d changed, want 2 (one was already at target tier)", changed)
}
// Verify both promotions landed in the persisted edges.
out := s.GetOutEdges("a")
if len(out) != 3 {
t.Fatalf("GetOutEdges(a) = %d, want 3", len(out))
}
for _, e := range out {
if e.Origin != graph.OriginLSPResolved {
t.Fatalf("edge %s->%s Origin = %q, want lsp_resolved", e.From, e.To, e.Origin)
}
}
// Empty batch is a no-op and returns 0.
if got := s.SetEdgeProvenanceBatch(nil); got != 0 {
t.Fatalf("empty batch returned %d, want 0", got)
}
if got := s.SetEdgeProvenanceBatch([]graph.EdgeProvenanceUpdate{}); got != 0 {
t.Fatalf("empty batch returned %d, want 0", got)
}
}
func testReindexEdge(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("old", "Old", "x.go", graph.KindFunction))
s.AddNode(mkNode("new", "New", "x.go", graph.KindFunction))
e := mkEdge("a", "old", graph.EdgeCalls)
s.AddEdge(e)
e.To = "new"
s.ReindexEdge(e, "old")
if got := len(s.GetInEdges("old")); got != 0 {
t.Fatalf("InEdges(old) after reindex = %d, want 0", got)
}
in := s.GetInEdges("new")
if len(in) != 1 || in[0].From != "a" {
t.Fatalf("InEdges(new) = %+v, want one edge from a", in)
}
}
func testConcurrency(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
const workers = 8
const perWorker = 50
var wg sync.WaitGroup
for w := range workers {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := range perWorker {
id := fmt.Sprintf("w%d/n%d", w, i)
s.AddNode(mkNode(id, fmt.Sprintf("N%d", i), fmt.Sprintf("f%d.go", w), graph.KindFunction))
}
}(w)
}
wg.Wait()
if got, want := s.NodeCount(), workers*perWorker; got != want {
t.Fatalf("concurrent NodeCount = %d, want %d", got, want)
}
}
func testEdgeIdentityRevisions(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
// Just ensure the method exists and returns a non-negative int.
// The semantic invariant ("bumps on origin change") is
// implementation-defined; backends may return 0 if they don't
// track this.
if got := s.EdgeIdentityRevisions(); got < 0 {
t.Fatalf("EdgeIdentityRevisions negative: %d", got)
}
}
func testVerifyEdgeIdentities(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddEdge(mkEdge("a", "b", graph.EdgeCalls))
if err := s.VerifyEdgeIdentities(); err != nil {
t.Fatalf("VerifyEdgeIdentities on consistent store: %v", err)
}
}
func testRepoMemoryEstimate(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
// Backends may return zero (disk/remote) or a real estimate
// (in-memory). The contract is that the call succeeds and
// NodeCount matches what we inserted.
est := s.RepoMemoryEstimate("r1")
if est.NodeCount != 1 {
t.Fatalf("RepoMemoryEstimate NodeCount = %d, want 1", est.NodeCount)
}
}
func testAllRepoMemoryEstimates(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkRepoNode("r1/a.go::Foo", "Foo", "r1/a.go", "r1", graph.KindFunction))
s.AddNode(mkRepoNode("r2/x.go::Baz", "Baz", "r2/x.go", "r2", graph.KindFunction))
all := s.AllRepoMemoryEstimates()
if len(all) != 2 {
t.Fatalf("AllRepoMemoryEstimates len = %d, want 2", len(all))
}
}
func testMetaPreserved(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
n := mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction)
n.Meta = map[string]any{
"signature": "func Foo(x int) error",
"visibility": "public",
}
s.AddNode(n)
got := s.GetNode("a.go::Foo")
if got == nil {
t.Fatalf("GetNode returned nil")
return
}
if got.Meta == nil {
t.Fatalf("Meta not preserved")
}
if got.Meta["signature"] != "func Foo(x int) error" {
t.Fatalf("Meta[signature] = %v", got.Meta["signature"])
}
if got.Meta["visibility"] != "public" {
t.Fatalf("Meta[visibility] = %v", got.Meta["visibility"])
}
}
func testEdgesByKind(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "C", "y.go", graph.KindType))
e1 := mkEdge("a", "b", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("a", "b", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("a", "c", graph.EdgeReferences)
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
var calls []*graph.Edge
for e := range s.EdgesByKind(graph.EdgeCalls) {
calls = append(calls, e)
}
if len(calls) != 2 {
t.Fatalf("EdgesByKind(EdgeCalls) yielded %d, want 2", len(calls))
}
for _, e := range calls {
if e.Kind != graph.EdgeCalls {
t.Fatalf("yielded edge has wrong kind: %s", e.Kind)
}
}
var refs []*graph.Edge
for e := range s.EdgesByKind(graph.EdgeReferences) {
refs = append(refs, e)
}
if len(refs) != 1 {
t.Fatalf("EdgesByKind(EdgeReferences) yielded %d, want 1", len(refs))
}
// Unknown kind yields nothing.
count := 0
for range s.EdgesByKind(graph.EdgeKind("nonexistent")) {
count++
}
if count != 0 {
t.Fatalf("EdgesByKind(nonexistent) yielded %d, want 0", count)
}
// Early stop honours the contract.
stopped := 0
for range s.EdgesByKind(graph.EdgeCalls) {
stopped++
if stopped == 1 {
break
}
}
if stopped != 1 {
t.Fatalf("early stop yielded %d before break, want 1", stopped)
}
}
func testNodesByKind(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "C", "y.go", graph.KindType))
var fns []*graph.Node
for n := range s.NodesByKind(graph.KindFunction) {
fns = append(fns, n)
}
if len(fns) != 2 {
t.Fatalf("NodesByKind(KindFunction) yielded %d, want 2", len(fns))
}
for _, n := range fns {
if n.Kind != graph.KindFunction {
t.Fatalf("yielded node has wrong kind: %s", n.Kind)
}
}
var types []*graph.Node
for n := range s.NodesByKind(graph.KindType) {
types = append(types, n)
}
if len(types) != 1 {
t.Fatalf("NodesByKind(KindType) yielded %d, want 1", len(types))
}
// Early stop honours the contract.
stopped := 0
for range s.NodesByKind(graph.KindFunction) {
stopped++
if stopped == 1 {
break
}
}
if stopped != 1 {
t.Fatalf("early stop yielded %d before break, want 1", stopped)
}
}
func testEdgesWithUnresolvedTarget(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
e1 := mkEdge("a", "b", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("a", "unresolved::Foo", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("a", "unresolved::Bar", graph.EdgeCalls)
e3.Line = 3
e4 := mkEdge("a", "resolved", graph.EdgeCalls)
e4.Line = 4
// Multi-repo COPY rewrite form: copyBulkLocked rewrites a bare
// `unresolved::<name>` stub to `<repoPrefix>::unresolved::<name>`
// so per-repo stubs can't collide on the COPY primary key. The
// pending-edge scan MUST yield this form too, or the Go resolver
// never gets a second pass at multi-repo stubs (the whole-repo
// "every function looks dead" bug). graph.IsUnresolvedTarget is
// the canonical matcher for both encodings.
e5 := mkEdge("a", "gortex::unresolved::Baz", graph.EdgeCalls)
e5.Line = 5
// Gate-owned fn-value placeholders: a captured function value parks at
// `unresolved::fnvalue::<name>` (bare and multi-repo forms) until the
// callback gate binds it. is_unresolved is 1 for them (they live in the
// unresolved space), but the master resolver can never bind them, so the
// pending scan MUST exclude both forms — otherwise they are pure bloat on
// the set the resolver reconciles every pass.
e6 := mkEdge("a", "unresolved::fnvalue::handler", graph.EdgeReferences)
e6.Line = 6
e7 := mkEdge("a", "gortex::unresolved::fnvalue::handler", graph.EdgeReferences)
e7.Line = 7
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
s.AddEdge(e5)
s.AddEdge(e6)
s.AddEdge(e7)
var unres []*graph.Edge
for e := range s.EdgesWithUnresolvedTarget() {
unres = append(unres, e)
}
if len(unres) != 3 {
t.Fatalf("EdgesWithUnresolvedTarget yielded %d, want 3 (unresolved::Foo, unresolved::Bar, gortex::unresolved::Baz); fn-value placeholders must be excluded", len(unres))
}
gotPrefixed := false
for _, e := range unres {
if !graph.IsUnresolvedTarget(e.To) {
t.Fatalf("yielded edge has non-unresolved To: %s", e.To)
}
if graph.IsFnValuePlaceholder(e.To) {
t.Fatalf("EdgesWithUnresolvedTarget yielded a gate-owned fn-value placeholder %q: the resolver pending scan must exclude both the bare and multi-repo forms", e.To)
}
if e.To == "gortex::unresolved::Baz" {
gotPrefixed = true
}
}
if !gotPrefixed {
t.Fatalf("EdgesWithUnresolvedTarget did not yield the multi-repo prefixed stub gortex::unresolved::Baz")
}
}
// testFnValuePlaceholderEdges pins graph.FnValuePlaceholderScanner: the scan
// must return EXACTLY the fn-value gate placeholders (both the bare and the
// multi-repo COPY-rewrite forms) and nothing else — not real references, not
// non-fnvalue unresolved stubs, not resolved edges. It is the mirror image of
// testEdgesWithUnresolvedTarget's exclusion.
func testFnValuePlaceholderEdges(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
sc, ok := s.(graph.FnValuePlaceholderScanner)
if !ok {
t.Skip("backend does not implement FnValuePlaceholderScanner")
}
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddEdge(mkEdge("a", "b", graph.EdgeReferences)) // real reference — not a placeholder
s.AddEdge(mkEdge("a", "unresolved::Foo", graph.EdgeCalls)) // unresolved, but not fn-value
s.AddEdge(mkEdge("a", "resolved", graph.EdgeReferences)) // resolved
ph1 := mkEdge("a", "unresolved::fnvalue::handler", graph.EdgeReferences)
ph1.Line = 6
ph2 := mkEdge("a", "gortex::unresolved::fnvalue::handler", graph.EdgeReferences)
ph2.Line = 7
s.AddEdge(ph1)
s.AddEdge(ph2)
seen := map[string]bool{}
for e := range sc.FnValuePlaceholderEdges() {
if !graph.IsFnValuePlaceholder(e.To) {
t.Fatalf("FnValuePlaceholderEdges yielded a non-placeholder To: %s", e.To)
}
seen[e.To] = true
}
if len(seen) != 2 {
t.Fatalf("FnValuePlaceholderEdges yielded %d distinct placeholders, want 2 (bare + multi-repo forms only); got %v", len(seen), seen)
}
if !seen["unresolved::fnvalue::handler"] {
t.Fatalf("FnValuePlaceholderEdges missed the bare form; got %v", seen)
}
if !seen["gortex::unresolved::fnvalue::handler"] {
t.Fatalf("FnValuePlaceholderEdges missed the multi-repo COPY-rewrite form; got %v", seen)
}
}
// testLightEdgeScanner pins graph.LightEdgeScanner: the kind filter must scope
// to the requested kinds (empty means all), and every promoted field must
// survive the meta-less scan intact and equal the values written. (The
// backend-specific Meta-is-nil guarantee is asserted where it holds — the disk
// backend's own test — since the in-memory backend legitimately keeps Meta.)
func testLightEdgeScanner(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
sc, ok := s.(graph.LightEdgeScanner)
if !ok {
t.Skip("backend does not implement LightEdgeScanner")
}
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
call := mkEdge("a", "b", graph.EdgeCalls)
call.Line, call.Confidence, call.Origin, call.Tier, call.CrossRepo = 11, 0.75, graph.OriginLSPResolved, "lsp", true
call.Meta = map[string]any{"via": "direct", "blob_only": "x"}
ref := mkEdge("a", "b", graph.EdgeReferences)
ref.Line = 22
imp := mkEdge("a", "b", graph.EdgeImports)
imp.Line = 33
s.AddEdge(call)
s.AddEdge(ref)
s.AddEdge(imp)
// Kind filter: calls + references, imports excluded.
got := sc.AllEdgesLight(graph.EdgeCalls, graph.EdgeReferences)
kinds := map[graph.EdgeKind]int{}
var lightCall *graph.Edge
for _, e := range got {
kinds[e.Kind]++
if e.Kind == graph.EdgeCalls {
lightCall = e
}
}
if len(got) != 2 || kinds[graph.EdgeCalls] != 1 || kinds[graph.EdgeReferences] != 1 || kinds[graph.EdgeImports] != 0 {
t.Fatalf("AllEdgesLight(calls,references) kind filter wrong: got %d edges %v, want {calls:1, references:1}", len(got), kinds)
}
// Empty kinds means every edge.
if all := sc.AllEdgesLight(); len(all) != 3 {
t.Fatalf("AllEdgesLight() with no kinds = %d, want 3 (all edges)", len(all))
}
// Promoted fields survive the meta-less scan.
if lightCall == nil {
t.Fatal("AllEdgesLight did not return the call edge")
}
if lightCall.Line != 11 || lightCall.Confidence != 0.75 || lightCall.Origin != graph.OriginLSPResolved ||
lightCall.Tier != "lsp" || !lightCall.CrossRepo {
t.Fatalf("AllEdgesLight dropped/altered a promoted field: line=%d conf=%v origin=%q tier=%q crossRepo=%v",
lightCall.Line, lightCall.Confidence, lightCall.Origin, lightCall.Tier, lightCall.CrossRepo)
}
}
func testEmptyStore(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
if s.NodeCount() != 0 {
t.Fatalf("empty NodeCount = %d, want 0", s.NodeCount())
}
if s.EdgeCount() != 0 {
t.Fatalf("empty EdgeCount = %d, want 0", s.EdgeCount())
}
if len(s.AllNodes()) != 0 {
t.Fatalf("empty AllNodes nonzero")
}
if len(s.AllEdges()) != 0 {
t.Fatalf("empty AllEdges nonzero")
}
if len(s.RepoPrefixes()) != 0 {
t.Fatalf("empty RepoPrefixes nonzero")
}
}
func testGetNodesByIDs(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("a.go::Bar", "Bar", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::Baz", "Baz", "b.go", graph.KindType))
got := s.GetNodesByIDs([]string{"a.go::Foo", "b.go::Baz", "missing", "a.go::Bar", "a.go::Foo"})
if len(got) != 3 {
t.Fatalf("GetNodesByIDs len = %d, want 3 (3 present, 1 missing, 1 duplicate)", len(got))
}
if got["a.go::Foo"] == nil || got["a.go::Foo"].Name != "Foo" {
t.Fatalf("missing or wrong Foo: %v", got["a.go::Foo"])
}
if got["b.go::Baz"] == nil || got["b.go::Baz"].Kind != graph.KindType {
t.Fatalf("missing or wrong Baz: %v", got["b.go::Baz"])
}
if _, present := got["missing"]; present {
t.Fatalf("missing ID should not be in map, got %v", got["missing"])
}
// Empty / nil input is a no-op.
if got := s.GetNodesByIDs(nil); len(got) != 0 {
t.Fatalf("nil input returned %d entries", len(got))
}
if got := s.GetNodesByIDs([]string{}); len(got) != 0 {
t.Fatalf("empty input returned %d entries", len(got))
}
if got := s.GetNodesByIDs([]string{""}); len(got) != 0 {
t.Fatalf("empty-string ID returned %d entries", len(got))
}
}
func testFindNodesByNames(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("b.go::Foo", "Foo", "b.go", graph.KindFunction))
s.AddNode(mkNode("c.go::Bar", "Bar", "c.go", graph.KindFunction))
got := s.FindNodesByNames([]string{"Foo", "Missing", "Bar", "Foo"})
if len(got) != 2 {
t.Fatalf("FindNodesByNames len = %d, want 2 (2 present, 1 missing, 1 duplicate)", len(got))
}
foos := got["Foo"]
if len(foos) != 2 {
t.Fatalf("Foo matches = %d, want 2", len(foos))
}
for _, n := range foos {
if n.Name != "Foo" {
t.Fatalf("matched node has wrong Name: %s", n.Name)
}
}
bars := got["Bar"]
if len(bars) != 1 || bars[0].Name != "Bar" {
t.Fatalf("Bar matches wrong: %v", bars)
}
if _, present := got["Missing"]; present {
t.Fatalf("missing name should not be in map")
}
// Empty / nil input.
if got := s.FindNodesByNames(nil); len(got) != 0 {
t.Fatalf("nil input returned %d entries", len(got))
}
if got := s.FindNodesByNames([]string{}); len(got) != 0 {
t.Fatalf("empty input returned %d entries", len(got))
}
}
// testGetEdgesByNodeIDs covers the batched fan-in / fan-out edge
// lookups. Builds a small graph with mixed fan-in/out, calls both
// methods with a mix of present and missing ids (plus an empty
// string), and asserts the per-id slices match what GetInEdges /
// GetOutEdges would return individually.
func testGetEdgesByNodeIDs(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
// Nodes
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "C", "y.go", graph.KindFunction))
s.AddNode(mkNode("d", "D", "y.go", graph.KindFunction))
// Edges: a→b, a→c, b→c, d→c (so c has 3 in-edges, a has 2 out-edges).
s.AddEdge(mkEdge("a", "b", graph.EdgeCalls))
s.AddEdge(mkEdge("a", "c", graph.EdgeCalls))
s.AddEdge(mkEdge("b", "c", graph.EdgeCalls))
s.AddEdge(mkEdge("d", "c", graph.EdgeReferences))
// --- GetOutEdgesByNodeIDs ---
outMap := s.GetOutEdgesByNodeIDs([]string{"a", "b", "d", "missing", "a"})
// a has 2 out-edges (a→b, a→c).
if got := sortEdgeKeys(outMap["a"]); len(got) != 2 {
t.Fatalf("GetOutEdgesByNodeIDs[a] = %v, want 2 edges", got)
}
// b has 1 out-edge (b→c).
if got := outMap["b"]; len(got) != 1 || got[0].To != "c" {
t.Fatalf("GetOutEdgesByNodeIDs[b] = %v, want one edge to c", got)
}
// d has 1 out-edge (d→c).
if got := outMap["d"]; len(got) != 1 || got[0].To != "c" {
t.Fatalf("GetOutEdgesByNodeIDs[d] = %v, want one edge to c", got)
}
// missing key — range over nil is a no-op, so callers can index
// without an ok-check.
if got := outMap["missing"]; len(got) != 0 {
t.Fatalf("GetOutEdgesByNodeIDs[missing] = %v, want empty", got)
}
// --- GetInEdgesByNodeIDs ---
inMap := s.GetInEdgesByNodeIDs([]string{"a", "b", "c", "missing"})
// a has 0 in-edges.
if got := inMap["a"]; len(got) != 0 {
t.Fatalf("GetInEdgesByNodeIDs[a] = %v, want empty", got)
}
// b has 1 in-edge (a→b).
if got := inMap["b"]; len(got) != 1 || got[0].From != "a" {
t.Fatalf("GetInEdgesByNodeIDs[b] = %v, want one edge from a", got)
}
// c has 3 in-edges (a→c, b→c, d→c).
if got := inMap["c"]; len(got) != 3 {
t.Fatalf("GetInEdgesByNodeIDs[c] = %v, want 3 edges", got)
}
froms := map[string]bool{}
for _, e := range inMap["c"] {
froms[e.From] = true
}
for _, want := range []string{"a", "b", "d"} {
if !froms[want] {
t.Fatalf("GetInEdgesByNodeIDs[c] missing edge from %q", want)
}
}
if got := inMap["missing"]; len(got) != 0 {
t.Fatalf("GetInEdgesByNodeIDs[missing] = %v, want empty", got)
}
// Empty / nil / empty-string inputs are no-ops.
if got := s.GetOutEdgesByNodeIDs(nil); len(got) != 0 {
t.Fatalf("GetOutEdgesByNodeIDs(nil) returned %d entries", len(got))
}
if got := s.GetInEdgesByNodeIDs(nil); len(got) != 0 {
t.Fatalf("GetInEdgesByNodeIDs(nil) returned %d entries", len(got))
}
if got := s.GetOutEdgesByNodeIDs([]string{}); len(got) != 0 {
t.Fatalf("GetOutEdgesByNodeIDs([]) returned %d entries", len(got))
}
if got := s.GetInEdgesByNodeIDs([]string{""}); len(got) != 0 {
t.Fatalf("GetInEdgesByNodeIDs([\"\"]) returned %d entries", len(got))
}
}
// testSymbolBundleSearcher exercises the optional
// graph.SymbolBundleSearcher capability. The interface is opt-in
// (today only the disk backend implements it; the in-memory
// *Graph deliberately leaves it unimplemented so the engine's
// fallback path stays exercised) — backends without the capability
// skip the subtest cleanly.
//
// Coverage:
// - SymbolSearcher.BulkUpsertSymbolFTS + BuildSymbolIndex must be
// called first so the FTS index is populated.
// - SearchSymbolBundles returns a bundle per matched id with the
// correct in/out edges attached.
// - Empty / no-match query returns an empty bundle slice.
func testSymbolBundleSearcher(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
bs, ok := s.(graph.SymbolBundleSearcher)
if !ok {
t.Skip("backend does not implement graph.SymbolBundleSearcher")
}
ss, ok := s.(graph.SymbolSearcher)
if !ok {
t.Skip("backend implements SymbolBundleSearcher but not SymbolSearcher — cannot populate FTS")
}
// Build a small graph: A → B → C, plus an unrelated isolated D.
// FTS-searchable name tokens that should land on the same hit.
s.AddNode(mkNode("a", "AlphaWidget", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "BetaWidget", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "GammaWidget", "y.go", graph.KindFunction))
s.AddNode(mkNode("d", "Delta", "y.go", graph.KindFunction))
s.AddEdge(mkEdge("a", "b", graph.EdgeCalls))
s.AddEdge(mkEdge("b", "c", graph.EdgeCalls))
s.AddEdge(mkEdge("a", "c", graph.EdgeCalls))
// Populate the FTS sidecar — every searchable node carries its
// tokenised name as the FTS text.
items := []graph.SymbolFTSItem{
{NodeID: "a", Tokens: "alpha widget"},
{NodeID: "b", Tokens: "beta widget"},
{NodeID: "c", Tokens: "gamma widget"},
{NodeID: "d", Tokens: "delta"},
}
if err := ss.BulkUpsertSymbolFTS("", items); err != nil {
t.Fatalf("BulkUpsertSymbolFTS: %v", err)
}
if err := ss.BuildSymbolIndex(); err != nil {
t.Fatalf("BuildSymbolIndex: %v", err)
}
// Querying for "widget" should match a/b/c and not d. Each bundle
// must carry the correct in/out edges off the graph.
bundles, err := bs.SearchSymbolBundles("widget", 10)
if err != nil {
t.Fatalf("SearchSymbolBundles: %v", err)
}
if len(bundles) == 0 {
t.Fatalf("SearchSymbolBundles returned no bundles — expected matches for a/b/c")
}
gotIDs := make(map[string]graph.SymbolBundle, len(bundles))
for _, b := range bundles {
if b.Node == nil {
t.Fatalf("bundle has nil node: %+v", b)
continue
}
gotIDs[b.Node.ID] = b
}
for _, want := range []string{"a", "b", "c"} {
if _, ok := gotIDs[want]; !ok {
t.Fatalf("missing bundle for id %q; got ids=%v", want, idsOf(bundles))
}
}
if _, ok := gotIDs["d"]; ok {
t.Fatalf("unexpected bundle for id %q (no 'widget' token in its FTS row)", "d")
}
// Edge verification: per-bundle in/out edges must match the
// in-memory truth surfaced via the existing GetIn/Out edges.
for id, b := range gotIDs {
wantOut := s.GetOutEdges(id)
if !edgeSlicesMatch(wantOut, b.OutEdges) {
t.Fatalf("bundle[%s].OutEdges mismatch: want=%v got=%v", id, edgeKeys(wantOut), edgeKeys(b.OutEdges))
}
wantIn := s.GetInEdges(id)
if !edgeSlicesMatch(wantIn, b.InEdges) {
t.Fatalf("bundle[%s].InEdges mismatch: want=%v got=%v", id, edgeKeys(wantIn), edgeKeys(b.InEdges))
}
}
// Empty query is a clean no-op.
if empty, err := bs.SearchSymbolBundles("", 10); err != nil || len(empty) != 0 {
t.Fatalf("SearchSymbolBundles(\"\"): err=%v len=%d, want empty", err, len(empty))
}
// No-match query — backend MAY return nil or empty slice; both
// are valid.
if no, err := bs.SearchSymbolBundles("nomatchforanything", 10); err != nil {
t.Fatalf("SearchSymbolBundles(nomatch): err=%v", err)
} else if len(no) != 0 {
t.Fatalf("SearchSymbolBundles(nomatch) returned %d bundles, want 0", len(no))
}
}
// idsOf is a small helper for the bundle assertions above.
func idsOf(bs []graph.SymbolBundle) []string {
out := make([]string, 0, len(bs))
for _, b := range bs {
if b.Node != nil {
out = append(out, b.Node.ID)
}
}
sort.Strings(out)
return out
}
// edgeSlicesMatch reports whether two edge slices contain the same
// (from, to, kind) tuples regardless of order. Used by the bundle
// assertions to ignore back-end-imposed ordering differences.
func edgeSlicesMatch(want, got []*graph.Edge) bool {
if len(want) != len(got) {
return false
}
wantKeys := edgeKeys(want)
gotKeys := edgeKeys(got)
sort.Strings(wantKeys)
sort.Strings(gotKeys)
for i := range wantKeys {
if wantKeys[i] != gotKeys[i] {
return false
}
}
return true
}
// edgeKeys flattens a slice of edges into deterministic (from→to:kind)
// strings for ordered diffing.
func edgeKeys(es []*graph.Edge) []string {
out := make([]string, 0, len(es))
for _, e := range es {
if e == nil {
continue
}
out = append(out, fmt.Sprintf("%s->%s:%s", e.From, e.To, e.Kind))
}
return out
}
// testDeadCodeCandidator exercises the optional
// graph.DeadCodeCandidator capability. Builds a small graph with
// nodes that fall into each filter case the analyzer cares about:
//
// - zero in-edges (dead).
// - in-edges of disallowed kind only (dead).
// - in-edges of allowed kind (alive).
// - mixed kinds across the candidate set (per-row allowlist must apply).
//
// The in-memory *graph.Graph implements this; the disk backend overrides
// with a server-side query. Both must return the same candidate set.
func testDeadCodeCandidator(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
dc, ok := s.(graph.DeadCodeCandidator)
if !ok {
t.Skip("backend does not implement graph.DeadCodeCandidator")
}
// Functions: AliveFunc (called), DeadFunc (no in-edges),
// ReadOnlyFunc (only EdgeReads — disallowed for KindFunction).
s.AddNode(mkNode("AliveFunc", "AliveFunc", "a.go", graph.KindFunction))
s.AddNode(mkNode("DeadFunc", "DeadFunc", "a.go", graph.KindFunction))
s.AddNode(mkNode("ReadOnlyFunc", "ReadOnlyFunc", "a.go", graph.KindFunction))
s.AddNode(mkNode("Caller", "Caller", "a.go", graph.KindFunction))
// Types: AliveType (referenced), DeadType (no in-edges).
s.AddNode(mkNode("AliveType", "AliveType", "b.go", graph.KindType))
s.AddNode(mkNode("DeadType", "DeadType", "b.go", graph.KindType))
// Methods: AliveMethod (called), DeadMethod (no in-edges).
s.AddNode(mkNode("AliveMethod", "AliveMethod", "c.go", graph.KindMethod))
s.AddNode(mkNode("DeadMethod", "DeadMethod", "c.go", graph.KindMethod))
// Edges that exercise the per-kind allowlist.
e1 := mkEdge("Caller", "AliveFunc", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("Caller", "ReadOnlyFunc", graph.EdgeReads)
e2.Line = 2
e3 := mkEdge("Caller", "AliveMethod", graph.EdgeCalls)
e3.Line = 3
e4 := mkEdge("Caller", "AliveType", graph.EdgeReferences)
e4.Line = 4
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
// Per-kind allowlist mirrors analysis.incomingUsageKinds for the
// three kinds under test. Functions are alive on Calls/References;
// methods on Calls/Implements; types on References/Instantiates.
allowedKinds := []graph.NodeKind{
graph.KindFunction,
graph.KindMethod,
graph.KindType,
}
allowedInEdges := map[graph.NodeKind][]graph.EdgeKind{
graph.KindFunction: {graph.EdgeCalls, graph.EdgeReferences},
graph.KindMethod: {graph.EdgeCalls, graph.EdgeImplements},
graph.KindType: {graph.EdgeReferences, graph.EdgeInstantiates},
}
got := dc.DeadCodeCandidates(allowedKinds, allowedInEdges)
gotIDs := sortNodeIDs(got)
// Caller has zero in-edges of any kind, so it surfaces too — the
// analyzer's per-kind allowlist would also flag it as a candidate
// here. The backend's job is just the candidate set; post-filters
// (exported / test / entry-point) run in Go.
want := []string{"Caller", "DeadFunc", "DeadMethod", "DeadType", "ReadOnlyFunc"}
if fmt.Sprint(gotIDs) != fmt.Sprint(want) {
t.Fatalf("DeadCodeCandidates = %v\nwant %v", gotIDs, want)
}
// Empty kind list returns nothing — never the whole graph.
if got := dc.DeadCodeCandidates(nil, allowedInEdges); len(got) != 0 {
t.Fatalf("DeadCodeCandidates(nil) = %d, want 0", len(got))
}
// Empty per-kind allowlist means "any incoming edge counts as
// usage" — AliveFunc and ReadOnlyFunc (both have *some* in-edge)
// drop out; only DeadFunc + Caller remain among functions.
anyKind := map[graph.NodeKind][]graph.EdgeKind{
graph.KindFunction: nil,
}
gotAny := dc.DeadCodeCandidates([]graph.NodeKind{graph.KindFunction}, anyKind)
gotAnyIDs := sortNodeIDs(gotAny)
wantAny := []string{"Caller", "DeadFunc"}
if fmt.Sprint(gotAnyIDs) != fmt.Sprint(wantAny) {
t.Fatalf("DeadCodeCandidates(any-kind) = %v\nwant %v", gotAnyIDs, wantAny)
}
}
// testIfaceImplementsScanner exercises the optional
// graph.IfaceImplementsScanner capability. Seeds two interfaces (one
// with methods Meta, one without) plus a type that implements each;
// the row set must include only the (type, iface) tuple whose target
// has a Meta["methods"] payload — the no-meta interface drops out.
func testIfaceImplementsScanner(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scanner, ok := s.(graph.IfaceImplementsScanner)
if !ok {
t.Skip("backend does not implement graph.IfaceImplementsScanner")
}
// Interface with required methods.
ifaceA := mkNode("iface_A", "Reader", "a.go", graph.KindInterface)
ifaceA.Meta = map[string]any{"methods": []string{"Read", "Close"}}
s.AddNode(ifaceA)
// Interface with no Meta — must not appear in the row set.
ifaceB := mkNode("iface_B", "Empty", "a.go", graph.KindInterface)
s.AddNode(ifaceB)
// Implementing type for each.
s.AddNode(mkNode("type_A", "ReaderImpl", "a.go", graph.KindType))
s.AddNode(mkNode("type_B", "EmptyImpl", "a.go", graph.KindType))
s.AddEdge(mkEdge("type_A", "iface_A", graph.EdgeImplements))
s.AddEdge(mkEdge("type_B", "iface_B", graph.EdgeImplements))
rows := scanner.IfaceImplementsRows()
if len(rows) != 1 {
t.Fatalf("IfaceImplementsRows len = %d, want 1 (iface_B has no Meta)", len(rows))
}
r := rows[0]
if r.TypeID != "type_A" || r.IfaceID != "iface_A" {
t.Fatalf("row = %+v, want type_A → iface_A", r)
}
if r.IfaceMeta == nil {
t.Fatalf("IfaceMeta is nil")
}
raw, ok := r.IfaceMeta["methods"]
if !ok {
t.Fatalf("IfaceMeta missing methods key: %+v", r.IfaceMeta)
}
// Meta encoding round-trips lists differently between backends
// (in-memory keeps []string; gob-encoded comes back as []any).
// Accept either.
var methods []string
switch v := raw.(type) {
case []string:
methods = v
case []any:
for _, m := range v {
if str, ok := m.(string); ok {
methods = append(methods, str)
}
}
default:
t.Fatalf("unexpected methods type %T: %v", raw, raw)
}
sort.Strings(methods)
if fmt.Sprint(methods) != fmt.Sprint([]string{"Close", "Read"}) {
t.Fatalf("methods = %v, want [Close Read]", methods)
}
}
// testNodeDegreeAggregator exercises the optional
// graph.NodeDegreeAggregator capability. Builds a small graph with
// nodes that cover every classification branch
// graph.GraphConnectivity / graph.ClassifyZeroEdge care about:
//
// - isolated (zero edges).
// - leaf (exactly one edge in either direction).
// - usage-edge in-bound only (alive — at least one EdgeCalls in).
// - non-usage-edge in-bound only (no EdgeCalls / EdgeReferences /
// etc — counts as "likely unused").
// - usage-edge mixed with non-usage in-edges (still alive).
// - unknown id (must be elided).
func testNodeDegreeAggregator(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
dc, ok := s.(graph.NodeDegreeAggregator)
if !ok {
t.Skip("backend does not implement graph.NodeDegreeAggregator")
}
s.AddNode(mkNode("Isolated", "Isolated", "a.go", graph.KindFunction))
s.AddNode(mkNode("LeafSink", "LeafSink", "a.go", graph.KindFunction))
s.AddNode(mkNode("LeafSource", "LeafSource", "a.go", graph.KindFunction))
s.AddNode(mkNode("Alive", "Alive", "a.go", graph.KindFunction))
s.AddNode(mkNode("StructuralOnly", "StructuralOnly", "a.go", graph.KindFunction))
s.AddNode(mkNode("Mixed", "Mixed", "a.go", graph.KindFunction))
s.AddNode(mkNode("Caller", "Caller", "a.go", graph.KindFunction))
s.AddNode(mkNode("FileNode", "FileNode", "a.go", graph.KindFile))
// One incoming call into LeafSink → leaf (in_count=1, out_count=0).
e1 := mkEdge("Caller", "LeafSink", graph.EdgeCalls)
e1.Line = 1
s.AddEdge(e1)
// One outgoing reference from LeafSource → leaf (in=0, out=1).
e2 := mkEdge("LeafSource", "Caller", graph.EdgeReferences)
e2.Line = 2
s.AddEdge(e2)
// Alive: incoming call → alive (in=1 usage).
e3 := mkEdge("Caller", "Alive", graph.EdgeCalls)
e3.Line = 3
s.AddEdge(e3)
// StructuralOnly: incoming EdgeDefines (NOT a usage kind) →
// classified as "likely unused" but not isolated.
e4 := mkEdge("FileNode", "StructuralOnly", graph.EdgeDefines)
e4.Line = 4
s.AddEdge(e4)
// Mixed: incoming EdgeDefines (non-usage) + incoming EdgeCalls
// (usage). UsageInCount must reflect ONLY the usage edge.
e5 := mkEdge("FileNode", "Mixed", graph.EdgeDefines)
e5.Line = 5
s.AddEdge(e5)
e6 := mkEdge("Caller", "Mixed", graph.EdgeCalls)
e6.Line = 6
s.AddEdge(e6)
ids := []string{
"Isolated",
"LeafSink",
"LeafSource",
"Alive",
"StructuralOnly",
"Mixed",
"unknown::id",
}
usage := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
rows := dc.NodeDegreeCounts(ids, usage)
byID := make(map[string]graph.NodeDegreeRow, len(rows))
for _, r := range rows {
byID[r.NodeID] = r
}
// Unknown id MUST be elided.
if _, ok := byID["unknown::id"]; ok {
t.Fatalf("NodeDegreeCounts must elide unknown ids, got row")
}
type want struct{ in, out, usageIn int }
cases := map[string]want{
"Isolated": {0, 0, 0},
"LeafSink": {1, 0, 1},
"LeafSource": {0, 1, 0},
"Alive": {1, 0, 1},
"StructuralOnly": {1, 0, 0},
"Mixed": {2, 0, 1},
}
for id, w := range cases {
got, ok := byID[id]
if !ok {
t.Errorf("missing row for %s", id)
continue
}
if got.InCount != w.in || got.OutCount != w.out || got.UsageInCount != w.usageIn {
t.Errorf("row %s = in=%d out=%d usage=%d, want in=%d out=%d usage=%d",
id, got.InCount, got.OutCount, got.UsageInCount,
w.in, w.out, w.usageIn)
}
}
// Empty ids returns nil — never the whole graph.
if got := dc.NodeDegreeCounts(nil, usage); len(got) != 0 {
t.Fatalf("NodeDegreeCounts(nil) = %d, want 0", len(got))
}
// Empty usage kinds means UsageInCount is always 0 (totals
// still populated).
noUsage := dc.NodeDegreeCounts([]string{"Mixed"}, nil)
if len(noUsage) != 1 {
t.Fatalf("NodeDegreeCounts(Mixed, nil) = %d rows, want 1", len(noUsage))
}
if noUsage[0].InCount != 2 || noUsage[0].UsageInCount != 0 {
t.Fatalf("NodeDegreeCounts(Mixed, nil) = in=%d usage=%d, want in=2 usage=0",
noUsage[0].InCount, noUsage[0].UsageInCount)
}
}
// testNodeFanAggregator exercises the optional
// graph.NodeFanAggregator capability. Builds a small graph that
// exercises the per-direction kind filter independently:
//
// - Hub: high fan-in (Calls + References) AND high fan-out (Calls).
// - Leaf: zero fan in either direction.
// - ReadHeavy: incoming Reads only — fan-in must be 0 when the
// filter is Calls+References.
// - CallerOnly: outgoing Calls only — fan-out non-zero, fan-in 0.
// - Unknown id elided.
func testNodeFanAggregator(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
fa, ok := s.(graph.NodeFanAggregator)
if !ok {
t.Skip("backend does not implement graph.NodeFanAggregator")
}
s.AddNode(mkNode("Hub", "Hub", "a.go", graph.KindFunction))
s.AddNode(mkNode("Leaf", "Leaf", "a.go", graph.KindFunction))
s.AddNode(mkNode("ReadHeavy", "ReadHeavy", "a.go", graph.KindFunction))
s.AddNode(mkNode("CallerOnly", "CallerOnly", "a.go", graph.KindFunction))
s.AddNode(mkNode("Target1", "Target1", "a.go", graph.KindFunction))
s.AddNode(mkNode("Target2", "Target2", "a.go", graph.KindFunction))
s.AddNode(mkNode("Src1", "Src1", "a.go", graph.KindFunction))
s.AddNode(mkNode("Src2", "Src2", "a.go", graph.KindFunction))
// Hub: 2 incoming Calls + 1 incoming Reference + 2 outgoing
// Calls + 1 outgoing Reference. With fan-in=Calls+Refs and
// fan-out=Calls: fan_in=3, fan_out=2.
add := func(from, to string, kind graph.EdgeKind, line int) {
e := mkEdge(from, to, kind)
e.Line = line
s.AddEdge(e)
}
add("Src1", "Hub", graph.EdgeCalls, 1)
add("Src2", "Hub", graph.EdgeCalls, 2)
add("Src1", "Hub", graph.EdgeReferences, 3)
add("Hub", "Target1", graph.EdgeCalls, 4)
add("Hub", "Target2", graph.EdgeCalls, 5)
add("Hub", "Target1", graph.EdgeReferences, 6)
// ReadHeavy: incoming Reads only.
add("Src1", "ReadHeavy", graph.EdgeReads, 7)
add("Src2", "ReadHeavy", graph.EdgeReads, 8)
// CallerOnly: outgoing Calls only.
add("CallerOnly", "Target1", graph.EdgeCalls, 9)
ids := []string{"Hub", "Leaf", "ReadHeavy", "CallerOnly", "unknown::id"}
rows := fa.NodeFanCounts(ids,
[]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences},
[]graph.EdgeKind{graph.EdgeCalls},
)
byID := make(map[string]graph.NodeFanRow, len(rows))
for _, r := range rows {
byID[r.NodeID] = r
}
if _, ok := byID["unknown::id"]; ok {
t.Fatalf("NodeFanCounts must elide unknown ids, got row")
}
type want struct{ in, out int }
cases := map[string]want{
"Hub": {3, 2},
"Leaf": {0, 0},
"ReadHeavy": {0, 0},
"CallerOnly": {0, 1},
}
for id, w := range cases {
got, ok := byID[id]
if !ok {
t.Errorf("missing row for %s", id)
continue
}
if got.FanIn != w.in || got.FanOut != w.out {
t.Errorf("row %s = in=%d out=%d, want in=%d out=%d",
id, got.FanIn, got.FanOut, w.in, w.out)
}
}
// Empty ids returns nil.
if got := fa.NodeFanCounts(nil, []graph.EdgeKind{graph.EdgeCalls}, nil); len(got) != 0 {
t.Fatalf("NodeFanCounts(nil) = %d, want 0", len(got))
}
// Empty kind sets → all-zero rows for known ids only.
zeros := fa.NodeFanCounts([]string{"Hub", "unknown::id"}, nil, nil)
if len(zeros) != 1 {
t.Fatalf("NodeFanCounts(empty kinds) = %d rows, want 1 (Hub only)", len(zeros))
}
if zeros[0].NodeID != "Hub" || zeros[0].FanIn != 0 || zeros[0].FanOut != 0 {
t.Fatalf("NodeFanCounts(empty kinds) = %+v, want Hub/0/0", zeros[0])
}
}
// testFileImporters exercises the optional graph.FileImporters
// capability. Seeds two importing files (one production, one test)
// plus an unrelated import edge that targets a different file. The
// returned rows must include exactly the importers of the target
// file — both via the file-node ID and via the FilePath-on-symbol
// shape — and must not surface the unrelated edge.
func testFileImporters(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
fi, ok := s.(graph.FileImporters)
if !ok {
t.Skip("backend does not implement graph.FileImporters")
}
// target file node + a symbol inside it.
s.AddNode(mkNode("pkg/target.go", "target.go", "pkg/target.go", graph.KindFile))
s.AddNode(mkNode("TargetFunc", "TargetFunc", "pkg/target.go", graph.KindFunction))
// Two importing files: one production, one test. Each has an
// import edge — one targets the file node by id, the other
// targets a symbol inside the file (FilePath match path).
s.AddNode(mkNode("pkg/prod.go", "prod.go", "pkg/prod.go", graph.KindFile))
s.AddNode(mkNode("pkg/test_test.go", "test_test.go", "pkg/test_test.go", graph.KindFile))
// And an unrelated importer that points elsewhere — must NOT
// surface in the results.
s.AddNode(mkNode("pkg/other.go", "other.go", "pkg/other.go", graph.KindFile))
s.AddNode(mkNode("pkg/elsewhere.go", "elsewhere.go", "pkg/elsewhere.go", graph.KindFile))
s.AddEdge(mkEdge("pkg/prod.go", "pkg/target.go", graph.EdgeImports))
s.AddEdge(mkEdge("pkg/test_test.go", "TargetFunc", graph.EdgeImports))
s.AddEdge(mkEdge("pkg/other.go", "pkg/elsewhere.go", graph.EdgeImports))
// A non-imports edge to the target file must also drop out.
s.AddEdge(mkEdge("pkg/prod.go", "TargetFunc", graph.EdgeCalls))
rows := fi.FileImporters("pkg/target.go")
got := make([]string, 0, len(rows))
for _, r := range rows {
got = append(got, r.FromFile)
}
sort.Strings(got)
want := []string{"pkg/prod.go", "pkg/test_test.go"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("FileImporters = %v, want %v", got, want)
}
if got := fi.FileImporters(""); len(got) != 0 {
t.Fatalf("FileImporters(empty) = %d rows, want 0", len(got))
}
if got := fi.FileImporters("pkg/no_such.go"); len(got) != 0 {
t.Fatalf("FileImporters(unknown) = %d rows, want 0", len(got))
}
}
// testInEdgeCounter exercises the optional graph.InEdgeCounter
// capability. Seeds a small graph and asserts the per-To fan-in
// count matches what an AllEdges-bucketing loop would compute for
// the same edge-kind set.
func testInEdgeCounter(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ic, ok := s.(graph.InEdgeCounter)
if !ok {
t.Skip("backend does not implement graph.InEdgeCounter")
}
s.AddNode(mkNode("A", "A", "a.go", graph.KindFunction))
s.AddNode(mkNode("B", "B", "a.go", graph.KindFunction))
s.AddNode(mkNode("C", "C", "a.go", graph.KindFunction))
s.AddNode(mkNode("T", "T", "a.go", graph.KindType))
// B is called twice (from A and C), referenced once (from A).
e1 := mkEdge("A", "B", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("C", "B", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("A", "B", graph.EdgeReferences)
e3.Line = 3
// T is referenced once and held by an import edge that should
// not be counted under {calls,references}.
e4 := mkEdge("A", "T", graph.EdgeReferences)
e4.Line = 4
e5 := mkEdge("A", "T", graph.EdgeImports)
e5.Line = 5
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
s.AddEdge(e5)
got := ic.InEdgeCountsByKind([]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences})
if got["B"] != 3 {
t.Fatalf("count[B] = %d, want 3", got["B"])
}
if got["T"] != 1 {
t.Fatalf("count[T] = %d, want 1", got["T"])
}
if _, ok := got["A"]; ok {
t.Fatalf("A should have zero matching incoming edges, got %d", got["A"])
}
// Empty kind list must return nil — never the whole graph.
if got := ic.InEdgeCountsByKind(nil); got != nil {
t.Fatalf("InEdgeCountsByKind(nil) = %v, want nil", got)
}
// Single-kind filter dedups when callers pass duplicates.
got2 := ic.InEdgeCountsByKind([]graph.EdgeKind{graph.EdgeCalls, graph.EdgeCalls})
if got2["B"] != 2 {
t.Fatalf("count[B] (calls only, deduped) = %d, want 2", got2["B"])
}
}
// testNodesInFilesByKindFinder exercises the optional
// graph.NodesInFilesByKindFinder capability. Seeds a graph spanning
// three files and three kinds; the result must include only the
// requested-kind nodes whose FilePath sits in the requested file
// set.
func testNodesInFilesByKindFinder(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
fn, ok := s.(graph.NodesInFilesByKindFinder)
if !ok {
t.Skip("backend does not implement graph.NodesInFilesByKindFinder")
}
// f1.go: function + method + type.
s.AddNode(mkNode("f1::F1", "F1", "f1.go", graph.KindFunction))
s.AddNode(mkNode("f1::M1", "M1", "f1.go", graph.KindMethod))
s.AddNode(mkNode("f1::T1", "T1", "f1.go", graph.KindType))
// f2.go: function only.
s.AddNode(mkNode("f2::F2", "F2", "f2.go", graph.KindFunction))
// f3.go: drops out of every result — not in the requested files.
s.AddNode(mkNode("f3::F3", "F3", "f3.go", graph.KindFunction))
got := fn.NodesInFilesByKind(
[]string{"f1.go", "f2.go"},
[]graph.NodeKind{graph.KindFunction, graph.KindMethod},
)
gotIDs := sortNodeIDs(got)
want := []string{"f1::F1", "f1::M1", "f2::F2"}
if fmt.Sprint(gotIDs) != fmt.Sprint(want) {
t.Fatalf("NodesInFilesByKind = %v, want %v", gotIDs, want)
}
// Empty files / kinds must return nil — never a whole-graph scan.
if got := fn.NodesInFilesByKind(nil, []graph.NodeKind{graph.KindFunction}); got != nil {
t.Fatalf("NodesInFilesByKind(nil files) = %v, want nil", got)
}
if got := fn.NodesInFilesByKind([]string{"f1.go"}, nil); got != nil {
t.Fatalf("NodesInFilesByKind(nil kinds) = %v, want nil", got)
}
// Dedup: passing the same file / kind twice must not double-yield.
gotDup := fn.NodesInFilesByKind(
[]string{"f1.go", "f1.go"},
[]graph.NodeKind{graph.KindType, graph.KindType},
)
if len(gotDup) != 1 || gotDup[0].ID != "f1::T1" {
t.Fatalf("NodesInFilesByKind(dup) = %v, want [f1::T1]", sortNodeIDs(gotDup))
}
}
// testEdgesByKindsScanner exercises the optional
// graph.EdgesByKindsScanner capability. Builds a small graph with a
// mix of edge kinds, then verifies the streaming filter returns
// exactly the union of the requested kinds in any order. Covers the
// edge cases that the edge-driven analyzers rely on: zero-match (no
// edge matches the requested kinds), empty filter (yields nothing —
// never a whole-table scan), and early stop honouring the iterator
// contract.
func testEdgesByKindsScanner(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
s.AddNode(mkNode("a", "A", "x.go", graph.KindFunction))
s.AddNode(mkNode("b", "B", "x.go", graph.KindFunction))
s.AddNode(mkNode("c", "C", "y.go", graph.KindType))
s.AddNode(mkNode("d", "D", "y.go", graph.KindField))
calls1 := mkEdge("a", "b", graph.EdgeCalls)
calls1.Line = 1
calls2 := mkEdge("a", "b", graph.EdgeCalls)
calls2.Line = 2
refs := mkEdge("a", "c", graph.EdgeReferences)
writes := mkEdge("a", "d", graph.EdgeWrites)
throws := mkEdge("a", "c", graph.EdgeThrows)
s.AddEdge(calls1)
s.AddEdge(calls2)
s.AddEdge(refs)
s.AddEdge(writes)
s.AddEdge(throws)
es, ok := s.(graph.EdgesByKindsScanner)
if !ok {
t.Skip("backend does not implement graph.EdgesByKindsScanner")
}
// Multi-kind: union of Calls + References must surface all three
// calls/refs edges; counts (not pointers) compared so the in-memory
// and disk backends agree without relying on edge identity.
counts := map[graph.EdgeKind]int{}
for e := range es.EdgesByKinds([]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}) {
counts[e.Kind]++
}
if counts[graph.EdgeCalls] != 2 || counts[graph.EdgeReferences] != 1 {
t.Fatalf("EdgesByKinds(Calls,References) = %+v, want Calls:2 References:1", counts)
}
if got := len(counts); got != 2 {
t.Fatalf("EdgesByKinds(Calls,References) yielded %d distinct kinds, want 2", got)
}
// Single-kind via the multi-kind path must match EdgesByKind.
single := 0
for e := range es.EdgesByKinds([]graph.EdgeKind{graph.EdgeWrites}) {
if e.Kind != graph.EdgeWrites {
t.Fatalf("EdgesByKinds(Writes) yielded kind=%s, want Writes", e.Kind)
}
single++
}
if single != 1 {
t.Fatalf("EdgesByKinds(Writes) yielded %d, want 1", single)
}
// Dedupe: repeating a kind must not double-yield. The backend's
// IN-list MUST collapse duplicates.
dup := 0
for range es.EdgesByKinds([]graph.EdgeKind{graph.EdgeCalls, graph.EdgeCalls}) {
dup++
}
if dup != 2 {
t.Fatalf("EdgesByKinds(Calls,Calls) yielded %d, want 2 (no double-yield)", dup)
}
// Empty kinds yields nothing — never a whole-table scan.
empty := 0
for range es.EdgesByKinds(nil) {
empty++
}
if empty != 0 {
t.Fatalf("EdgesByKinds(nil) yielded %d, want 0", empty)
}
emptySlice := 0
for range es.EdgesByKinds([]graph.EdgeKind{}) {
emptySlice++
}
if emptySlice != 0 {
t.Fatalf("EdgesByKinds([]) yielded %d, want 0", emptySlice)
}
// Empty string kinds get elided (matches dedupeEdgeKinds contract).
blank := 0
for range es.EdgesByKinds([]graph.EdgeKind{"", "", ""}) {
blank++
}
if blank != 0 {
t.Fatalf("EdgesByKinds(blank) yielded %d, want 0", blank)
}
// Zero-match: a kind nothing in the graph uses yields nothing.
zero := 0
for range es.EdgesByKinds([]graph.EdgeKind{graph.EdgeKind("nonexistent")}) {
zero++
}
if zero != 0 {
t.Fatalf("EdgesByKinds(nonexistent) yielded %d, want 0", zero)
}
// Early stop honours the iterator contract.
stopped := 0
for range es.EdgesByKinds([]graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}) {
stopped++
if stopped == 1 {
break
}
}
if stopped != 1 {
t.Fatalf("early stop yielded %d before break, want 1", stopped)
}
}
// testNodesByKindsScanner exercises the optional graph.NodesByKindsScanner
// capability. Seeds nodes of several kinds, including ones whose Meta
// holds the keys the metadata analyzers read, and asserts:
// - the IN-list returns exactly the union of the requested kinds
// (with nodes' Meta intact so post-filtering still works);
// - kinds the caller did not request never surface;
// - empty / nil kinds returns nil without scanning;
// - duplicate kinds in the input never duplicate the output.
//
// The Meta-preservation assertion is the load-bearing one: every
// downstream handler still runs its meta gate in Go after the kind
// pushdown, so the capability is worthless if Meta doesn't round-trip
// through the backend.
func testNodesByKindsScanner(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.NodesByKindsScanner)
if !ok {
t.Skip("backend does not implement graph.NodesByKindsScanner")
}
// Two functions (one with coverage meta), one method, one type,
// one file (with cgo meta), one todo (with assignee meta), one
// table. Mix of meta-bearing and meta-bare nodes so the
// round-trip assertion covers both shapes. Meta values stay
// scalar — testMetaPreserved already covers flat round-trip, and
// the disk backend's gob encoder needs gob.Register for nested
// map shapes (out of scope for a kind-pushdown capability test).
fn1 := mkNode("pkg/a.go::Fn1", "Fn1", "pkg/a.go", graph.KindFunction)
fn1.Meta = map[string]any{
"coverage_pct": 42.5,
"author_email": "alice@example.com",
}
fn2 := mkNode("pkg/a.go::Fn2", "Fn2", "pkg/a.go", graph.KindFunction)
method := mkNode("pkg/a.go::T.M", "M", "pkg/a.go", graph.KindMethod)
typ := mkNode("pkg/a.go::T", "T", "pkg/a.go", graph.KindType)
file := mkNode("pkg/a.go", "a.go", "pkg/a.go", graph.KindFile)
file.Meta = map[string]any{"uses_cgo": true}
todo := mkNode("pkg/a.go::TODO:7", "TODO", "pkg/a.go", graph.KindTodo)
todo.Meta = map[string]any{
"tag": "TODO",
"assignee": "alice",
"text": "wire this up",
}
tbl := mkNode("table::users", "users", "schema/001.sql", graph.KindTable)
tbl.Meta = map[string]any{"table": "users", "dialect": "postgres"}
for _, n := range []*graph.Node{fn1, fn2, method, typ, file, todo, tbl} {
s.AddNode(n)
}
// Function + method — the stale_code/ownership/coverage default.
gotFnM := scan.NodesByKinds([]graph.NodeKind{graph.KindFunction, graph.KindMethod})
wantFnM := []string{"pkg/a.go::Fn1", "pkg/a.go::Fn2", "pkg/a.go::T.M"}
if got := sortNodeIDs(gotFnM); fmt.Sprint(got) != fmt.Sprint(wantFnM) {
t.Fatalf("NodesByKinds(function,method) = %v, want %v", got, wantFnM)
}
// Meta round-trip: pick up Fn1 and assert flat scalar meta survived.
var fn1Got *graph.Node
for _, n := range gotFnM {
if n.ID == "pkg/a.go::Fn1" {
fn1Got = n
break
}
}
if fn1Got == nil {
t.Fatalf("Fn1 missing from result")
return
}
if pct, _ := fn1Got.Meta["coverage_pct"].(float64); pct != 42.5 {
t.Fatalf("Fn1.Meta.coverage_pct = %v, want 42.5", fn1Got.Meta["coverage_pct"])
}
if email, _ := fn1Got.Meta["author_email"].(string); email != "alice@example.com" {
t.Fatalf("Fn1.Meta.author_email = %q, want alice@example.com", email)
}
// Single kind on a kind with meta — todo/file.
gotTodo := scan.NodesByKinds([]graph.NodeKind{graph.KindTodo})
if len(gotTodo) != 1 || gotTodo[0].ID != "pkg/a.go::TODO:7" {
t.Fatalf("NodesByKinds(todo) = %v, want [pkg/a.go::TODO:7]", sortNodeIDs(gotTodo))
}
if tag, _ := gotTodo[0].Meta["tag"].(string); tag != "TODO" {
t.Fatalf("Todo.Meta.tag = %q, want TODO", tag)
}
gotFile := scan.NodesByKinds([]graph.NodeKind{graph.KindFile})
if len(gotFile) != 1 || gotFile[0].ID != "pkg/a.go" {
t.Fatalf("NodesByKinds(file) = %v, want [pkg/a.go]", sortNodeIDs(gotFile))
}
if cgo, _ := gotFile[0].Meta["uses_cgo"].(bool); !cgo {
t.Fatalf("File.Meta.uses_cgo = false, want true")
}
// Table kind — for orphan/unreferenced analyzers.
gotTbl := scan.NodesByKinds([]graph.NodeKind{graph.KindTable})
if len(gotTbl) != 1 || gotTbl[0].ID != "table::users" {
t.Fatalf("NodesByKinds(table) = %v, want [table::users]", sortNodeIDs(gotTbl))
}
// Empty / nil kinds — nil result, no scan.
if got := scan.NodesByKinds(nil); got != nil {
t.Fatalf("NodesByKinds(nil) = %v, want nil", got)
}
if got := scan.NodesByKinds([]graph.NodeKind{}); got != nil {
t.Fatalf("NodesByKinds([]) = %v, want nil", got)
}
// Unknown kind — no rows, but still nil/empty, never the full table.
if got := scan.NodesByKinds([]graph.NodeKind{graph.NodeKind("no_such_kind")}); len(got) != 0 {
t.Fatalf("NodesByKinds(unknown) = %v, want 0 rows", got)
}
// Dedup: passing the same kind twice must not double-yield.
gotDup := scan.NodesByKinds([]graph.NodeKind{graph.KindFunction, graph.KindFunction})
wantDup := []string{"pkg/a.go::Fn1", "pkg/a.go::Fn2"}
if got := sortNodeIDs(gotDup); fmt.Sprint(got) != fmt.Sprint(wantDup) {
t.Fatalf("NodesByKinds(dup function) = %v, want %v", got, wantDup)
}
}
// testEdgeKindCounter exercises the optional graph.EdgeKindCounter
// capability. Seeds a graph with several kinds in different
// frequencies and asserts the per-kind tally matches what an
// AllEdges()+map[kind]++ loop would compute.
func testEdgeKindCounter(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ek, ok := s.(graph.EdgeKindCounter)
if !ok {
t.Skip("backend does not implement graph.EdgeKindCounter")
}
// Empty graph returns nil or empty — both are valid per the
// contract; callers must treat them the same.
if got := ek.EdgeKindCounts(); len(got) != 0 {
t.Fatalf("EdgeKindCounts(empty) = %v, want empty", got)
}
s.AddNode(mkNode("A", "A", "a.go", graph.KindFunction))
s.AddNode(mkNode("B", "B", "a.go", graph.KindFunction))
s.AddNode(mkNode("C", "C", "a.go", graph.KindFunction))
s.AddNode(mkNode("f1", "a.go", "a.go", graph.KindFile))
// 3 calls, 2 references, 1 imports.
e1 := mkEdge("A", "B", graph.EdgeCalls)
e2 := mkEdge("A", "C", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("B", "C", graph.EdgeCalls)
e3.Line = 3
e4 := mkEdge("A", "C", graph.EdgeReferences)
e4.Line = 4
e5 := mkEdge("B", "C", graph.EdgeReferences)
e5.Line = 5
e6 := mkEdge("A", "f1", graph.EdgeImports)
e6.Line = 6
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
s.AddEdge(e5)
s.AddEdge(e6)
got := ek.EdgeKindCounts()
if got[graph.EdgeCalls] != 3 {
t.Fatalf("EdgeKindCounts[calls] = %d, want 3", got[graph.EdgeCalls])
}
if got[graph.EdgeReferences] != 2 {
t.Fatalf("EdgeKindCounts[references] = %d, want 2", got[graph.EdgeReferences])
}
if got[graph.EdgeImports] != 1 {
t.Fatalf("EdgeKindCounts[imports] = %d, want 1", got[graph.EdgeImports])
}
// No extends edge was added; absence must produce 0 via the
// zero value (callers index with `m[k]`).
if got[graph.EdgeExtends] != 0 {
t.Fatalf("EdgeKindCounts[extends] = %d, want 0", got[graph.EdgeExtends])
}
}
// testCrossRepoEdgeAggregator exercises the optional
// graph.CrossRepoEdgeAggregator capability. Seeds a two-repo graph
// with one cross_repo_calls + one cross_repo_implements and two
// same-repo edges of other kinds. Asserts the per-triple counts and
// that single-repo edges drop out.
func testCrossRepoEdgeAggregator(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ag, ok := s.(graph.CrossRepoEdgeAggregator)
if !ok {
t.Skip("backend does not implement graph.CrossRepoEdgeAggregator")
}
// Empty graph -> nil.
if got := ag.CrossRepoEdgeCounts(); got != nil {
t.Fatalf("CrossRepoEdgeCounts(empty) = %v, want nil", got)
}
s.AddNode(mkRepoNode("repoA::Caller", "Caller", "a/c.go", "repoA", graph.KindFunction))
s.AddNode(mkRepoNode("repoA::Callee2", "Callee2", "a/d.go", "repoA", graph.KindFunction))
s.AddNode(mkRepoNode("repoB::Callee", "Callee", "b/d.go", "repoB", graph.KindFunction))
s.AddNode(mkRepoNode("repoB::Iface", "Iface", "b/i.go", "repoB", graph.KindType))
s.AddNode(mkRepoNode("repoA::Impl", "Impl", "a/i.go", "repoA", graph.KindType))
// Two cross-repo edges to the same (kind, fromRepo, toRepo) +
// one cross-repo implements + one non-cross edge.
e1 := mkEdge("repoA::Caller", "repoB::Callee", graph.EdgeCrossRepoCalls)
e2 := mkEdge("repoA::Caller", "repoB::Callee", graph.EdgeCrossRepoCalls)
e2.Line = 2
e3 := mkEdge("repoA::Impl", "repoB::Iface", graph.EdgeCrossRepoImplements)
e3.Line = 3
e4 := mkEdge("repoA::Caller", "repoA::Callee2", graph.EdgeCalls)
e4.Line = 4
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
rows := ag.CrossRepoEdgeCounts()
// Sort for stable assertions — capability output order is
// unspecified.
sort.Slice(rows, func(i, j int) bool {
if rows[i].Kind != rows[j].Kind {
return rows[i].Kind < rows[j].Kind
}
if rows[i].FromRepo != rows[j].FromRepo {
return rows[i].FromRepo < rows[j].FromRepo
}
return rows[i].ToRepo < rows[j].ToRepo
})
if len(rows) != 2 {
t.Fatalf("CrossRepoEdgeCounts: got %d rows, want 2 (rows=%v)", len(rows), rows)
}
if rows[0].Kind != graph.EdgeCrossRepoCalls || rows[0].FromRepo != "repoA" || rows[0].ToRepo != "repoB" || rows[0].Count != 2 {
t.Fatalf("CrossRepoEdgeCounts[0] = %+v, want {cross_repo_calls,repoA,repoB,2}", rows[0])
}
if rows[1].Kind != graph.EdgeCrossRepoImplements || rows[1].FromRepo != "repoA" || rows[1].ToRepo != "repoB" || rows[1].Count != 1 {
t.Fatalf("CrossRepoEdgeCounts[1] = %+v, want {cross_repo_implements,repoA,repoB,1}", rows[1])
}
}
// testFileImportAggregator exercises the optional
// graph.FileImportAggregator capability. Seeds a graph with several
// import edges and asserts the per-target-file counts. Covers both
// the unscoped and the scope-bound paths plus the file-node-by-ID
// vs symbol-FilePath import shapes.
func testFileImportAggregator(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ag, ok := s.(graph.FileImportAggregator)
if !ok {
t.Skip("backend does not implement graph.FileImportAggregator")
}
if got := ag.FileImportCounts(nil); got != nil {
t.Fatalf("FileImportCounts(empty graph) = %v, want nil", got)
}
// Two targets, three importing files, mixed shapes.
s.AddNode(mkNode("pkg/popular.go", "popular.go", "pkg/popular.go", graph.KindFile))
s.AddNode(mkNode("PopularFn", "PopularFn", "pkg/popular.go", graph.KindFunction))
s.AddNode(mkNode("pkg/lonely.go", "lonely.go", "pkg/lonely.go", graph.KindFile))
s.AddNode(mkNode("pkg/a.go", "a.go", "pkg/a.go", graph.KindFile))
s.AddNode(mkNode("pkg/b.go", "b.go", "pkg/b.go", graph.KindFile))
s.AddNode(mkNode("pkg/c.go", "c.go", "pkg/c.go", graph.KindFile))
// pkg/popular.go imported by 3 files (two via file-id, one via symbol-FilePath).
s.AddEdge(mkEdge("pkg/a.go", "pkg/popular.go", graph.EdgeImports))
s.AddEdge(mkEdge("pkg/b.go", "pkg/popular.go", graph.EdgeImports))
s.AddEdge(mkEdge("pkg/c.go", "PopularFn", graph.EdgeImports))
// pkg/lonely.go imported once.
s.AddEdge(mkEdge("pkg/a.go", "pkg/lonely.go", graph.EdgeImports))
// A calls edge — must drop out of imports counts.
s.AddEdge(mkEdge("pkg/a.go", "PopularFn", graph.EdgeCalls))
rows := ag.FileImportCounts(nil)
got := map[string]int{}
for _, r := range rows {
got[r.FilePath] = r.Count
}
if got["pkg/popular.go"] != 3 {
t.Fatalf("FileImportCounts[popular.go] = %d, want 3", got["pkg/popular.go"])
}
if got["pkg/lonely.go"] != 1 {
t.Fatalf("FileImportCounts[lonely.go] = %d, want 1", got["pkg/lonely.go"])
}
// Scope-bound: only count edges whose target is in the allow set.
scoped := ag.FileImportCounts([]string{"pkg/lonely.go"})
if len(scoped) != 1 || scoped[0].FilePath != "pkg/lonely.go" || scoped[0].Count != 1 {
t.Fatalf("FileImportCounts(scope=lonely) = %v, want [lonely.go:1]", scoped)
}
// Scope-bound with file-id + symbol shape both targeting popular.
scopedPop := ag.FileImportCounts([]string{"pkg/popular.go", "PopularFn"})
gotPop := map[string]int{}
for _, r := range scopedPop {
gotPop[r.FilePath] = r.Count
}
if gotPop["pkg/popular.go"] != 3 {
t.Fatalf("FileImportCounts(scope=popular+sym) = %v, want popular.go:3", scopedPop)
}
// Empty (non-nil) scope MUST return nil — never a whole-graph scan.
if got := ag.FileImportCounts([]string{}); got != nil {
t.Fatalf("FileImportCounts(empty scope) = %v, want nil", got)
}
}
// testInDegreeForNodes exercises the optional graph.InDegreeForNodes
// capability. Seeds a tiny graph with three targets carrying 0 / 1 / 3
// incoming edges (of mixed kinds) and asserts the counter returns the
// per-target count restricted to the caller's id set.
func testInDegreeForNodes(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ic, ok := s.(graph.InDegreeForNodes)
if !ok {
t.Skip("backend does not implement graph.InDegreeForNodes")
}
s.AddNode(mkNode("Hub", "Hub", "a.go", graph.KindFunction))
s.AddNode(mkNode("Lonely", "Lonely", "a.go", graph.KindFunction))
s.AddNode(mkNode("Isolated", "Isolated", "a.go", graph.KindFunction))
s.AddNode(mkNode("C1", "C1", "a.go", graph.KindFunction))
s.AddNode(mkNode("C2", "C2", "a.go", graph.KindFunction))
s.AddNode(mkNode("C3", "C3", "a.go", graph.KindFunction))
s.AddNode(mkNode("Outside", "Outside", "a.go", graph.KindFunction))
e1 := mkEdge("C1", "Hub", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("C2", "Hub", graph.EdgeReferences)
e2.Line = 2
e3 := mkEdge("C3", "Hub", graph.EdgeReads)
e3.Line = 3
e4 := mkEdge("C1", "Lonely", graph.EdgeCalls)
e4.Line = 4
// One incoming edge that targets Outside — must NOT surface when
// Outside is absent from the caller's id list.
e5 := mkEdge("C2", "Outside", graph.EdgeCalls)
e5.Line = 5
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
s.AddEdge(e5)
got := ic.InDegreeForNodes([]string{"Hub", "Lonely", "Isolated"})
if got["Hub"] != 3 {
t.Fatalf("InDegreeForNodes[Hub] = %d, want 3", got["Hub"])
}
if got["Lonely"] != 1 {
t.Fatalf("InDegreeForNodes[Lonely] = %d, want 1", got["Lonely"])
}
// Isolated and Outside are absent — the contract drops zero-count
// targets from the map.
if _, ok := got["Isolated"]; ok {
t.Fatalf("InDegreeForNodes[Isolated] surfaced with value %d, want absent", got["Isolated"])
}
if _, ok := got["Outside"]; ok {
t.Fatalf("InDegreeForNodes[Outside] surfaced — caller didn't ask for it")
}
// Empty ids => nil (never a whole-table scan).
if got := ic.InDegreeForNodes(nil); got != nil {
t.Fatalf("InDegreeForNodes(nil) = %v, want nil", got)
}
if got := ic.InDegreeForNodes([]string{}); got != nil {
t.Fatalf("InDegreeForNodes(empty) = %v, want nil", got)
}
// Duplicated ids dedup naturally.
dup := ic.InDegreeForNodes([]string{"Hub", "Hub", "Hub"})
if dup["Hub"] != 3 {
t.Fatalf("InDegreeForNodes(dup Hub) = %d, want 3", dup["Hub"])
}
}
// testReachableForwardByKinds exercises the optional
// graph.ReachableForwardByKinds capability. Seeds a small directed
// graph mixing allowed and disallowed edge kinds, then asserts the
// closure from the seed set is the transitive subset reachable
// through only the allowed kinds.
func testReachableForwardByKinds(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
rf, ok := s.(graph.ReachableForwardByKinds)
if !ok {
t.Skip("backend does not implement graph.ReachableForwardByKinds")
}
// Layout:
// Test -> A (calls)
// A -> B (calls)
// B -> C (references)
// C -> D (reads) <-- disallowed kind: D unreachable
// X -> Y (calls) <-- disjoint subgraph: neither in closure
for _, id := range []string{"Test", "A", "B", "C", "D", "X", "Y"} {
s.AddNode(mkNode(id, id, "a.go", graph.KindFunction))
}
e1 := mkEdge("Test", "A", graph.EdgeCalls)
e1.Line = 1
e2 := mkEdge("A", "B", graph.EdgeCalls)
e2.Line = 2
e3 := mkEdge("B", "C", graph.EdgeReferences)
e3.Line = 3
e4 := mkEdge("C", "D", graph.EdgeReads)
e4.Line = 4
e5 := mkEdge("X", "Y", graph.EdgeCalls)
e5.Line = 5
s.AddEdge(e1)
s.AddEdge(e2)
s.AddEdge(e3)
s.AddEdge(e4)
s.AddEdge(e5)
kinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
got := rf.ReachableForwardByKinds([]string{"Test"}, kinds)
want := map[string]bool{"Test": true, "A": true, "B": true, "C": true}
for id := range want {
if !got[id] {
t.Fatalf("ReachableForwardByKinds: missing %q in closure %v", id, got)
}
}
if got["D"] {
t.Fatalf("ReachableForwardByKinds: D should not be reachable (reads is disallowed)")
}
if got["X"] || got["Y"] {
t.Fatalf("ReachableForwardByKinds: disjoint subgraph leaked: %v", got)
}
// Empty seeds => nil.
if got := rf.ReachableForwardByKinds(nil, kinds); got != nil {
t.Fatalf("ReachableForwardByKinds(nil) = %v, want nil", got)
}
// Empty kinds => seed set only.
zero := rf.ReachableForwardByKinds([]string{"Test"}, nil)
if !zero["Test"] || zero["A"] {
t.Fatalf("ReachableForwardByKinds(no kinds) = %v, want {Test:true}", zero)
}
// Duplicate seeds dedup naturally.
dup := rf.ReachableForwardByKinds([]string{"Test", "Test"}, kinds)
if !dup["Test"] || !dup["A"] || !dup["B"] || !dup["C"] {
t.Fatalf("ReachableForwardByKinds(dup seeds) = %v, want full closure", dup)
}
}
// testBFS exercises the optional graph.BFSCapable capability. The same
// fixture and assertions run against every backend the factory produces,
// so the in-memory reference walk and the SQLite recursive-CTE walk are
// shadow-tested for identical hop-sets — including a cycle, a node-backed
// gate (an unresolved target), a min-depth-via-two-parents tie-break, the
// depth bound, and deterministic limit truncation.
func testBFS(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
bc, ok := s.(graph.BFSCapable)
if !ok {
t.Skip("backend does not implement graph.BFSCapable")
}
// Layout (functions):
// A -calls-> B, C
// B -calls-> D B -references-> E
// C -calls-> D
// D -calls-> A (cycle) D -calls-> unresolved::ghost (no node)
// E -references-> F
for _, id := range []string{"A", "B", "C", "D", "E", "F"} {
s.AddNode(mkNode(id, id, "a.go", graph.KindFunction))
}
addE := func(from, to string, kind graph.EdgeKind, line int) {
e := mkEdge(from, to, kind)
e.Line = line
s.AddEdge(e)
}
addE("A", "B", graph.EdgeCalls, 1)
addE("A", "C", graph.EdgeCalls, 2)
addE("B", "D", graph.EdgeCalls, 3)
addE("C", "D", graph.EdgeCalls, 4)
addE("D", "A", graph.EdgeCalls, 5) // cycle back to the seed
addE("D", "unresolved::ghost", graph.EdgeCalls, 6)
addE("B", "E", graph.EdgeReferences, 7)
addE("E", "F", graph.EdgeReferences, 8)
calls := []graph.EdgeKind{graph.EdgeCalls}
callsRefs := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
must := func(hops []graph.BFSHop, err error) []graph.BFSHop {
t.Helper()
if err != nil {
t.Fatalf("BFS error: %v", err)
}
return hops
}
assertHops := func(name string, got, want []graph.BFSHop) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: got %d hops %+v, want %d %+v", name, len(got), got, len(want), want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s: hop[%d] = %+v, want %+v (full=%+v)", name, i, got[i], want[i], got)
}
}
}
// Forward, calls only: the cycle terminates; D settles at min depth 2
// via the (parent,kind)-smallest edge (B < C); the unresolved target and
// the references edges are excluded.
assertHops("fwd/calls", must(bc.BFS([]string{"A"}, graph.DirectionForward, calls, 5, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "C", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "D", Depth: 2, ParentID: "B", EdgeKind: graph.EdgeCalls},
})
// Forward, calls + references: E reached via B (references), F via E.
assertHops("fwd/callsRefs", must(bc.BFS([]string{"A"}, graph.DirectionForward, callsRefs, 5, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "C", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "D", Depth: 2, ParentID: "B", EdgeKind: graph.EdgeCalls},
{NodeID: "E", Depth: 2, ParentID: "B", EdgeKind: graph.EdgeReferences},
{NodeID: "F", Depth: 3, ParentID: "E", EdgeKind: graph.EdgeReferences},
})
// Backward (callers) from D, calls only: A is reached at depth 2 via the
// (parent,kind)-smallest caller (B < C).
assertHops("bwd/calls", must(bc.BFS([]string{"D"}, graph.DirectionBackward, calls, 5, 0)), []graph.BFSHop{
{NodeID: "D", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "D", EdgeKind: graph.EdgeCalls},
{NodeID: "C", Depth: 1, ParentID: "D", EdgeKind: graph.EdgeCalls},
{NodeID: "A", Depth: 2, ParentID: "B", EdgeKind: graph.EdgeCalls},
})
// Depth bound cuts the walk off before D.
assertHops("fwd/depth1", must(bc.BFS([]string{"A"}, graph.DirectionForward, calls, 1, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "C", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
})
// Limit truncates deterministically after (depth, node_id) ordering.
assertHops("fwd/limit2", must(bc.BFS([]string{"A"}, graph.DirectionForward, calls, 5, 2)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
})
// Empty kinds and a zero depth bound both yield the seed only.
assertHops("fwd/noKinds", must(bc.BFS([]string{"A"}, graph.DirectionForward, nil, 5, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
})
assertHops("fwd/depth0", must(bc.BFS([]string{"A"}, graph.DirectionForward, calls, 0, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
})
// Empty seeds returns nil; duplicate seeds collapse.
if got := must(bc.BFS(nil, graph.DirectionForward, calls, 5, 0)); got != nil {
t.Fatalf("BFS(nil seeds) = %+v, want nil", got)
}
assertHops("fwd/dupSeeds", must(bc.BFS([]string{"A", "A"}, graph.DirectionForward, calls, 1, 0)), []graph.BFSHop{
{NodeID: "A", Depth: 0},
{NodeID: "B", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
{NodeID: "C", Depth: 1, ParentID: "A", EdgeKind: graph.EdgeCalls},
})
}
// testThrowerErrorSurfacer exercises the optional
// graph.ThrowerErrorSurfacer capability. Seeds throwers with mixed
// error targets and EdgeEmits→KindString attachments, asserts the
// per-thrower row dedup + path-prefix filter both fire.
func testThrowerErrorSurfacer(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
ts, ok := s.(graph.ThrowerErrorSurfacer)
if !ok {
t.Skip("backend does not implement graph.ThrowerErrorSurfacer")
}
// Throwers ThrowA (in pkg/keep/), ThrowB (in pkg/drop/). Targets
// ErrIO + ErrTimeout. ThrowA also emits two literal error_msg
// strings; one EdgeEmits goes to a non-error_msg context that
// must NOT surface in ErrorMsgs.
s.AddNode(mkNode("ThrowA", "ThrowA", "pkg/keep/a.go", graph.KindFunction))
s.AddNode(mkNode("ThrowB", "ThrowB", "pkg/drop/b.go", graph.KindFunction))
s.AddNode(mkNode("ErrIO", "ErrIO", "errors/io.go", graph.KindType))
s.AddNode(mkNode("ErrTimeout", "ErrTimeout", "errors/io.go", graph.KindType))
msgOK1 := mkNode("msg1", "open failed", "pkg/keep/a.go", graph.KindString)
msgOK1.Meta = map[string]any{"context": "error_msg"}
s.AddNode(msgOK1)
msgOK2 := mkNode("msg2", "timeout", "pkg/keep/a.go", graph.KindString)
msgOK2.Meta = map[string]any{"context": "error_msg"}
s.AddNode(msgOK2)
// Wrong context — must be filtered out.
msgWrong := mkNode("msg3", "log line", "pkg/keep/a.go", graph.KindString)
msgWrong.Meta = map[string]any{"context": "log_msg"}
s.AddNode(msgWrong)
// ThrowA throws ErrIO twice (dedup to one target) + ErrTimeout once.
e1 := mkEdge("ThrowA", "ErrIO", graph.EdgeThrows)
e1.FilePath = "pkg/keep/a.go"
e1.Line = 10
e2 := mkEdge("ThrowA", "ErrIO", graph.EdgeThrows)
e2.FilePath = "pkg/keep/a.go"
e2.Line = 12
e3 := mkEdge("ThrowA", "ErrTimeout", graph.EdgeThrows)
e3.FilePath = "pkg/keep/a.go"
e3.Line = 14
// ThrowB throws ErrIO once.
e4 := mkEdge("ThrowB", "ErrIO", graph.EdgeThrows)
e4.FilePath = "pkg/drop/b.go"
e4.Line = 4
// EdgeEmits attachments for ThrowA.
e5 := mkEdge("ThrowA", "msg1", graph.EdgeEmits)
e5.Line = 11
e6 := mkEdge("ThrowA", "msg2", graph.EdgeEmits)
e6.Line = 13
e7 := mkEdge("ThrowA", "msg3", graph.EdgeEmits)
e7.Line = 15
for _, e := range []*graph.Edge{e1, e2, e3, e4, e5, e6, e7} {
s.AddEdge(e)
}
rows := ts.ThrowerErrorSurface("")
byID := map[string]graph.ThrowerErrorRow{}
for _, r := range rows {
byID[r.ThrowerID] = r
}
a, ok := byID["ThrowA"]
if !ok {
t.Fatalf("ThrowerErrorSurface: ThrowA missing from rows %v", rows)
}
if a.Throws != 3 {
t.Fatalf("ThrowA.Throws = %d, want 3", a.Throws)
}
gotTargets := append([]string(nil), a.ErrorTargets...)
sort.Strings(gotTargets)
if fmt.Sprint(gotTargets) != fmt.Sprint([]string{"ErrIO", "ErrTimeout"}) {
t.Fatalf("ThrowA.ErrorTargets = %v, want [ErrIO ErrTimeout]", gotTargets)
}
gotMsgs := append([]string(nil), a.ErrorMsgs...)
sort.Strings(gotMsgs)
if fmt.Sprint(gotMsgs) != fmt.Sprint([]string{"open failed", "timeout"}) {
t.Fatalf("ThrowA.ErrorMsgs = %v, want [open failed timeout]", gotMsgs)
}
b, ok := byID["ThrowB"]
if !ok || b.Throws != 1 || len(b.ErrorTargets) != 1 || b.ErrorTargets[0] != "ErrIO" {
t.Fatalf("ThrowB row = %+v, want Throws=1 ErrorTargets=[ErrIO]", b)
}
if len(b.ErrorMsgs) != 0 {
t.Fatalf("ThrowB.ErrorMsgs = %v, want empty", b.ErrorMsgs)
}
// Path-prefix filter drops ThrowB (under pkg/drop/) and keeps ThrowA.
keep := ts.ThrowerErrorSurface("pkg/keep/")
if len(keep) != 1 || keep[0].ThrowerID != "ThrowA" {
t.Fatalf("ThrowerErrorSurface(pkg/keep/) = %v, want only ThrowA", keep)
}
drop := ts.ThrowerErrorSurface("pkg/missing/")
if len(drop) != 0 {
t.Fatalf("ThrowerErrorSurface(pkg/missing/) = %v, want empty", drop)
}
}
// testEdgeAdjacencyForKinds exercises the optional
// graph.EdgeAdjacencyForKinds capability. Seeds a graph mixing
// function/method/type nodes joined by Calls / References / Writes
// edges and asserts the iterator yields only (from, to) pairs whose
// edge kind is in the allowed set AND whose endpoints both fall in
// the allowed node-kind set.
func testEdgeAdjacencyForKinds(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.EdgeAdjacencyForKinds)
if !ok {
t.Skip("backend does not implement graph.EdgeAdjacencyForKinds")
}
s.AddNode(mkNode("F1", "F1", "x.go", graph.KindFunction))
s.AddNode(mkNode("F2", "F2", "x.go", graph.KindFunction))
s.AddNode(mkNode("M1", "M1", "x.go", graph.KindMethod))
s.AddNode(mkNode("T1", "T1", "y.go", graph.KindType))
s.AddNode(mkNode("V1", "V1", "y.go", graph.KindVariable))
// F1 → F2 Calls (function→function, in-set)
e1 := mkEdge("F1", "F2", graph.EdgeCalls)
e1.Line = 1
// F2 → M1 References (function→method, in-set)
e2 := mkEdge("F2", "M1", graph.EdgeReferences)
e2.Line = 2
// F1 → T1 References (function→type, NOT in-set: T1 excluded)
e3 := mkEdge("F1", "T1", graph.EdgeReferences)
e3.Line = 3
// T1 → F2 References (type→function, NOT in-set: T1 excluded)
e4 := mkEdge("T1", "F2", graph.EdgeReferences)
e4.Line = 4
// M1 → F1 Writes (method→function, edge kind excluded)
e5 := mkEdge("M1", "F1", graph.EdgeWrites)
e5.Line = 5
// F1 → V1 References (function→variable, NOT in-set: V1 excluded)
e6 := mkEdge("F1", "V1", graph.EdgeReferences)
e6.Line = 6
for _, e := range []*graph.Edge{e1, e2, e3, e4, e5, e6} {
s.AddEdge(e)
}
eKinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
nKinds := []graph.NodeKind{graph.KindFunction, graph.KindMethod}
got := make(map[[2]string]int)
for pair := range scan.EdgeAdjacencyForKinds(eKinds, nKinds) {
got[pair]++
}
want := map[[2]string]int{
{"F1", "F2"}: 1,
{"F2", "M1"}: 1,
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("EdgeAdjacencyForKinds = %v, want %v", got, want)
}
// Empty edge kinds yields nothing — never a whole-table scan.
empty := 0
for range scan.EdgeAdjacencyForKinds(nil, nKinds) {
empty++
}
if empty != 0 {
t.Fatalf("EdgeAdjacencyForKinds(nil edges) yielded %d, want 0", empty)
}
// Empty node kinds yields nothing.
for range scan.EdgeAdjacencyForKinds(eKinds, nil) {
empty++
}
if empty != 0 {
t.Fatalf("EdgeAdjacencyForKinds(nil nodes) yielded %d, want 0", empty)
}
// Zero-match: edge kind absent from graph yields nothing.
zero := 0
for range scan.EdgeAdjacencyForKinds([]graph.EdgeKind{graph.EdgeKind("nonexistent")}, nKinds) {
zero++
}
if zero != 0 {
t.Fatalf("EdgeAdjacencyForKinds(nonexistent edge) yielded %d, want 0", zero)
}
// Node-kind filter actually narrows: asking only for {Type} drops every pair.
narrowed := 0
for range scan.EdgeAdjacencyForKinds(eKinds, []graph.NodeKind{graph.KindType}) {
narrowed++
}
if narrowed != 0 {
t.Fatalf("EdgeAdjacencyForKinds(Type only) yielded %d, want 0", narrowed)
}
// Early stop honours the iterator contract.
stopped := 0
for range scan.EdgeAdjacencyForKinds(eKinds, nKinds) {
stopped++
if stopped == 1 {
break
}
}
if stopped != 1 {
t.Fatalf("early stop yielded %d before break, want 1", stopped)
}
}
// testCommunityCrossingsByKind exercises the optional
// graph.CommunityCrossingsByKind capability. Seeds a small graph
// with a known community partition and asserts per-source crossing
// counts match for: no edges, all-same-community, all-cross, mixed.
func testCommunityCrossingsByKind(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.CommunityCrossingsByKind)
if !ok {
t.Skip("backend does not implement graph.CommunityCrossingsByKind")
}
s.AddNode(mkNode("A1", "A1", "x.go", graph.KindFunction))
s.AddNode(mkNode("A2", "A2", "x.go", graph.KindFunction))
s.AddNode(mkNode("B1", "B1", "y.go", graph.KindFunction))
s.AddNode(mkNode("B2", "B2", "y.go", graph.KindFunction))
s.AddNode(mkNode("C1", "C1", "z.go", graph.KindFunction))
// A1 → A2 Calls (same community A — NOT a crossing)
e1 := mkEdge("A1", "A2", graph.EdgeCalls)
e1.Line = 1
// A1 → B1 Calls (A→B — crossing)
e2 := mkEdge("A1", "B1", graph.EdgeCalls)
e2.Line = 2
// A1 → C1 References (A→C — crossing, second from A1)
e3 := mkEdge("A1", "C1", graph.EdgeReferences)
e3.Line = 3
// B1 → B2 References (same community B — NOT a crossing)
e4 := mkEdge("B1", "B2", graph.EdgeReferences)
e4.Line = 4
// B2 → C1 Calls (B→C — crossing)
e5 := mkEdge("B2", "C1", graph.EdgeCalls)
e5.Line = 5
// A2 → B2 Writes (different community but edge kind excluded)
e6 := mkEdge("A2", "B2", graph.EdgeWrites)
e6.Line = 6
for _, e := range []*graph.Edge{e1, e2, e3, e4, e5, e6} {
s.AddEdge(e)
}
communities := map[string]string{
"A1": "A", "A2": "A",
"B1": "B", "B2": "B",
"C1": "C",
}
kinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeReferences}
got := scan.CommunityCrossingsByKind(kinds, communities)
want := map[string]int{
"A1": 2, // → B1 + → C1
"B2": 1, // → C1
}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("CommunityCrossingsByKind(mixed) = %v, want %v", got, want)
}
// All-same-community partition: no crossings at all.
same := map[string]string{
"A1": "A", "A2": "A", "B1": "A", "B2": "A", "C1": "A",
}
if r := scan.CommunityCrossingsByKind(kinds, same); len(r) != 0 {
t.Fatalf("CommunityCrossingsByKind(all-same) = %v, want empty", r)
}
// All-cross-community partition: every edge in scope is a crossing.
allCross := map[string]string{
"A1": "1", "A2": "2", "B1": "3", "B2": "4", "C1": "5",
}
allGot := scan.CommunityCrossingsByKind(kinds, allCross)
allWant := map[string]int{
"A1": 3, // A1 has 3 in-scope out-edges
"B1": 1, // B1 → B2 (now also a crossing)
"B2": 1, // B2 → C1
}
if fmt.Sprint(allGot) != fmt.Sprint(allWant) {
t.Fatalf("CommunityCrossingsByKind(all-cross) = %v, want %v", allGot, allWant)
}
// Empty kinds returns nil — never a whole-table scan.
if r := scan.CommunityCrossingsByKind(nil, communities); r != nil {
t.Fatalf("CommunityCrossingsByKind(nil kinds) = %v, want nil", r)
}
// Empty community map returns nil.
if r := scan.CommunityCrossingsByKind(kinds, nil); r != nil {
t.Fatalf("CommunityCrossingsByKind(nil comm) = %v, want nil", r)
}
// Kind absent from graph yields nil.
if r := scan.CommunityCrossingsByKind([]graph.EdgeKind{graph.EdgeKind("nonexistent")}, communities); r != nil {
t.Fatalf("CommunityCrossingsByKind(nonexistent) = %v, want nil", r)
}
}
// testNodeIDsByKinds exercises the optional graph.NodeIDsByKinds
// capability. Seeds nodes of several kinds and asserts the
// projection returns just the IDs of the requested kinds, with
// duplicates collapsed and empty input returning nil.
func testNodeIDsByKinds(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.NodeIDsByKinds)
if !ok {
t.Skip("backend does not implement graph.NodeIDsByKinds")
}
s.AddNode(mkNode("F1", "F1", "x.go", graph.KindFunction))
s.AddNode(mkNode("F2", "F2", "x.go", graph.KindFunction))
s.AddNode(mkNode("M1", "M1", "x.go", graph.KindMethod))
s.AddNode(mkNode("T1", "T1", "y.go", graph.KindType))
s.AddNode(mkNode("V1", "V1", "y.go", graph.KindVariable))
got := scan.NodeIDsByKinds([]graph.NodeKind{graph.KindFunction, graph.KindMethod})
sort.Strings(got)
want := []string{"F1", "F2", "M1"}
if fmt.Sprint(got) != fmt.Sprint(want) {
t.Fatalf("NodeIDsByKinds(Function,Method) = %v, want %v", got, want)
}
// Empty kinds returns nil.
if r := scan.NodeIDsByKinds(nil); r != nil {
t.Fatalf("NodeIDsByKinds(nil) = %v, want nil", r)
}
if r := scan.NodeIDsByKinds([]graph.NodeKind{}); r != nil {
t.Fatalf("NodeIDsByKinds(empty) = %v, want nil", r)
}
// Blank kinds are elided.
if r := scan.NodeIDsByKinds([]graph.NodeKind{"", ""}); r != nil {
t.Fatalf("NodeIDsByKinds(blank) = %v, want nil", r)
}
// Duplicates collapse — the IN-list must dedupe.
dup := scan.NodeIDsByKinds([]graph.NodeKind{graph.KindFunction, graph.KindFunction})
sort.Strings(dup)
wantDup := []string{"F1", "F2"}
if fmt.Sprint(dup) != fmt.Sprint(wantDup) {
t.Fatalf("NodeIDsByKinds(Function,Function) = %v, want %v", dup, wantDup)
}
// Kinds absent from the graph yield an empty slice (or nil).
miss := scan.NodeIDsByKinds([]graph.NodeKind{graph.KindInterface})
if len(miss) != 0 {
t.Fatalf("NodeIDsByKinds(Interface) = %v, want empty", miss)
}
}
// testMemberMethodsByType exercises the optional
// graph.MemberMethodsByType capability. Seeds a graph with multiple
// types, their methods, and a non-method EdgeMemberOf edge to verify
// the kind gate.
func testMemberMethodsByType(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.MemberMethodsByType)
if !ok {
t.Skip("backend does not implement graph.MemberMethodsByType")
}
// Two types with method members + a noise field.
s.AddNode(mkNode("T1", "T1", "a.go", graph.KindType))
s.AddNode(mkNode("T2", "T2", "b.go", graph.KindType))
s.AddNode(mkNode("M1", "Foo", "a.go", graph.KindMethod))
s.AddNode(mkNode("M2", "Bar", "a.go", graph.KindMethod))
s.AddNode(mkNode("M3", "Foo", "b.go", graph.KindMethod))
s.AddNode(mkNode("F1", "Field1", "a.go", graph.KindField))
s.AddEdge(mkEdge("M1", "T1", graph.EdgeMemberOf))
s.AddEdge(mkEdge("M2", "T1", graph.EdgeMemberOf))
s.AddEdge(mkEdge("M3", "T2", graph.EdgeMemberOf))
// Non-method source — must NOT appear.
s.AddEdge(mkEdge("F1", "T1", graph.EdgeMemberOf))
got := scan.MemberMethodsByType()
t1Names := map[string]bool{}
for _, m := range got["T1"] {
t1Names[m.Name] = true
}
if !t1Names["Foo"] || !t1Names["Bar"] {
t.Fatalf("MemberMethodsByType T1 = %v, want {Foo, Bar}", got["T1"])
}
if len(got["T1"]) != 2 {
t.Fatalf("MemberMethodsByType T1 size = %d, want 2", len(got["T1"]))
}
t2Names := map[string]bool{}
for _, m := range got["T2"] {
t2Names[m.Name] = true
}
if !t2Names["Foo"] || len(got["T2"]) != 1 {
t.Fatalf("MemberMethodsByType T2 = %v, want {Foo}", got["T2"])
}
// Verify FilePath / StartLine columns are projected.
for _, m := range got["T1"] {
if m.MethodID == "" || m.FilePath == "" {
t.Fatalf("MemberMethodsByType T1 row missing columns: %+v", m)
}
}
// Empty store returns nil.
empty := factory(t)
if r := empty.(graph.MemberMethodsByType).MemberMethodsByType(); r != nil {
t.Fatalf("MemberMethodsByType(empty) = %v, want nil", r)
}
}
// testStructuralParentEdges exercises the optional
// graph.StructuralParentEdges capability. Seeds a mix of extends /
// implements / composes edges with varying endpoint kinds.
func testStructuralParentEdges(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.StructuralParentEdges)
if !ok {
t.Skip("backend does not implement graph.StructuralParentEdges")
}
// Types / interfaces (in-set endpoints).
s.AddNode(mkNode("C1", "Child", "a.go", graph.KindType))
s.AddNode(mkNode("P1", "Parent", "a.go", graph.KindType))
s.AddNode(mkNode("I1", "Iface", "a.go", graph.KindInterface))
// A method (NOT in-set).
s.AddNode(mkNode("M1", "Foo", "a.go", graph.KindMethod))
// In-set: type → type extends.
e1 := mkEdge("C1", "P1", graph.EdgeExtends)
e1.Line = 1
e1.Origin = graph.OriginASTResolved
// In-set: type → interface implements.
e2 := mkEdge("C1", "I1", graph.EdgeImplements)
e2.Line = 2
e2.Origin = graph.OriginASTInferred
// In-set: type → type composes.
e3 := mkEdge("C1", "P1", graph.EdgeComposes)
e3.Line = 3
// OUT: extends with a method on one side.
e4 := mkEdge("M1", "P1", graph.EdgeExtends)
e4.Line = 4
// OUT: irrelevant kind.
e5 := mkEdge("C1", "P1", graph.EdgeCalls)
e5.Line = 5
for _, e := range []*graph.Edge{e1, e2, e3, e4, e5} {
s.AddEdge(e)
}
rows := scan.StructuralParentEdges()
if len(rows) != 3 {
t.Fatalf("StructuralParentEdges len = %d, want 3 (rows=%v)", len(rows), rows)
}
// Verify origin propagation on the ast_resolved row.
var sawResolved, sawInferred bool
for _, r := range rows {
if r.FromID != "C1" {
t.Fatalf("unexpected FromID %q in row %v", r.FromID, r)
}
if r.FromKind != graph.KindType {
t.Fatalf("unexpected FromKind %q in row %v", r.FromKind, r)
}
if r.Origin == graph.OriginASTResolved {
sawResolved = true
}
if r.Origin == graph.OriginASTInferred {
sawInferred = true
}
}
if !sawResolved || !sawInferred {
t.Fatalf("origin not propagated: resolved=%v inferred=%v", sawResolved, sawInferred)
}
// Empty graph returns nil/empty.
empty := factory(t)
if r := empty.(graph.StructuralParentEdges).StructuralParentEdges(); len(r) != 0 {
t.Fatalf("StructuralParentEdges(empty) = %v, want empty", r)
}
}
// testCrossRepoCandidates exercises the optional
// graph.CrossRepoCandidates capability. Seeds same-repo and
// cross-repo edges and asserts only the distinct, non-empty
// repo-prefix pairs survive.
func testCrossRepoCandidates(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.CrossRepoCandidates)
if !ok {
t.Skip("backend does not implement graph.CrossRepoCandidates")
}
// Repo A.
s.AddNode(mkRepoNode("A1", "fnA1", "a.go", "repoA", graph.KindFunction))
s.AddNode(mkRepoNode("A2", "fnA2", "a.go", "repoA", graph.KindFunction))
// Repo B.
s.AddNode(mkRepoNode("B1", "fnB1", "b.go", "repoB", graph.KindFunction))
// No repo.
s.AddNode(mkNode("X1", "fnX1", "x.go", graph.KindFunction))
// Same-repo calls — must NOT appear.
e1 := mkEdge("A1", "A2", graph.EdgeCalls)
e1.Line = 1
// Cross-repo call — in.
e2 := mkEdge("A1", "B1", graph.EdgeCalls)
e2.Line = 2
// Cross-repo implements — in.
e3 := mkEdge("A1", "B1", graph.EdgeImplements)
e3.Line = 3
// Cross-repo edge but kind not in baseKinds — out.
e4 := mkEdge("A1", "B1", graph.EdgeReferences)
e4.Line = 4
// Either endpoint missing repo — out.
e5 := mkEdge("A1", "X1", graph.EdgeCalls)
e5.Line = 5
for _, e := range []*graph.Edge{e1, e2, e3, e4, e5} {
s.AddEdge(e)
}
kinds := []graph.EdgeKind{graph.EdgeCalls, graph.EdgeImplements, graph.EdgeExtends}
rows := scan.CrossRepoCandidates(kinds)
if len(rows) != 2 {
t.Fatalf("CrossRepoCandidates len = %d, want 2 (rows=%v)", len(rows), rows)
}
for _, r := range rows {
if r.FromRepo != "repoA" || r.ToRepo != "repoB" {
t.Fatalf("unexpected repos in row %v", r)
}
if r.Edge == nil || r.Edge.From != "A1" || r.Edge.To != "B1" {
t.Fatalf("unexpected edge in row %v", r)
}
}
// Empty kinds returns nil — never a whole-table scan.
if r := scan.CrossRepoCandidates(nil); r != nil {
t.Fatalf("CrossRepoCandidates(nil) = %v, want nil", r)
}
}
// testExtractCandidates exercises the optional
// graph.ExtractCandidatesScanner capability. Builds a graph with
// three functions:
// - Long+Hot: long body, 3 distinct callers, 6 distinct callees
// (passes every threshold).
// - Long+Cold: long body, 1 caller, 6 callees (fails minCallers).
// - Short+Hot: short body, 3 callers, 6 callees (fails minLines).
func testExtractCandidates(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.ExtractCandidatesScanner)
if !ok {
t.Skip("backend does not implement graph.ExtractCandidatesScanner")
}
mk := func(id string, kind graph.NodeKind, start, end int) *graph.Node {
n := mkNode(id, id, "p/a.go", kind)
n.StartLine = start
n.EndLine = end
return n
}
s.AddNode(mk("LongHot", graph.KindFunction, 1, 60))
s.AddNode(mk("LongCold", graph.KindFunction, 100, 160))
s.AddNode(mk("ShortHot", graph.KindFunction, 200, 205))
// Callers + callees as plain function nodes.
for i := 0; i < 6; i++ {
c := mkNode(fmt.Sprintf("C%d", i), fmt.Sprintf("C%d", i), "p/c.go", graph.KindFunction)
s.AddNode(c)
t := mkNode(fmt.Sprintf("T%d", i), fmt.Sprintf("T%d", i), "p/t.go", graph.KindFunction)
s.AddNode(t)
}
// LongHot: 3 distinct callers, 6 distinct callees.
for i := 0; i < 3; i++ {
e := mkEdge(fmt.Sprintf("C%d", i), "LongHot", graph.EdgeCalls)
e.Line = i + 1
s.AddEdge(e)
}
for i := 0; i < 6; i++ {
e := mkEdge("LongHot", fmt.Sprintf("T%d", i), graph.EdgeCalls)
e.Line = 100 + i
s.AddEdge(e)
}
// LongCold: 1 caller, 6 callees.
e := mkEdge("C0", "LongCold", graph.EdgeCalls)
e.Line = 200
s.AddEdge(e)
for i := 0; i < 6; i++ {
e := mkEdge("LongCold", fmt.Sprintf("T%d", i), graph.EdgeCalls)
e.Line = 300 + i
s.AddEdge(e)
}
// ShortHot: 3 callers, 6 callees but too short.
for i := 0; i < 3; i++ {
e := mkEdge(fmt.Sprintf("C%d", i), "ShortHot", graph.EdgeCalls)
e.Line = 400 + i
s.AddEdge(e)
}
for i := 0; i < 6; i++ {
e := mkEdge("ShortHot", fmt.Sprintf("T%d", i), graph.EdgeCalls)
e.Line = 500 + i
s.AddEdge(e)
}
rows := scan.ExtractCandidates(
[]graph.EdgeKind{graph.EdgeCalls},
20, // minLines
2, // minCallers
5, // minFanOut
"", // no prefix
)
byID := make(map[string]graph.ExtractCandidateRow)
for _, r := range rows {
byID[r.NodeID] = r
}
r, ok := byID["LongHot"]
if !ok {
t.Fatalf("expected LongHot in result, got %v", rows)
}
if r.CallerCount != 3 || r.FanOut != 6 || r.LineCount != 60 {
t.Fatalf("LongHot row mismatch: %+v", r)
}
if _, present := byID["LongCold"]; present {
t.Fatalf("LongCold should have been filtered (caller count < 2)")
}
if _, present := byID["ShortHot"]; present {
t.Fatalf("ShortHot should have been filtered (lines < 20)")
}
// Path prefix narrows to only LongHot (it's the one in p/a.go;
// LongCold and ShortHot also are in p/a.go so use a prefix that
// doesn't match).
none := scan.ExtractCandidates(
[]graph.EdgeKind{graph.EdgeCalls}, 20, 2, 5, "no/such/",
)
if len(none) != 0 {
t.Fatalf("ExtractCandidates with non-matching prefix = %d, want 0", len(none))
}
// Empty kinds returns nil.
if r := scan.ExtractCandidates(nil, 0, 0, 0, ""); r != nil {
t.Fatalf("ExtractCandidates(nil kinds) = %v, want nil", r)
}
}
// testFileSymbolNamesByPaths exercises the optional
// graph.FileSymbolNamesByPaths capability.
func testFileSymbolNamesByPaths(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.FileSymbolNamesByPaths)
if !ok {
t.Skip("backend does not implement graph.FileSymbolNamesByPaths")
}
s.AddNode(mkNode("Alpha", "Alpha", "a.go", graph.KindFunction))
s.AddNode(mkNode("Beta", "Beta", "a.go", graph.KindType))
s.AddNode(mkNode("Gamma", "Gamma", "a.go", graph.KindMethod))
s.AddNode(mkNode("LowCardField", "LowCardField", "a.go", graph.KindField))
s.AddNode(mkNode("Delta", "Delta", "b.go", graph.KindFunction))
rows := scan.FileSymbolNamesByPaths(
[]string{"a.go", "b.go"},
[]graph.NodeKind{graph.KindFunction, graph.KindMethod, graph.KindType, graph.KindInterface},
)
byFile := make(map[string]map[string]struct{})
for _, r := range rows {
seen := byFile[r.FilePath]
if seen == nil {
seen = make(map[string]struct{})
byFile[r.FilePath] = seen
}
seen[r.Name] = struct{}{}
}
want := map[string]map[string]struct{}{
"a.go": {"Alpha": {}, "Beta": {}, "Gamma": {}},
"b.go": {"Delta": {}},
}
for file, names := range want {
got := byFile[file]
if len(got) != len(names) {
t.Fatalf("file %q: got %v, want %v", file, got, names)
}
for n := range names {
if _, ok := got[n]; !ok {
t.Errorf("file %q: missing name %q (got %v)", file, n, got)
}
}
}
// LowCardField (KindField) must not appear because it's not in
// the requested kinds.
if _, ok := byFile["a.go"]["LowCardField"]; ok {
t.Fatalf("kind filter leaked KindField row")
}
// Empty paths returns nil.
if r := scan.FileSymbolNamesByPaths(nil, nil); r != nil {
t.Fatalf("FileSymbolNamesByPaths(nil) = %v, want nil", r)
}
}
// testClassHierarchyTraverser exercises the optional
// graph.ClassHierarchyTraverser capability.
func testClassHierarchyTraverser(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.ClassHierarchyTraverser)
if !ok {
t.Skip("backend does not implement graph.ClassHierarchyTraverser")
}
s.AddNode(mkNode("Animal", "Animal", "z.go", graph.KindInterface))
s.AddNode(mkNode("Dog", "Dog", "z.go", graph.KindType))
s.AddNode(mkNode("Puppy", "Puppy", "z.go", graph.KindType))
// Dog implements Animal; Puppy extends Dog.
e1 := mkEdge("Dog", "Animal", graph.EdgeImplements)
e1.Line = 1
s.AddEdge(e1)
e2 := mkEdge("Puppy", "Dog", graph.EdgeExtends)
e2.Line = 2
s.AddEdge(e2)
upRows := scan.ClassHierarchyTraverse(
"Puppy", "up",
[]graph.EdgeKind{graph.EdgeExtends, graph.EdgeImplements, graph.EdgeComposes},
5,
)
if len(upRows) != 2 {
t.Fatalf("Puppy up: %d rows, want 2 (Dog, Animal). rows=%v", len(upRows), upRows)
}
visited := map[string]bool{}
for _, r := range upRows {
for _, id := range r.Path {
visited[id] = true
}
}
if !visited["Dog"] || !visited["Animal"] {
t.Fatalf("Puppy up: missing Dog or Animal in visited set: %v", visited)
}
downRows := scan.ClassHierarchyTraverse(
"Animal", "down",
[]graph.EdgeKind{graph.EdgeExtends, graph.EdgeImplements, graph.EdgeComposes},
5,
)
visited = map[string]bool{}
for _, r := range downRows {
for _, id := range r.Path {
visited[id] = true
}
}
if !visited["Dog"] || !visited["Puppy"] {
t.Fatalf("Animal down: missing Dog or Puppy in visited set: %v", visited)
}
// Empty kinds / depth=0 / unknown seed must return nil.
if r := scan.ClassHierarchyTraverse("Puppy", "up", nil, 5); r != nil {
t.Fatalf("nil kinds: got %v", r)
}
if r := scan.ClassHierarchyTraverse("Puppy", "up",
[]graph.EdgeKind{graph.EdgeExtends}, 0); r != nil {
t.Fatalf("depth=0: got %v", r)
}
if r := scan.ClassHierarchyTraverse("nope", "up",
[]graph.EdgeKind{graph.EdgeExtends}, 5); r != nil {
t.Fatalf("unknown seed: got %v", r)
}
}
// testFileEditingContext exercises the optional
// graph.FileEditingContext capability.
func testFileEditingContext(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.FileEditingContext)
if !ok {
t.Skip("backend does not implement graph.FileEditingContext")
}
// File node + two functions inside it; an importing file with one
// function that calls into the file; a downstream file with a
// function the file's function calls.
s.AddNode(mkNode("a.go", "a.go", "a.go", graph.KindFile))
s.AddNode(mkNode("a.go::Foo", "Foo", "a.go", graph.KindFunction))
s.AddNode(mkNode("a.go::Bar", "Bar", "a.go", graph.KindMethod))
s.AddNode(mkNode("b.go", "b.go", "b.go", graph.KindFile))
s.AddNode(mkNode("b.go::Caller", "Caller", "b.go", graph.KindFunction))
s.AddNode(mkNode("c.go::Callee", "Callee", "c.go", graph.KindFunction))
// Import edge: a.go imports b.go.
e := mkEdge("a.go", "b.go", graph.EdgeImports)
e.Line = 1
s.AddEdge(e)
// Caller in b.go calls Foo in a.go.
e = mkEdge("b.go::Caller", "a.go::Foo", graph.EdgeCalls)
e.Line = 2
s.AddEdge(e)
// Foo in a.go calls Callee in c.go.
e = mkEdge("a.go::Foo", "c.go::Callee", graph.EdgeCalls)
e.Line = 3
s.AddEdge(e)
res := scan.FileEditingContext("a.go", []graph.NodeKind{graph.KindFunction, graph.KindMethod})
if res == nil {
t.Fatalf("FileEditingContext returned nil for a.go")
return
}
if res.FileNode == nil || res.FileNode.ID != "a.go" {
t.Fatalf("FileNode missing or wrong: %+v", res.FileNode)
}
defineIDs := map[string]bool{}
for _, n := range res.Defines {
defineIDs[n.ID] = true
}
if !defineIDs["a.go::Foo"] || !defineIDs["a.go::Bar"] {
t.Fatalf("defines missing entries: got %v", defineIDs)
}
if len(res.Imports) != 1 || res.Imports[0].To != "b.go" {
t.Fatalf("imports = %v, want one edge a.go→b.go", res.Imports)
}
calledBy := map[string]bool{}
for _, n := range res.CalledBy {
calledBy[n.ID] = true
}
if !calledBy["b.go::Caller"] {
t.Fatalf("called_by missing Caller: %v", calledBy)
}
calls := map[string]bool{}
for _, n := range res.Calls {
calls[n.ID] = true
}
if !calls["c.go::Callee"] {
t.Fatalf("calls missing Callee: %v", calls)
}
// Empty path returns nil.
if r := scan.FileEditingContext("", nil); r != nil {
t.Fatalf("empty path: got %v, want nil", r)
}
}
// testNodeDegreeByKinds exercises the optional
// graph.NodeDegreeByKinds capability.
func testNodeDegreeByKinds(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
scan, ok := s.(graph.NodeDegreeByKinds)
if !ok {
t.Skip("backend does not implement graph.NodeDegreeByKinds")
}
s.AddNode(mkNode("Iso", "Iso", "pkg/iso.go", graph.KindFunction))
s.AddNode(mkNode("Hub", "Hub", "pkg/hub.go", graph.KindFunction))
s.AddNode(mkNode("Leaf", "Leaf", "pkg/leaf.go", graph.KindMethod))
s.AddNode(mkNode("Other", "Other", "pkg/other.go", graph.KindType))
s.AddNode(mkNode("Caller", "Caller", "pkg/caller.go", graph.KindFunction))
// 2 incoming + 1 outgoing on Hub.
for i, from := range []string{"Caller", "Leaf"} {
e := mkEdge(from, "Hub", graph.EdgeCalls)
e.Line = i + 1
s.AddEdge(e)
}
e := mkEdge("Hub", "Leaf", graph.EdgeCalls)
e.Line = 3
s.AddEdge(e)
rows := scan.NodeDegreeByKinds(
[]graph.NodeKind{graph.KindFunction, graph.KindMethod},
"",
)
byID := make(map[string]graph.NodeDegreeRow)
for _, r := range rows {
byID[r.NodeID] = r
}
if got := byID["Hub"]; got.InCount != 2 || got.OutCount != 1 {
t.Fatalf("Hub: %+v, want in=2 out=1", got)
}
if got, ok := byID["Iso"]; !ok || got.InCount != 0 || got.OutCount != 0 {
t.Fatalf("Iso: ok=%v got=%+v, want in=0 out=0", ok, got)
}
if _, ok := byID["Other"]; ok {
t.Fatalf("Other (KindType) leaked into kind-filtered result")
}
// Empty kinds returns nil.
if r := scan.NodeDegreeByKinds(nil, ""); r != nil {
t.Fatalf("NodeDegreeByKinds(nil) = %v, want nil", r)
}
// Path prefix narrows.
rows = scan.NodeDegreeByKinds(
[]graph.NodeKind{graph.KindFunction, graph.KindMethod},
"pkg/leaf",
)
if len(rows) != 1 || rows[0].NodeID != "Leaf" {
t.Fatalf("pathPrefix scope mismatch: got %v", rows)
}
}
// eqShingles reports whether two []uint64 are element-for-element
// equal with order preserved — the exact contract LoadCloneShingles
// must round-trip.
func eqShingles(a, b []uint64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// testCloneShingleSidecar mirrors the FileMtime sidecar conformance:
// set shingle sets for a few node ids under a repo prefix, Load them
// back (asserting exact []uint64 equality with order preserved),
// Delete a subset and re-Load (asserting the gone rows are gone and
// the survivors untouched), verify repo-prefix scoping isolates rows,
// and that an empty/absent load returns an empty (non-nil) map, not an
// error. Backends that don't implement the capability skip — both the
// in-memory Graph and the SQLite Store do implement it.
func testCloneShingleSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.CloneShingleWriter)
if !ok {
t.Skip("backend does not implement graph.CloneShingleWriter")
}
r, ok := s.(graph.CloneShingleReader)
if !ok {
t.Skip("backend implements CloneShingleWriter but not CloneShingleReader")
}
// Empty / absent load returns an empty (non-nil) map, not an error.
if got, err := r.LoadCloneShingles("repoA"); err != nil {
t.Fatalf("LoadCloneShingles(empty store): %v", err)
} else if got == nil {
t.Fatalf("LoadCloneShingles(empty store) = nil, want empty non-nil map")
} else if len(got) != 0 {
t.Fatalf("LoadCloneShingles(empty store) = %v, want empty", got)
}
// Empty input is a no-op.
if err := w.BulkSetCloneShingles("repoA", nil); err != nil {
t.Fatalf("BulkSetCloneShingles(nil): %v", err)
}
// Write three shingle sets under repoA. Order within each set must
// survive the round-trip, so use non-sorted, repeated-value slices.
want := map[string][]uint64{
"a.go::Foo": {9, 1, 9, 4, 2},
"a.go::Bar": {7},
"b.go::Baz": {0xFFFFFFFFFFFFFFFF, 0, 42},
}
if err := w.BulkSetCloneShingles("repoA", want); err != nil {
t.Fatalf("BulkSetCloneShingles(repoA): %v", err)
}
got, err := r.LoadCloneShingles("repoA")
if err != nil {
t.Fatalf("LoadCloneShingles(repoA): %v", err)
}
if len(got) != len(want) {
t.Fatalf("LoadCloneShingles(repoA) len = %d, want %d", len(got), len(want))
}
for id, ws := range want {
if !eqShingles(got[id], ws) {
t.Fatalf("LoadCloneShingles(repoA)[%q] = %v, want %v (order preserved)", id, got[id], ws)
}
}
// Overwrite is idempotent in place: re-setting one id replaces it.
if err := w.BulkSetCloneShingles("repoA", map[string][]uint64{"a.go::Bar": {7, 8, 9}}); err != nil {
t.Fatalf("BulkSetCloneShingles(overwrite): %v", err)
}
if got, err := r.LoadCloneShingles("repoA"); err != nil {
t.Fatalf("LoadCloneShingles after overwrite: %v", err)
} else if !eqShingles(got["a.go::Bar"], []uint64{7, 8, 9}) {
t.Fatalf("overwrite not in place: a.go::Bar = %v, want [7 8 9]", got["a.go::Bar"])
}
// Deep-copy isolation: mutating the input slice after the write must
// not corrupt stored state, and mutating the returned slice must not
// corrupt the next read.
src := []uint64{1, 2, 3}
if err := w.BulkSetCloneShingles("repoA", map[string][]uint64{"a.go::Foo": src}); err != nil {
t.Fatalf("BulkSetCloneShingles(isolation): %v", err)
}
src[0] = 999
got2, err := r.LoadCloneShingles("repoA")
if err != nil {
t.Fatalf("LoadCloneShingles(isolation): %v", err)
}
if !eqShingles(got2["a.go::Foo"], []uint64{1, 2, 3}) {
t.Fatalf("input mutation leaked into store: a.go::Foo = %v, want [1 2 3]", got2["a.go::Foo"])
}
got2["a.go::Foo"][0] = 777
if got3, _ := r.LoadCloneShingles("repoA"); !eqShingles(got3["a.go::Foo"], []uint64{1, 2, 3}) {
t.Fatalf("returned-slice mutation leaked into store: a.go::Foo = %v, want [1 2 3]", got3["a.go::Foo"])
}
// Delete a subset and re-Load — the deleted rows must be gone; the
// survivors untouched.
if err := w.DeleteCloneShingles([]string{"a.go::Bar", "b.go::Baz", "missing::id", ""}); err != nil {
t.Fatalf("DeleteCloneShingles: %v", err)
}
after, err := r.LoadCloneShingles("repoA")
if err != nil {
t.Fatalf("LoadCloneShingles after delete: %v", err)
}
if _, present := after["a.go::Bar"]; present {
t.Fatalf("a.go::Bar still present after delete")
}
if _, present := after["b.go::Baz"]; present {
t.Fatalf("b.go::Baz still present after delete")
}
if !eqShingles(after["a.go::Foo"], []uint64{1, 2, 3}) {
t.Fatalf("survivor a.go::Foo corrupted after delete: %v", after["a.go::Foo"])
}
// Empty delete is a no-op.
if err := w.DeleteCloneShingles(nil); err != nil {
t.Fatalf("DeleteCloneShingles(nil): %v", err)
}
// Repo-prefix scoping: a write under repoB must not surface under
// repoA, and vice versa.
if err := w.BulkSetCloneShingles("repoB", map[string][]uint64{"c.go::Qux": {5, 6}}); err != nil {
t.Fatalf("BulkSetCloneShingles(repoB): %v", err)
}
aRows, err := r.LoadCloneShingles("repoA")
if err != nil {
t.Fatalf("LoadCloneShingles(repoA) after repoB write: %v", err)
}
if _, leaked := aRows["c.go::Qux"]; leaked {
t.Fatalf("repoB row c.go::Qux leaked into repoA scope")
}
bRows, err := r.LoadCloneShingles("repoB")
if err != nil {
t.Fatalf("LoadCloneShingles(repoB): %v", err)
}
if len(bRows) != 1 || !eqShingles(bRows["c.go::Qux"], []uint64{5, 6}) {
t.Fatalf("LoadCloneShingles(repoB) = %v, want {c.go::Qux:[5 6]}", bRows)
}
}
// testChurnEnrichmentSidecar mirrors the clone-shingle sidecar
// conformance for the churn enrichment capability (change A): write,
// read-all vs read-by-prefix, idempotent overwrite, per-repo isolation,
// and delete.
func testChurnEnrichmentSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.ChurnEnrichmentWriter)
if !ok {
t.Skip("backend does not implement graph.ChurnEnrichmentWriter")
}
r, ok := s.(graph.ChurnEnrichmentReader)
if !ok {
t.Skip("backend implements ChurnEnrichmentWriter but not ChurnEnrichmentReader")
}
// Empty store + empty input are no-ops.
if got := r.ChurnRows("repoA"); len(got) != 0 {
t.Fatalf("ChurnRows(empty store) = %v, want empty", got)
}
if err := w.BulkSetChurn("repoA", nil); err != nil {
t.Fatalf("BulkSetChurn(nil): %v", err)
}
rowsA := []graph.ChurnEnrichment{
{NodeID: "a.go", CommitCount: 5, AgeDays: 30, ChurnRate: 1.5, LastAuthor: "x@y", LastCommitAt: "2026-01-01T00:00:00Z", HeadSHA: "abc", Branch: "main", ComputedAt: "2026-06-01T00:00:00Z"},
{NodeID: "a.go::Foo", CommitCount: 2, AgeDays: 10, ChurnRate: 0.2, LastAuthor: "z@y", LastCommitAt: "2026-02-01T00:00:00Z"},
}
rowsB := []graph.ChurnEnrichment{
{NodeID: "b.go::Bar", CommitCount: 9, AgeDays: 90, ChurnRate: 0.1, LastAuthor: "q@y"},
}
if err := w.BulkSetChurn("repoA", rowsA); err != nil {
t.Fatalf("BulkSetChurn(repoA): %v", err)
}
if err := w.BulkSetChurn("repoB", rowsB); err != nil {
t.Fatalf("BulkSetChurn(repoB): %v", err)
}
// Per-repo read isolation.
if got := r.ChurnRows("repoA"); len(got) != 2 {
t.Fatalf("ChurnRows(repoA) len = %d, want 2", len(got))
}
if got := r.ChurnRows("repoB"); len(got) != 1 {
t.Fatalf("ChurnRows(repoB) len = %d, want 1", len(got))
}
// Empty prefix returns ALL rows across repos.
all := r.ChurnRows("")
if len(all) != 3 {
t.Fatalf("ChurnRows(\"\") len = %d, want 3 (all repos)", len(all))
}
// Field round-trip + repo_prefix stamping.
byID := map[string]graph.ChurnEnrichment{}
for _, e := range all {
byID[e.NodeID] = e
}
foo := byID["a.go"]
if foo.RepoPrefix != "repoA" || foo.CommitCount != 5 || foo.ChurnRate != 1.5 ||
foo.LastAuthor != "x@y" || foo.LastCommitAt != "2026-01-01T00:00:00Z" ||
foo.HeadSHA != "abc" || foo.Branch != "main" {
t.Fatalf("round-trip mismatch for a.go: %+v", foo)
}
// Idempotent overwrite (INSERT OR REPLACE on node_id).
rowsA[0].CommitCount = 99
if err := w.BulkSetChurn("repoA", rowsA[:1]); err != nil {
t.Fatalf("BulkSetChurn(overwrite): %v", err)
}
for _, e := range r.ChurnRows("repoA") {
if e.NodeID == "a.go" && e.CommitCount != 99 {
t.Fatalf("overwrite failed: a.go commit_count = %d, want 99", e.CommitCount)
}
}
// Delete.
if err := w.DeleteChurn([]string{"a.go", "a.go::Foo"}); err != nil {
t.Fatalf("DeleteChurn: %v", err)
}
if got := r.ChurnRows("repoA"); len(got) != 0 {
t.Fatalf("ChurnRows(repoA) after delete = %d, want 0", len(got))
}
if got := r.ChurnRows("repoB"); len(got) != 1 {
t.Fatalf("DeleteChurn must not touch repoB: len = %d, want 1", len(got))
}
}
// testCoverageEnrichmentSidecar mirrors the churn sidecar conformance.
func testCoverageEnrichmentSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.CoverageEnrichmentWriter)
if !ok {
t.Skip("backend does not implement graph.CoverageEnrichmentWriter")
}
r, ok := s.(graph.CoverageEnrichmentReader)
if !ok {
t.Skip("backend implements CoverageEnrichmentWriter but not Reader")
}
if got := r.CoverageRows("repoA"); len(got) != 0 {
t.Fatalf("CoverageRows(empty) = %v, want empty", got)
}
if err := w.BulkSetCoverage("repoA", nil); err != nil {
t.Fatalf("BulkSetCoverage(nil): %v", err)
}
rowsA := []graph.CoverageEnrichment{
{NodeID: "a.go::Foo", CoveragePct: 87.5, NumStmt: 8, Hit: 7},
{NodeID: "a.go::Bar", CoveragePct: 0, NumStmt: 3, Hit: 0},
}
rowsB := []graph.CoverageEnrichment{{NodeID: "b.go::Baz", CoveragePct: 100, NumStmt: 1, Hit: 1}}
if err := w.BulkSetCoverage("repoA", rowsA); err != nil {
t.Fatalf("BulkSetCoverage(repoA): %v", err)
}
if err := w.BulkSetCoverage("repoB", rowsB); err != nil {
t.Fatalf("BulkSetCoverage(repoB): %v", err)
}
if got := r.CoverageRows("repoA"); len(got) != 2 {
t.Fatalf("CoverageRows(repoA) = %d, want 2", len(got))
}
if got := r.CoverageRows(""); len(got) != 3 {
t.Fatalf("CoverageRows(all) = %d, want 3", len(got))
}
byID := map[string]graph.CoverageEnrichment{}
for _, e := range r.CoverageRows("") {
byID[e.NodeID] = e
}
foo := byID["a.go::Foo"]
if foo.RepoPrefix != "repoA" || foo.CoveragePct != 87.5 || foo.NumStmt != 8 || foo.Hit != 7 {
t.Fatalf("round-trip mismatch: %+v", foo)
}
rowsA[0].CoveragePct = 12.0
if err := w.BulkSetCoverage("repoA", rowsA[:1]); err != nil {
t.Fatalf("overwrite: %v", err)
}
for _, e := range r.CoverageRows("repoA") {
if e.NodeID == "a.go::Foo" && e.CoveragePct != 12.0 {
t.Fatalf("overwrite failed: %v", e.CoveragePct)
}
}
if err := w.DeleteCoverage([]string{"a.go::Foo", "a.go::Bar"}); err != nil {
t.Fatalf("DeleteCoverage: %v", err)
}
if got := r.CoverageRows("repoA"); len(got) != 0 {
t.Fatalf("after delete repoA = %d, want 0", len(got))
}
if got := r.CoverageRows("repoB"); len(got) != 1 {
t.Fatalf("delete must not touch repoB: %d", len(got))
}
}
// testReleaseEnrichmentSidecar mirrors the churn/coverage sidecar conformance.
func testReleaseEnrichmentSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.ReleaseEnrichmentWriter)
if !ok {
t.Skip("backend does not implement graph.ReleaseEnrichmentWriter")
}
r := s.(graph.ReleaseEnrichmentReader)
if err := w.BulkSetReleases("repoA", nil); err != nil {
t.Fatalf("BulkSetReleases(nil): %v", err)
}
if err := w.BulkSetReleases("repoA", []graph.ReleaseEnrichment{
{NodeID: "a.go", AddedIn: "v1.0.0"},
{NodeID: "b.go", AddedIn: "v1.2.0"},
}); err != nil {
t.Fatalf("BulkSetReleases(repoA): %v", err)
}
if err := w.BulkSetReleases("repoB", []graph.ReleaseEnrichment{{NodeID: "c.go", AddedIn: "v2.0.0"}}); err != nil {
t.Fatalf("BulkSetReleases(repoB): %v", err)
}
if got := r.ReleaseRows("repoA"); len(got) != 2 {
t.Fatalf("ReleaseRows(repoA) = %d, want 2", len(got))
}
if got := r.ReleaseRows(""); len(got) != 3 {
t.Fatalf("ReleaseRows(all) = %d, want 3", len(got))
}
byID := map[string]graph.ReleaseEnrichment{}
for _, e := range r.ReleaseRows("") {
byID[e.NodeID] = e
}
if byID["a.go"].AddedIn != "v1.0.0" || byID["a.go"].RepoPrefix != "repoA" {
t.Fatalf("round-trip mismatch: %+v", byID["a.go"])
}
if err := w.DeleteReleases([]string{"a.go", "b.go"}); err != nil {
t.Fatalf("DeleteReleases: %v", err)
}
if got := r.ReleaseRows("repoA"); len(got) != 0 {
t.Fatalf("after delete repoA = %d, want 0", len(got))
}
if got := r.ReleaseRows("repoB"); len(got) != 1 {
t.Fatalf("delete must not touch repoB: %d", len(got))
}
}
// testBlameEnrichmentSidecar mirrors the other enrichment sidecars.
func testBlameEnrichmentSidecar(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
w, ok := s.(graph.BlameEnrichmentWriter)
if !ok {
t.Skip("backend does not implement graph.BlameEnrichmentWriter")
}
r := s.(graph.BlameEnrichmentReader)
if err := w.BulkSetBlame("repoA", nil); err != nil {
t.Fatalf("BulkSetBlame(nil): %v", err)
}
if err := w.BulkSetBlame("repoA", []graph.BlameEnrichment{
{NodeID: "a.go::Foo", Commit: "abc", Email: "x@y", Timestamp: 1700000000},
{NodeID: "a.go::Bar", Commit: "def", Email: "z@y", Timestamp: 1700001000},
}); err != nil {
t.Fatalf("BulkSetBlame(repoA): %v", err)
}
if err := w.BulkSetBlame("repoB", []graph.BlameEnrichment{{NodeID: "b.go::Baz", Commit: "ghi", Email: "q@y", Timestamp: 1700002000}}); err != nil {
t.Fatalf("BulkSetBlame(repoB): %v", err)
}
if got := r.BlameRows("repoA"); len(got) != 2 {
t.Fatalf("BlameRows(repoA) = %d, want 2", len(got))
}
if got := r.BlameRows(""); len(got) != 3 {
t.Fatalf("BlameRows(all) = %d, want 3", len(got))
}
byID := map[string]graph.BlameEnrichment{}
for _, e := range r.BlameRows("") {
byID[e.NodeID] = e
}
foo := byID["a.go::Foo"]
if foo.RepoPrefix != "repoA" || foo.Commit != "abc" || foo.Email != "x@y" || foo.Timestamp != 1700000000 {
t.Fatalf("round-trip mismatch: %+v", foo)
}
if err := w.DeleteBlame([]string{"a.go::Foo", "a.go::Bar"}); err != nil {
t.Fatalf("DeleteBlame: %v", err)
}
if got := r.BlameRows("repoA"); len(got) != 0 {
t.Fatalf("after delete repoA = %d, want 0", len(got))
}
if got := r.BlameRows("repoB"); len(got) != 1 {
t.Fatalf("delete must not touch repoB: %d", len(got))
}
}
// testContractBridgeRoundTrip verifies the contract-bridge subgraph
// survives the backend: a KindContractBridge node with its grouped
// Meta (incl. a []string repo spread), EdgeBridges edges carrying
// Meta["side"], kind-filtered retrieval, and the EvictFile path the
// bridge re-materialisation pass uses to drop the prior generation.
func testContractBridgeRoundTrip(t *testing.T, factory Factory) {
t.Helper()
s := factory(t)
const bridgeFile = "contracts://bridges"
s.AddNode(&graph.Node{
ID: "http::GET::/v1/users", Kind: graph.KindContract,
Name: "http::GET::/v1/users", FilePath: "svc-a/routes.go",
Language: "contract", RepoPrefix: "svc-a",
})
s.AddNode(&graph.Node{
ID: "grpc::Users::GetUser", Kind: graph.KindContract,
Name: "grpc::Users::GetUser", FilePath: "svc-b/client.go",
Language: "contract", RepoPrefix: "svc-b",
})
s.AddNode(&graph.Node{
ID: "bridge::http::GET::/v1/users", Kind: graph.KindContractBridge,
Name: "GET /v1/users", FilePath: bridgeFile,
Language: "contract", RepoPrefix: "svc-a",
Meta: map[string]any{
"contract_type": "http",
"canonical_key": "GET /v1/users",
"repos": []string{"svc-a", "svc-b"},
"provider_count": 1,
"consumer_count": 2,
"cross_repo": true,
},
})
s.AddEdge(&graph.Edge{
From: "bridge::http::GET::/v1/users", To: "http::GET::/v1/users",
Kind: graph.EdgeBridges, FilePath: bridgeFile,
Meta: map[string]any{"side": "both"},
})
s.AddEdge(&graph.Edge{
From: "bridge::http::GET::/v1/users", To: "grpc::Users::GetUser",
Kind: graph.EdgeBridges, FilePath: bridgeFile,
Meta: map[string]any{"side": "consumer"},
})
got := s.GetNode("bridge::http::GET::/v1/users")
if got == nil {
t.Fatalf("bridge node did not round-trip")
}
if got.Kind != graph.KindContractBridge {
t.Fatalf("bridge kind = %q, want %q", got.Kind, graph.KindContractBridge)
}
if got.Meta == nil {
t.Fatalf("bridge Meta not preserved")
}
if got.Meta["canonical_key"] != "GET /v1/users" {
t.Fatalf("Meta[canonical_key] = %v", got.Meta["canonical_key"])
}
switch repos := got.Meta["repos"].(type) {
case []string:
if len(repos) != 2 || repos[0] != "svc-a" || repos[1] != "svc-b" {
t.Fatalf("Meta[repos] = %v", repos)
}
case []any:
if len(repos) != 2 || repos[0] != "svc-a" || repos[1] != "svc-b" {
t.Fatalf("Meta[repos] = %v", repos)
}
default:
t.Fatalf("Meta[repos] has unexpected type %T (%v)", got.Meta["repos"], got.Meta["repos"])
}
var byKind []*graph.Node
for n := range s.NodesByKind(graph.KindContractBridge) {
byKind = append(byKind, n)
}
if len(byKind) != 1 || byKind[0].ID != "bridge::http::GET::/v1/users" {
t.Fatalf("NodesByKind(contract_bridge) = %v", byKind)
}
out := s.GetOutEdges("bridge::http::GET::/v1/users")
sides := map[string]string{}
for _, e := range out {
if e.Kind != graph.EdgeBridges {
t.Fatalf("unexpected out-edge kind %q", e.Kind)
}
side, _ := e.Meta["side"].(string)
sides[e.To] = side
}
if sides["http::GET::/v1/users"] != "both" || sides["grpc::Users::GetUser"] != "consumer" {
t.Fatalf("EdgeBridges side meta did not round-trip: %v", sides)
}
// The reverse direction the impact query walks.
var inKinds []graph.EdgeKind
for _, e := range s.GetInEdges("http::GET::/v1/users") {
inKinds = append(inKinds, e.Kind)
}
if len(inKinds) != 1 || inKinds[0] != graph.EdgeBridges {
t.Fatalf("GetInEdges(contract) = %v, want one bridges edge", inKinds)
}
// Evicting the synthetic bridge file must drop the bridge node and
// its edges while leaving the contract nodes untouched — this is
// the idempotency mechanism the materialisation pass relies on.
nodesRemoved, edgesRemoved := s.EvictFile(bridgeFile)
if nodesRemoved != 1 {
t.Fatalf("EvictFile removed %d nodes, want 1", nodesRemoved)
}
if edgesRemoved != 2 {
t.Fatalf("EvictFile removed %d edges, want 2", edgesRemoved)
}
if s.GetNode("bridge::http::GET::/v1/users") != nil {
t.Fatalf("bridge node survived EvictFile")
}
if s.GetNode("http::GET::/v1/users") == nil || s.GetNode("grpc::Users::GetUser") == nil {
t.Fatalf("contract nodes must survive bridge eviction")
}
if got := s.GetInEdges("http::GET::/v1/users"); len(got) != 0 {
t.Fatalf("stale bridge in-edges survived eviction: %v", got)
}
}