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

662 lines
29 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}