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

305 lines
9.1 KiB
Go

package indexer
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/search"
)
// inertTestWatcher builds an indexed repo with a single Go file and a
// Watcher wired to it, ready to drive patchGraph directly. The search
// backend is set because the modify path evicts from and adds to it.
func inertTestWatcher(t *testing.T, fileName, content string) (string, *Indexer, *Watcher) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, fileName)
writeTestFile(t, path, content)
g := graph.New()
idx := newTestIndexer(g)
idx.search = search.NewBM25()
idx.SetRootPath(dir)
_, err := idx.Index(dir)
require.NoError(t, err)
w, err := NewWatcher(idx, config.WatchConfig{Enabled: true, DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
return dir, idx, w
}
// TestWatcher_CommentOnlyChangeSkipsReindex is the central proof of
// the content-aware skip: a save that adds only comment lines — which
// shifts every following line number but changes no Function / Type /
// Method — must not trigger a structural reindex. The proof is node
// pointer identity: a reindex evicts and rebuilds the file's nodes,
// minting fresh structs, so if the live node pointer is unchanged
// after the save, no eviction happened.
func TestWatcher_CommentOnlyChangeSkipsReindex(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func Stable() {}
`)
path := filepath.Join(dir, "main.go")
nodes := idx.graph.GetFileNodes("main.go")
var funcID string
for _, n := range nodes {
if n.Kind == graph.KindFunction && n.Name == "Stable" {
funcID = n.ID
}
}
require.NotEmpty(t, funcID, "Stable must be indexed before the edit")
before := idx.graph.GetNode(funcID)
require.NotNil(t, before)
// Comment-only edit: the function and its signature are untouched,
// but every line below the new comments shifts down.
writeTestFile(t, path, `package main
// Stable does nothing at all.
// This comment block is brand new and shifts the line numbers.
func Stable() {}
`)
w.patchGraph(path, ChangeModified)
after := idx.graph.GetNode(funcID)
require.NotNil(t, after, "the function node must still exist")
assert.Same(t, before, after,
"a comment-only change must not evict and rebuild the node — "+
"the structural reindex was supposed to be skipped")
}
// TestWatcher_WhitespaceChangeSkipsReindex proves a pure whitespace
// reflow — blank lines, indentation — is treated as structurally
// inert. Like the comment case, the node pointer must survive.
func TestWatcher_WhitespaceChangeSkipsReindex(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func KeepMe() {}
`)
path := filepath.Join(dir, "main.go")
before := nodeByName(t, idx, "main.go", "KeepMe")
// Whitespace-only edit: extra blank lines, no structural change.
writeTestFile(t, path, `package main
func KeepMe() {}
`)
w.patchGraph(path, ChangeModified)
after := nodeByName(t, idx, "main.go", "KeepMe")
assert.Same(t, before, after,
"a whitespace-only change must skip the structural reindex")
}
// TestWatcher_InertChangeEmitsZeroDeltaEvent checks the skip path
// still reports the save through the events channel and history, but
// with every node/edge delta zero — nothing structural moved.
func TestWatcher_InertChangeEmitsZeroDeltaEvent(t *testing.T) {
dir, _, w := inertTestWatcher(t, "main.go", `package main
func Same() {}
`)
path := filepath.Join(dir, "main.go")
writeTestFile(t, path, `package main
// freshly added doc line
func Same() {}
`)
w.patchGraph(path, ChangeModified)
select {
case ev := <-w.Events():
assert.Equal(t, ChangeModified, ev.Kind)
assert.Zero(t, ev.NodesAdded, "an inert skip adds no nodes")
assert.Zero(t, ev.NodesRemoved, "an inert skip removes no nodes")
assert.Zero(t, ev.EdgesAdded, "an inert skip adds no edges")
assert.Zero(t, ev.EdgesRemoved, "an inert skip removes no edges")
default:
t.Fatal("an inert modify must still publish a change event")
}
history := w.History()
require.Len(t, history, 1)
assert.Equal(t, ChangeModified, history[0].Kind)
}
// TestWatcher_InertChangeFiresCallbackWithUnchangedSymbols verifies
// the symbol-change callback still fires on a skipped save, with the
// before and after symbol sets identical — consumers see a coherent
// no-op rather than no callback at all.
func TestWatcher_InertChangeFiresCallbackWithUnchangedSymbols(t *testing.T) {
dir, _, w := inertTestWatcher(t, "main.go", `package main
func Untouched() {}
`)
path := filepath.Join(dir, "main.go")
var mu sync.Mutex
var gotOld, gotNew []string
calls := 0
w.OnSymbolChange(func(_ string, oldSymbols, newSymbols []*graph.Node) {
mu.Lock()
defer mu.Unlock()
calls++
for _, n := range oldSymbols {
gotOld = append(gotOld, n.Name)
}
for _, n := range newSymbols {
gotNew = append(gotNew, n.Name)
}
})
writeTestFile(t, path, `package main
// a comment, nothing else
func Untouched() {}
`)
w.patchGraph(path, ChangeModified)
mu.Lock()
defer mu.Unlock()
require.Equal(t, 1, calls, "the callback must fire once on an inert save")
assert.Equal(t, gotOld, gotNew,
"an inert save must report identical before/after symbol sets")
assert.Contains(t, gotOld, "Untouched")
}
// TestWatcher_StructuralChangeStillReindexes is the other half of the
// contract: a save that DOES change a structural symbol must reindex
// normally. Renaming the function changes the fingerprint, so the
// skip must not engage — the old symbol is evicted and the new one
// indexed.
func TestWatcher_StructuralChangeStillReindexes(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func OldName() {}
`)
path := filepath.Join(dir, "main.go")
require.NotEmpty(t, idx.graph.FindNodesByName("OldName"))
writeTestFile(t, path, `package main
func NewName() {}
`)
w.patchGraph(path, ChangeModified)
assert.Empty(t, idx.graph.FindNodesByName("OldName"),
"a renamed function must be evicted — the reindex must run")
assert.NotEmpty(t, idx.graph.FindNodesByName("NewName"),
"the renamed function must be indexed under its new name")
select {
case ev := <-w.Events():
assert.Equal(t, ChangeModified, ev.Kind)
assert.NotZero(t, ev.NodesRemoved,
"a structural change must report evicted nodes")
default:
t.Fatal("a structural modify must publish a change event")
}
}
// TestWatcher_AddedSymbolStillReindexes covers the additive case:
// declaring a new function alongside the existing one changes the
// fingerprint (a new tuple appears), so the reindex must run.
func TestWatcher_AddedSymbolStillReindexes(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func First() {}
`)
path := filepath.Join(dir, "main.go")
writeTestFile(t, path, `package main
func First() {}
func Second() {}
`)
w.patchGraph(path, ChangeModified)
assert.NotEmpty(t, idx.graph.FindNodesByName("First"))
assert.NotEmpty(t, idx.graph.FindNodesByName("Second"),
"adding a function must trigger a reindex so the new symbol appears")
}
// TestWatcher_SignatureChangeStillReindexes proves the fingerprint is
// signature-sensitive, not just name-sensitive: changing a function's
// parameter list keeps its name but changes its signature, and that
// must still reindex.
func TestWatcher_SignatureChangeStillReindexes(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func Compute(a int) {}
`)
path := filepath.Join(dir, "main.go")
funcBefore := nodeByName(t, idx, "main.go", "Compute")
// Same name, different signature — a structural change.
writeTestFile(t, path, `package main
func Compute(a int, b string) {}
`)
w.patchGraph(path, ChangeModified)
funcAfter := nodeByName(t, idx, "main.go", "Compute")
assert.NotSame(t, funcBefore, funcAfter,
"a signature change must evict and rebuild the node — reindex must run")
}
// TestWatcher_InertSkipRefreshesMtime checks the skip path advances
// the indexer's recorded mtime past the save, so the adaptive poller
// does not keep re-flagging the structurally-untouched file as stale.
func TestWatcher_InertSkipRefreshesMtime(t *testing.T) {
dir, idx, w := inertTestWatcher(t, "main.go", `package main
func Steady() {}
`)
path := filepath.Join(dir, "main.go")
mtimeBefore := idx.FileMtimes()["main.go"]
require.NotZero(t, mtimeBefore)
// Save the file with a strictly later mtime, comment-only.
future := time.Now().Add(2 * time.Second)
writeTestFile(t, path, `package main
// a new comment
func Steady() {}
`)
require.NoError(t, os.Chtimes(path, future, future))
w.patchGraph(path, ChangeModified)
mtimeAfter := idx.FileMtimes()["main.go"]
assert.Greater(t, mtimeAfter, mtimeBefore,
"an inert skip must restamp the recorded mtime past the save")
}
// nodeByName returns the live graph node for a named symbol in a file,
// failing the test if it is absent.
func nodeByName(t *testing.T, idx *Indexer, relPath, name string) *graph.Node {
t.Helper()
for _, n := range idx.graph.GetFileNodes(relPath) {
if n.Name == name {
return n
}
}
t.Fatalf("node %q not found in %s", name, relPath)
return nil
}