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
662 lines
29 KiB
Go
662 lines
29 KiB
Go
package graph
|
||
|
||
import (
|
||
"fmt"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestAddNode_Idempotent proves the invariant the resilience work added
|
||
// to the graph: N duplicate AddNode calls converge to the same Stats()
|
||
// and the same secondary-index contents as a single call. Without this,
|
||
// a daemon restart that loads a snapshot and then re-runs IndexCtx on
|
||
// top of it (which doesn't evict first) produces N× the byFile /
|
||
// byName / byRepo slice entries — the B1 symptom.
|
||
func TestAddNode_Idempotent(t *testing.T) {
|
||
g := New()
|
||
n := &Node{
|
||
ID: "repo/a.go::Foo",
|
||
Name: "Foo",
|
||
Kind: KindFunction,
|
||
FilePath: "repo/a.go",
|
||
QualName: "pkg.Foo",
|
||
RepoPrefix: "repo",
|
||
}
|
||
|
||
g.AddNode(n)
|
||
base := g.Stats()
|
||
require.Equal(t, 1, base.TotalNodes)
|
||
|
||
for i := 0; i < 10; i++ {
|
||
g.AddNode(n)
|
||
}
|
||
|
||
got := g.Stats()
|
||
assert.Equal(t, base.TotalNodes, got.TotalNodes,
|
||
"duplicate AddNode must not grow node count")
|
||
|
||
byFile := g.GetFileNodes("repo/a.go")
|
||
assert.Len(t, byFile, 1, "byFile must not duplicate")
|
||
byName := g.FindNodesByName("Foo")
|
||
assert.Len(t, byName, 1, "byName must not duplicate")
|
||
byRepo := g.GetRepoNodes("repo")
|
||
assert.Len(t, byRepo, 1, "byRepo must not duplicate")
|
||
assert.Equal(t, n, g.GetNodeByQualName("pkg.Foo"))
|
||
}
|
||
|
||
// TestAddEdge_Idempotent is the edge counterpart of the node test. With
|
||
// the same (From, To, Kind, FilePath, Line), repeated AddEdge calls
|
||
// converge to a single adjacency-list entry. This is what made the
|
||
// "edges double on every daemon restart" symptom recede.
|
||
func TestAddEdge_Idempotent(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "a::A", Name: "A", Kind: KindFunction, FilePath: "a"})
|
||
g.AddNode(&Node{ID: "b::B", Name: "B", Kind: KindFunction, FilePath: "b"})
|
||
|
||
e := &Edge{From: "b::B", To: "a::A", Kind: EdgeCalls, FilePath: "b", Line: 7}
|
||
for i := 0; i < 10; i++ {
|
||
g.AddEdge(e)
|
||
}
|
||
|
||
assert.Equal(t, 1, g.EdgeCount(), "duplicate AddEdge must not grow edge count")
|
||
assert.Len(t, g.GetOutEdges("b::B"), 1, "outEdges must have exactly one entry")
|
||
assert.Len(t, g.GetInEdges("a::A"), 1, "inEdges must have exactly one entry")
|
||
}
|
||
|
||
// TestAddEdge_DifferentFromSameTo guards the edgeKey shape: two edges
|
||
// with different From but identical (To, Kind, FilePath, Line) must
|
||
// both survive, as distinct entries in the target's inEdges bucket.
|
||
// An earlier version of the sidecar omitted From from the key, which
|
||
// made two such edges collide at the inEdges[to] index — the second
|
||
// AddEdge overwrote the first and downstream BFS traversal lost one
|
||
// caller. Cross-repo impact analysis regressed until From landed in
|
||
// the key.
|
||
func TestAddEdge_DifferentFromSameTo(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "target::T", Name: "T", Kind: KindFunction, FilePath: "t"})
|
||
g.AddNode(&Node{ID: "caller1::C1", Name: "C1", Kind: KindFunction, FilePath: "c1"})
|
||
g.AddNode(&Node{ID: "caller2::C2", Name: "C2", Kind: KindFunction, FilePath: "c2"})
|
||
|
||
// Both edges lack FilePath/Line — a common shape in tests that
|
||
// construct synthetic graphs. Without From in the key they would
|
||
// dedup to one inEdges entry.
|
||
g.AddEdge(&Edge{From: "caller1::C1", To: "target::T", Kind: EdgeCalls})
|
||
g.AddEdge(&Edge{From: "caller2::C2", To: "target::T", Kind: EdgeCalls})
|
||
|
||
in := g.GetInEdges("target::T")
|
||
assert.Len(t, in, 2, "two distinct callers must both appear in inEdges")
|
||
}
|
||
|
||
// TestAddEdge_LineDisambiguates proves that two call-sites from the
|
||
// same caller to the same callee at different lines are preserved —
|
||
// they're distinct edges, not duplicates. `foo(); foo();` in the same
|
||
// function must survive dedup.
|
||
func TestAddEdge_LineDisambiguates(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "a::A", Name: "A", Kind: KindFunction, FilePath: "a"})
|
||
g.AddNode(&Node{ID: "b::B", Name: "B", Kind: KindFunction, FilePath: "b"})
|
||
|
||
g.AddEdge(&Edge{From: "b::B", To: "a::A", Kind: EdgeCalls, FilePath: "b", Line: 7})
|
||
g.AddEdge(&Edge{From: "b::B", To: "a::A", Kind: EdgeCalls, FilePath: "b", Line: 11})
|
||
|
||
assert.Equal(t, 2, g.EdgeCount(), "different lines must produce distinct edges")
|
||
}
|
||
|
||
// TestAddNode_Replace verifies that re-adding a node with an updated
|
||
// Meta preserves the slice positions and replaces the pointer in place.
|
||
// This is the "same ID, new signature / new line" case that happens
|
||
// during IncrementalReindex after a file edit.
|
||
func TestAddNode_Replace(t *testing.T) {
|
||
g := New()
|
||
n1 := &Node{ID: "a::X", Name: "X", Kind: KindFunction, FilePath: "a",
|
||
Meta: map[string]any{"signature": "X()"}}
|
||
g.AddNode(n1)
|
||
|
||
n2 := &Node{ID: "a::X", Name: "X", Kind: KindFunction, FilePath: "a",
|
||
Meta: map[string]any{"signature": "X(arg int)"}}
|
||
g.AddNode(n2)
|
||
|
||
got := g.GetNode("a::X")
|
||
require.NotNil(t, got)
|
||
assert.Equal(t, "X(arg int)", got.Meta["signature"],
|
||
"replacement must install new pointer")
|
||
assert.Len(t, g.GetFileNodes("a"), 1, "byFile must not grow on replace")
|
||
assert.Len(t, g.FindNodesByName("X"), 1, "byName must not grow on replace")
|
||
// The slice entry must be the new pointer — readers iterate byFile
|
||
// and rely on it reflecting the current node state.
|
||
assert.Same(t, n2, g.GetFileNodes("a")[0])
|
||
}
|
||
|
||
// TestAddNode_MigrateBuckets verifies that when a replacement changes
|
||
// the node's FilePath / Name / RepoPrefix, the secondary-index entry
|
||
// moves from the old bucket to the new one. Without this, a rename
|
||
// (unusual but legal) would leave ghost entries in both buckets.
|
||
func TestAddNode_MigrateBuckets(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "x::X", Name: "OldName", Kind: KindFunction,
|
||
FilePath: "old.go", RepoPrefix: "oldrepo", QualName: "pkg.Old"})
|
||
g.AddNode(&Node{ID: "x::X", Name: "NewName", Kind: KindFunction,
|
||
FilePath: "new.go", RepoPrefix: "newrepo", QualName: "pkg.New"})
|
||
|
||
assert.Empty(t, g.GetFileNodes("old.go"), "old bucket must be emptied")
|
||
assert.Len(t, g.GetFileNodes("new.go"), 1, "new bucket must have the entry")
|
||
assert.Empty(t, g.FindNodesByName("OldName"))
|
||
assert.Len(t, g.FindNodesByName("NewName"), 1)
|
||
assert.Empty(t, g.GetRepoNodes("oldrepo"))
|
||
assert.Len(t, g.GetRepoNodes("newrepo"), 1)
|
||
assert.Nil(t, g.GetNodeByQualName("pkg.Old"))
|
||
assert.NotNil(t, g.GetNodeByQualName("pkg.New"))
|
||
}
|
||
|
||
// TestAddNode_PreservesRepoPrefixOnEmptyDowngrade pins the warmup bug
|
||
// where some path re-AddNode'd existing repo-stamped nodes with
|
||
// RepoPrefix="" — clearing them out of byRepo[prefix] without touching
|
||
// the underlying nodes map. The user-visible symptom: per-repo queries
|
||
// (RepoStats / GetRepoNodes / RepoMemoryEstimate) returned empty for
|
||
// repos whose nodes were still present in the graph. Defensive fix:
|
||
// a non-empty prev RepoPrefix is sticky — the empty new value is
|
||
// promoted to prev's value rather than allowed to silently strip the
|
||
// node from its bucket.
|
||
func TestAddNode_PreservesRepoPrefixOnEmptyDowngrade(t *testing.T) {
|
||
g := New()
|
||
original := &Node{
|
||
ID: "myrepo/file.go::Foo", Name: "Foo", Kind: KindFunction,
|
||
FilePath: "myrepo/file.go", RepoPrefix: "myrepo",
|
||
}
|
||
g.AddNode(original)
|
||
require.Len(t, g.GetRepoNodes("myrepo"), 1, "node must land in byRepo at first add")
|
||
|
||
// Re-add with empty RepoPrefix (the buggy caller).
|
||
g.AddNode(&Node{
|
||
ID: "myrepo/file.go::Foo", Name: "Foo", Kind: KindFunction,
|
||
FilePath: "myrepo/file.go",
|
||
// RepoPrefix intentionally empty.
|
||
})
|
||
|
||
assert.Len(t, g.GetRepoNodes("myrepo"), 1,
|
||
"byRepo[myrepo] must still contain the node after empty-prefix re-add")
|
||
assert.NotNil(t, g.GetNode("myrepo/file.go::Foo"),
|
||
"node itself must still exist")
|
||
assert.Equal(t, "myrepo", g.GetNode("myrepo/file.go::Foo").RepoPrefix,
|
||
"RepoPrefix on the stored node must be preserved")
|
||
}
|
||
|
||
// TestEvictFile_SwapWithLast exercises the sidecar-based swap-with-last
|
||
// removal path. Uses enough nodes per file that iteration order would
|
||
// surface a mis-tracked sidecar position. The assertion is simple: post
|
||
// eviction, the graph is empty of entries for that file.
|
||
func TestEvictFile_SwapWithLast(t *testing.T) {
|
||
g := New()
|
||
for i := 0; i < 100; i++ {
|
||
g.AddNode(&Node{
|
||
ID: fmt.Sprintf("f.go::Sym%d", i),
|
||
Name: fmt.Sprintf("Sym%d", i),
|
||
Kind: KindFunction,
|
||
FilePath: "f.go",
|
||
})
|
||
}
|
||
assert.Len(t, g.GetFileNodes("f.go"), 100)
|
||
|
||
n, _ := g.EvictFile("f.go")
|
||
assert.Equal(t, 100, n)
|
||
assert.Empty(t, g.GetFileNodes("f.go"))
|
||
assert.Equal(t, 0, g.NodeCount())
|
||
}
|
||
|
||
// TestRestartStability simulates the daemon-restart cycle: snapshot
|
||
// into a fresh graph (via AddNode/AddEdge replay, which is what
|
||
// loadSnapshot does), and verify Stats() matches the original. Repeat
|
||
// many times to catch any state that drifts across restarts.
|
||
//
|
||
// Before the sidecar landed, Stats().TotalEdges doubled on every cycle;
|
||
// after, the invariant holds for arbitrary N.
|
||
func TestRestartStability(t *testing.T) {
|
||
orig := buildRepresentativeGraph()
|
||
want := orig.Stats()
|
||
|
||
for cycle := 0; cycle < 5; cycle++ {
|
||
replay := New()
|
||
for _, n := range orig.AllNodes() {
|
||
replay.AddNode(n)
|
||
}
|
||
for _, e := range orig.AllEdges() {
|
||
replay.AddEdge(e)
|
||
}
|
||
|
||
// Simulate a second "IndexCtx on top" pass — this is what
|
||
// the old warmup did after loadSnapshot. Without idempotent
|
||
// writes, this pass doubles every edge.
|
||
for _, n := range orig.AllNodes() {
|
||
replay.AddNode(n)
|
||
}
|
||
for _, e := range orig.AllEdges() {
|
||
replay.AddEdge(e)
|
||
}
|
||
|
||
got := replay.Stats()
|
||
assert.Equal(t, want.TotalNodes, got.TotalNodes,
|
||
"cycle %d: node count drifted", cycle)
|
||
assert.Equal(t, want.TotalEdges, got.TotalEdges,
|
||
"cycle %d: edge count drifted (B1 regression)", cycle)
|
||
}
|
||
}
|
||
|
||
func buildRepresentativeGraph() *Graph {
|
||
g := New()
|
||
// Build a small call graph that stresses every secondary index:
|
||
// multiple files, multiple names, multiple repos, calls + imports.
|
||
files := []struct {
|
||
path, repo string
|
||
}{
|
||
{"r1/a.go", "r1"},
|
||
{"r1/b.go", "r1"},
|
||
{"r2/c.go", "r2"},
|
||
}
|
||
for _, f := range files {
|
||
for i := 0; i < 5; i++ {
|
||
g.AddNode(&Node{
|
||
ID: fmt.Sprintf("%s::Fn%d", f.path, i),
|
||
Name: fmt.Sprintf("Fn%d", i),
|
||
Kind: KindFunction,
|
||
FilePath: f.path,
|
||
RepoPrefix: f.repo,
|
||
})
|
||
}
|
||
}
|
||
// Add a few call edges between files.
|
||
g.AddEdge(&Edge{From: "r1/a.go::Fn0", To: "r1/b.go::Fn1", Kind: EdgeCalls, FilePath: "r1/a.go", Line: 10})
|
||
g.AddEdge(&Edge{From: "r1/a.go::Fn0", To: "r2/c.go::Fn2", Kind: EdgeCalls, FilePath: "r1/a.go", Line: 12})
|
||
g.AddEdge(&Edge{From: "r1/b.go::Fn3", To: "r2/c.go::Fn4", Kind: EdgeCalls, FilePath: "r1/b.go", Line: 5})
|
||
return g
|
||
}
|
||
|
||
// TestReindexEdge_UpdatesSidecar verifies ReindexEdge migrates the
|
||
// inEdges bucket + both sidecars when the resolver changes an edge's
|
||
// To field (unresolved::X → real::X). A bug here would show up as
|
||
// GetInEdges returning zero entries after resolve, or later AddEdge
|
||
// refusing to dedup because the key changed out from under the sidecar.
|
||
func TestReindexEdge_UpdatesSidecar(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "a::A", Name: "A", Kind: KindFunction, FilePath: "a"})
|
||
g.AddNode(&Node{ID: "b::B", Name: "B", Kind: KindFunction, FilePath: "b"})
|
||
g.AddNode(&Node{ID: "unresolved::real", Name: "real", Kind: KindFunction, FilePath: "u"})
|
||
|
||
e := &Edge{From: "a::A", To: "unresolved::real", Kind: EdgeCalls, FilePath: "a", Line: 3}
|
||
g.AddEdge(e)
|
||
|
||
require.Len(t, g.GetInEdges("unresolved::real"), 1)
|
||
require.Len(t, g.GetInEdges("b::B"), 0)
|
||
|
||
// Resolver-style mutation.
|
||
oldTo := e.To
|
||
e.To = "b::B"
|
||
g.ReindexEdge(e, oldTo)
|
||
|
||
assert.Len(t, g.GetInEdges("unresolved::real"), 0,
|
||
"old target bucket must be emptied")
|
||
assert.Len(t, g.GetInEdges("b::B"), 1,
|
||
"new target bucket must hold the edge")
|
||
|
||
// Adding the same edge with its NEW identity must dedup via the
|
||
// updated sidecar — if ReindexEdge forgot to rewrite the
|
||
// outEdgeIdx key, this would append a duplicate.
|
||
g.AddEdge(e)
|
||
assert.Equal(t, 1, g.EdgeCount(), "AddEdge after ReindexEdge must still dedup")
|
||
}
|
||
|
||
// TestRemoveEdgeFromBucket_SwappedEdgeWithMutatedTo regresses a daemon
|
||
// crash:
|
||
//
|
||
// panic: runtime error: index out of range [N] with length N
|
||
// graph.addEdgeToBucket
|
||
// graph.(*Graph).ReindexEdge
|
||
// resolver.(*Resolver).ResolveAll
|
||
//
|
||
// The resolver's serial pass mutates `j.edge.To = j.newTo` BEFORE
|
||
// taking the shard lock. If the swap-with-last in
|
||
// removeEdgeFromBucket lands on an edge whose .To was mutated in the
|
||
// same flight (e.g. another job in the same bucket), recomputing
|
||
// keyOf(swapped) returns the NEW key while the sidecar still has an
|
||
// entry under the ORIGINAL key pointing past the shrunk slice. The
|
||
// next AddEdge that collides with the orphaned key panics.
|
||
//
|
||
// The fix stores each entry's insertion-time edgeKey in a parallel
|
||
// slice (outEdgeKeys / inEdgeKeys) so the sidecar update is
|
||
// independent of the live Edge struct.
|
||
func TestRemoveEdgeFromBucket_SwappedEdgeWithMutatedTo(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "a::A", Name: "A", Kind: KindFunction, FilePath: "a"})
|
||
g.AddNode(&Node{ID: "b::B", Name: "B", Kind: KindFunction, FilePath: "b"})
|
||
g.AddNode(&Node{ID: "x::X", Name: "X", Kind: KindFunction, FilePath: "x"})
|
||
g.AddNode(&Node{ID: "y::Y", Name: "Y", Kind: KindFunction, FilePath: "y"})
|
||
|
||
// Two edges share an unresolved bucket. We'll mutate eSwapped's To
|
||
// out-of-band (mimicking the resolver's pre-lock mutation) before
|
||
// removing eHead, forcing eSwapped to be the swap-with-last
|
||
// element. With the bug, the sidecar update used keyOf(eSwapped)
|
||
// — a different key than the one eSwapped was indexed under —
|
||
// leaving a stale entry that pointed past the shrunk slice.
|
||
const target = "unresolved::shared"
|
||
eHead := &Edge{From: "a::A", To: target, Kind: EdgeCalls, FilePath: "a", Line: 1}
|
||
eSwapped := &Edge{From: "b::B", To: target, Kind: EdgeCalls, FilePath: "b", Line: 2}
|
||
g.AddEdge(eHead)
|
||
g.AddEdge(eSwapped)
|
||
require.Len(t, g.GetInEdges(target), 2)
|
||
|
||
// Out-of-band mutation: eSwapped.To changes BUT we don't yet
|
||
// ReindexEdge. This models the in-flight window in
|
||
// resolver.go's serial pass.
|
||
eSwapped.To = "x::X"
|
||
|
||
// Now remove eHead via ReindexEdge — this triggers the swap that
|
||
// previously corrupted the sidecar.
|
||
oldHead := target
|
||
eHead.To = "y::Y"
|
||
g.ReindexEdge(eHead, oldHead)
|
||
|
||
// With the bug, inEdgeIdx[target] still held an orphan entry under
|
||
// eSwapped's ORIGINAL key (To=target) at position 1 — past the
|
||
// now-shrunk slice (length 1, valid index only 0). Any subsequent
|
||
// AddEdge whose key collides with that stale entry would do
|
||
// `bucket[target][1] = newEdge` and panic with
|
||
// "index out of range [1] with length 1".
|
||
//
|
||
// Construct exactly that collision: a fresh edge sharing
|
||
// eSwapped's original (From, To, Kind, FilePath, Line) tuple,
|
||
// which is what the resolver does when it pre-stages a duplicate
|
||
// pending edge from another file at the same line.
|
||
collision := &Edge{From: "b::B", To: target, Kind: EdgeCalls, FilePath: "b", Line: 2}
|
||
require.NotPanics(t, func() {
|
||
g.AddEdge(collision)
|
||
}, "addEdgeToBucket must not panic on a stale sidecar position")
|
||
|
||
// eHead has been migrated to its new target.
|
||
assert.Len(t, g.GetInEdges("y::Y"), 1, "eHead's new target should hold one edge")
|
||
}
|
||
|
||
// TestReindexEdge_OutEdgeKeysStayConsistent regresses the daemon
|
||
// warmup panic:
|
||
//
|
||
// panic: runtime error: index out of range [61] with length 58
|
||
// graph.removeEdgeFromBucket
|
||
// graph.(*Graph).evictEdgesLocked
|
||
// graph.(*Graph).EvictFile
|
||
// indexer.(*Indexer).indexFile
|
||
// indexer.(*Indexer).IncrementalReindex
|
||
//
|
||
// The failure mode: ReindexEdge updates outEdgeIdx[oldKey→newKey] but
|
||
// previously did NOT update the parallel outEdgeKeys[pos] slice. A
|
||
// later swap-with-last removal in the same outEdges bucket reads
|
||
// outEdgeKeys[swappedPos] — finds the stale insertion-time key — and
|
||
// re-inserts THAT key into outEdgeIdx pointing at the swapped slot.
|
||
// outEdgeIdx then holds both the live newKey (still pointing at the
|
||
// original pre-swap position) AND a stale-key entry. The next op
|
||
// that walks back to the original pos finds the slice has shrunk
|
||
// past it and panics.
|
||
//
|
||
// The fix: ReindexEdge rewrites outEdgeKeys[pos] = newKey alongside
|
||
// the outEdgeIdx update so the parallel slice never holds stale keys.
|
||
func TestReindexEdge_OutEdgeKeysStayConsistent(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "a::A", Name: "A", Kind: KindFunction, FilePath: "a"})
|
||
g.AddNode(&Node{ID: "t1", Name: "t1", Kind: KindFunction, FilePath: "t1"})
|
||
g.AddNode(&Node{ID: "t2", Name: "t2", Kind: KindFunction, FilePath: "t2"})
|
||
g.AddNode(&Node{ID: "t3", Name: "t3", Kind: KindFunction, FilePath: "t3"})
|
||
g.AddNode(&Node{ID: "t2-prime", Name: "t2'", Kind: KindFunction, FilePath: "t2p"})
|
||
g.AddNode(&Node{ID: "t2-prime-prime", Name: "t2''", Kind: KindFunction, FilePath: "t2pp"})
|
||
|
||
// Three edges share the same From, populating one outEdges bucket
|
||
// with three slots. Distinct lines so the keys differ.
|
||
e1 := &Edge{From: "a::A", To: "t1", Kind: EdgeCalls, FilePath: "a", Line: 1}
|
||
e2 := &Edge{From: "a::A", To: "t2", Kind: EdgeCalls, FilePath: "a", Line: 2}
|
||
e3 := &Edge{From: "a::A", To: "t3", Kind: EdgeCalls, FilePath: "a", Line: 3}
|
||
g.AddEdge(e1)
|
||
g.AddEdge(e2)
|
||
g.AddEdge(e3)
|
||
require.Len(t, g.GetOutEdges("a::A"), 3)
|
||
|
||
// ReindexEdge e2 — outEdgeKeys[1] would stay stale before the fix.
|
||
oldTo := e2.To
|
||
e2.To = "t2-prime"
|
||
g.ReindexEdge(e2, oldTo)
|
||
|
||
// Force a swap-with-last in the outEdges["a::A"] bucket by
|
||
// removing e1. With the bug, this propagates the stale key for
|
||
// slot 1 (e2's original key) into outEdgeIdx.
|
||
require.True(t, g.RemoveEdge(e1.From, e1.To, e1.Kind))
|
||
|
||
// ReindexEdge e2 a second time — drives outEdgeIdx into the
|
||
// inconsistent state where it holds both the new key and the
|
||
// stale key from the previous swap.
|
||
oldTo = e2.To
|
||
e2.To = "t2-prime-prime"
|
||
g.ReindexEdge(e2, oldTo)
|
||
|
||
// Removal that touches the bucket must NOT panic. With the bug,
|
||
// removing e3 via its resolved key triggered
|
||
// `slice[pos] = slice[last]` with pos past the shrunk slice.
|
||
require.NotPanics(t, func() {
|
||
g.RemoveEdge(e3.From, e3.To, e3.Kind)
|
||
}, "swap-with-last after repeated ReindexEdge must not panic")
|
||
|
||
// e2 still queryable at its final target — sanity check that the
|
||
// bucket bookkeeping survived intact.
|
||
out := g.GetOutEdges("a::A")
|
||
require.Len(t, out, 1)
|
||
assert.Equal(t, "t2-prime-prime", out[0].To)
|
||
}
|
||
|
||
// TestEvictFile_AfterReindex regresses the same panic via the actual
|
||
// eviction path the daemon hit (EvictFile → evictEdgesLocked) instead
|
||
// of going through the public RemoveEdge API. The fixture stages the
|
||
// exact corruption window the daemon panic describes:
|
||
//
|
||
// 1. A multi-edge outEdges bucket on a single From.
|
||
// 2. ReindexEdge against a non-last slot in that bucket — outEdgeKeys
|
||
// for that slot becomes stale (still holds the pre-mutation key).
|
||
// 3. A swap-with-last removal earlier in the bucket pulls the stale
|
||
// key into outEdgeIdx pointing at the swapped position.
|
||
// 4. The slice subsequently shrinks past that position.
|
||
// 5. EvictFile on the reindexed edge's NEW target then walks
|
||
// inEdges[that target], grabs the still-correct live key from
|
||
// inEdgeKeys, and calls removeEdgeFromBucket(outEdges, ...) on
|
||
// the From bucket. With the bug, outEdgeIdx still has the live
|
||
// key pointing past the now-shrunk slice → panic.
|
||
func TestEvictFile_AfterReindex(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "src/a.go::A", Name: "A", Kind: KindFunction, FilePath: "src/a.go"})
|
||
g.AddNode(&Node{ID: "t1.go::T1", Name: "T1", Kind: KindFunction, FilePath: "t1.go"})
|
||
g.AddNode(&Node{ID: "t2.go::T2", Name: "T2", Kind: KindFunction, FilePath: "t2.go"})
|
||
g.AddNode(&Node{ID: "t3.go::T3", Name: "T3", Kind: KindFunction, FilePath: "t3.go"})
|
||
g.AddNode(&Node{ID: "t2p.go::T2P", Name: "T2P", Kind: KindFunction, FilePath: "t2p.go"})
|
||
|
||
// Three outgoing edges from A — slot 1 is the one we'll reindex.
|
||
e1 := &Edge{From: "src/a.go::A", To: "t1.go::T1", Kind: EdgeCalls, FilePath: "src/a.go", Line: 1}
|
||
e2 := &Edge{From: "src/a.go::A", To: "t2.go::T2", Kind: EdgeCalls, FilePath: "src/a.go", Line: 2}
|
||
e3 := &Edge{From: "src/a.go::A", To: "t3.go::T3", Kind: EdgeCalls, FilePath: "src/a.go", Line: 3}
|
||
g.AddEdge(e1)
|
||
g.AddEdge(e2)
|
||
g.AddEdge(e3)
|
||
|
||
// Step 1: reindex e2's To. Without the fix, outEdgeKeys[1] keeps
|
||
// the pre-mutation key while outEdgeIdx swaps to the new key.
|
||
old := e2.To
|
||
e2.To = "t2p.go::T2P"
|
||
g.ReindexEdge(e2, old)
|
||
|
||
// Step 2: evict T1 — its inEdges bucket holds e1; Phase 2 of
|
||
// evictEdgesLocked calls removeEdgeFromBucket(outEdges["src/a.go::A"], k_for_e1).
|
||
// Inside, swap-with-last picks slot 2's key (k_for_e3 — correct)
|
||
// because slot 2 is what the swap consumes. So no panic yet, but
|
||
// after the swap the bucket is shape [e3, e2] with outEdgeKeys
|
||
// = [k_for_e3, STALE_pre-reindex_e2_key].
|
||
require.NotPanics(t, func() { g.EvictFile("t1.go") })
|
||
|
||
// Step 3: evict T3 — its inEdges bucket now points at the
|
||
// swapped slot 0 (e3). removeEdgeFromBucket(outEdges, k_for_e3)
|
||
// runs, swap-with-last picks up outEdgeKeys[1] which is the
|
||
// STALE key. With the bug, that stale key gets re-inserted into
|
||
// outEdgeIdx at position 0 alongside the still-live e2 key
|
||
// (which now points at position 1, but the slice has shrunk to
|
||
// length 1).
|
||
require.NotPanics(t, func() { g.EvictFile("t3.go") })
|
||
|
||
// Step 4: evict T2P. inEdges[T2P] holds e2 with inEdgeKeys
|
||
// carrying the LIVE key (insertion via addEdgeToBucket during
|
||
// ReindexEdge used the new key). removeEdgeFromBucket(outEdges
|
||
// ["src/a.go::A"], LIVE_key) looks up outEdgeIdx[LIVE_key] = 1,
|
||
// then tries slice[1] in a slice of length 1 → panic with the
|
||
// bug, clean removal with the fix.
|
||
require.NotPanics(t, func() {
|
||
g.EvictFile("t2p.go")
|
||
}, "EvictFile on the reindexed edge's new target must not panic on stale outEdgeIdx")
|
||
|
||
// All edges removed — bucket should be empty.
|
||
assert.Empty(t, g.GetOutEdges("src/a.go::A"), "outEdges bucket must drain after every target was evicted")
|
||
}
|
||
|
||
// edgeIdentityGraph builds a two-node graph with one A→B calls edge at
|
||
// the given Origin, returning the graph and the live in-graph edge.
|
||
func edgeIdentityGraph(t *testing.T, origin string) (*Graph, *Edge) {
|
||
t.Helper()
|
||
g := New()
|
||
g.AddNode(&Node{ID: "p/a.go::A", Name: "A", Kind: KindFunction, FilePath: "p/a.go"})
|
||
g.AddNode(&Node{ID: "p/b.go::B", Name: "B", Kind: KindFunction, FilePath: "p/b.go"})
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 7, Origin: origin})
|
||
out := g.GetOutEdges("p/a.go::A")
|
||
require.Len(t, out, 1)
|
||
return g, out[0]
|
||
}
|
||
|
||
// TestSetEdgeProvenance_ChangesIdentityAndCounts proves SetEdgeProvenance
|
||
// is a delete-then-insert of the edge's identity: a real Origin change
|
||
// flips the IdentityHash and bumps the revision counter by exactly one,
|
||
// while the logical adjacency-list slot is untouched.
|
||
func TestSetEdgeProvenance_ChangesIdentityAndCounts(t *testing.T) {
|
||
g, e := edgeIdentityGraph(t, OriginTextMatched)
|
||
require.Equal(t, 0, g.EdgeIdentityRevisions(), "fresh graph has no provenance churn")
|
||
|
||
before := e.IdentityHash()
|
||
changed := g.SetEdgeProvenance(e, OriginLSPResolved)
|
||
|
||
assert.True(t, changed, "upgrading Origin must report an identity change")
|
||
assert.Equal(t, OriginLSPResolved, e.Origin, "Origin must be applied")
|
||
assert.NotEqual(t, before, e.IdentityHash(), "identity hash must change with Origin")
|
||
assert.Equal(t, 1, g.EdgeIdentityRevisions(), "exactly one revision recorded")
|
||
|
||
// The logical edge is unchanged — same single adjacency entry.
|
||
assert.Len(t, g.GetOutEdges("p/a.go::A"), 1, "outEdges count must not change")
|
||
assert.Len(t, g.GetInEdges("p/b.go::B"), 1, "inEdges count must not change")
|
||
}
|
||
|
||
// TestSetEdgeProvenance_NoOpWhenOriginUnchanged proves a SetEdgeProvenance
|
||
// call that does not actually change Origin is a no-op: identity stable,
|
||
// counter untouched, return value false.
|
||
func TestSetEdgeProvenance_NoOpWhenOriginUnchanged(t *testing.T) {
|
||
g, e := edgeIdentityGraph(t, OriginASTResolved)
|
||
before := e.IdentityHash()
|
||
|
||
changed := g.SetEdgeProvenance(e, OriginASTResolved)
|
||
|
||
assert.False(t, changed, "setting Origin to its current value is a no-op")
|
||
assert.Equal(t, before, e.IdentityHash(), "identity hash must be stable on a no-op")
|
||
assert.Equal(t, 0, g.EdgeIdentityRevisions(), "a no-op must not bump the counter")
|
||
}
|
||
|
||
// TestSetEdgeProvenance_RederivesTierWhenSet confirms Tier — the sole
|
||
// Origin-derived label on an edge — is recomputed when it was already
|
||
// populated, and left empty (the in-memory default) when it was not.
|
||
func TestSetEdgeProvenance_RederivesTierWhenSet(t *testing.T) {
|
||
// Tier already set: must be re-derived from the new Origin.
|
||
g, e := edgeIdentityGraph(t, OriginTextMatched)
|
||
e.Tier = ResolvedBy(OriginTextMatched)
|
||
g.SetEdgeProvenance(e, OriginLSPResolved)
|
||
assert.Equal(t, ResolvedBy(OriginLSPResolved), e.Tier, "populated Tier must track the new Origin")
|
||
|
||
// Tier left empty: must stay empty rather than start being stamped.
|
||
g2, e2 := edgeIdentityGraph(t, OriginTextMatched)
|
||
g2.SetEdgeProvenance(e2, OriginLSPResolved)
|
||
assert.Equal(t, "", e2.Tier, "an unset Tier must remain unset")
|
||
}
|
||
|
||
// TestAddEdge_ReaddWithUpgradedOriginCounts proves the second mutation
|
||
// path: re-adding an edge with the same logical key but an upgraded
|
||
// Origin (the resolver's AddEdge-based upgrade path) replaces the slot
|
||
// in place AND is counted as an identity revision — without creating a
|
||
// duplicate parallel edge.
|
||
func TestAddEdge_ReaddWithUpgradedOriginCounts(t *testing.T) {
|
||
g, _ := edgeIdentityGraph(t, OriginTextMatched)
|
||
require.Equal(t, 0, g.EdgeIdentityRevisions())
|
||
|
||
// Re-add the same logical edge with a stronger Origin.
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 7, Origin: OriginLSPResolved})
|
||
|
||
assert.Equal(t, 1, g.EdgeCount(), "re-add must not create a parallel edge")
|
||
assert.Len(t, g.GetOutEdges("p/a.go::A"), 1, "still one outEdge")
|
||
assert.Len(t, g.GetInEdges("p/b.go::B"), 1, "still one inEdge")
|
||
assert.Equal(t, 1, g.EdgeIdentityRevisions(), "the Origin upgrade on re-add must be counted once")
|
||
assert.Equal(t, OriginLSPResolved, g.GetOutEdges("p/a.go::A")[0].Origin, "newer Origin wins")
|
||
}
|
||
|
||
// TestAddEdge_ReaddWithSameOriginDoesNotCount proves an idempotent
|
||
// re-add carrying the SAME Origin is not mistaken for provenance churn.
|
||
func TestAddEdge_ReaddWithSameOriginDoesNotCount(t *testing.T) {
|
||
g, _ := edgeIdentityGraph(t, OriginASTResolved)
|
||
for i := 0; i < 5; i++ {
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 7, Origin: OriginASTResolved})
|
||
}
|
||
assert.Equal(t, 1, g.EdgeCount(), "idempotent re-add must not grow the edge count")
|
||
assert.Equal(t, 0, g.EdgeIdentityRevisions(), "re-add with an unchanged Origin is not a revision")
|
||
}
|
||
|
||
// TestVerifyEdgeIdentities_PassesOnNormalGraph proves a graph built
|
||
// only through the sanctioned mutation paths (AddEdge, SetEdgeProvenance)
|
||
// is internally consistent — the out-edge and in-edge views agree on
|
||
// every edge's provenance-bearing identity.
|
||
func TestVerifyEdgeIdentities_PassesOnNormalGraph(t *testing.T) {
|
||
g := New()
|
||
for _, id := range []string{"p/a.go::A", "p/b.go::B", "p/c.go::C"} {
|
||
g.AddNode(&Node{ID: id, Name: id, Kind: KindFunction, FilePath: id})
|
||
}
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 3, Origin: OriginTextMatched})
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/c.go::C", Kind: EdgeCalls, FilePath: "p/a.go", Line: 4, Origin: OriginASTResolved})
|
||
g.AddEdge(&Edge{From: "p/b.go::B", To: "p/c.go::C", Kind: EdgeReferences, FilePath: "p/b.go", Line: 9})
|
||
|
||
require.NoError(t, g.VerifyEdgeIdentities(), "freshly built graph must be identity-consistent")
|
||
|
||
// A sanctioned provenance change keeps the graph consistent.
|
||
out := g.GetOutEdges("p/a.go::A")
|
||
require.NotEmpty(t, out)
|
||
g.SetEdgeProvenance(out[0], OriginLSPResolved)
|
||
require.NoError(t, g.VerifyEdgeIdentities(), "SetEdgeProvenance must preserve identity consistency")
|
||
}
|
||
|
||
// TestVerifyEdgeIdentities_CatchesDivergentOrigin proves the verifier
|
||
// is not vacuous: when an edge's Origin is changed on only one
|
||
// adjacency view (the failure mode of mutating a copied edge instead
|
||
// of routing through SetEdgeProvenance), VerifyEdgeIdentities reports
|
||
// the inconsistency.
|
||
func TestVerifyEdgeIdentities_CatchesDivergentOrigin(t *testing.T) {
|
||
g := New()
|
||
g.AddNode(&Node{ID: "p/a.go::A", Name: "A", Kind: KindFunction, FilePath: "p/a.go"})
|
||
g.AddNode(&Node{ID: "p/b.go::B", Name: "B", Kind: KindFunction, FilePath: "p/b.go"})
|
||
g.AddEdge(&Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 7, Origin: OriginTextMatched})
|
||
require.NoError(t, g.VerifyEdgeIdentities())
|
||
|
||
// Simulate the bug: the in-edge bucket gets a *different* edge
|
||
// object whose Origin diverges from the out-edge view. addEdgeToBucket
|
||
// keys on the Origin-free logical key, so this overwrites the slot
|
||
// with a copy rather than appending.
|
||
sTo := g.shardFor("p/b.go::B")
|
||
sTo.mu.Lock()
|
||
divergent := &Edge{From: "p/a.go::A", To: "p/b.go::B", Kind: EdgeCalls, FilePath: "p/a.go", Line: 7, Origin: OriginLSPResolved}
|
||
addEdgeToBucket(sTo.inEdges, sTo.inEdgeKeys, sTo.inEdgeIdx, "p/b.go::B", divergent)
|
||
sTo.mu.Unlock()
|
||
|
||
err := g.VerifyEdgeIdentities()
|
||
require.Error(t, err, "a divergent-Origin edge across adjacency views must be caught")
|
||
assert.Contains(t, err.Error(), "p/a.go::A", "the error must name the offending edge")
|
||
}
|