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
305 lines
9.1 KiB
Go
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
|
|
}
|