项目文件夹

文件
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 行
29 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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")
}