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

651 lines
19 KiB
Go

package indexer
import (
"os"
"path/filepath"
"strings"
"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/parser"
"github.com/zzet/gortex/internal/parser/languages"
)
func setupTestDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
// main.go
writeFile(t, filepath.Join(dir, "main.go"), `package main
import "fmt"
func main() {
fmt.Println("hello")
helper()
}
func helper() {}
`)
// pkg/util.go
pkgDir := filepath.Join(dir, "pkg")
require.NoError(t, os.MkdirAll(pkgDir, 0o755))
writeFile(t, filepath.Join(pkgDir, "util.go"), `package pkg
type Config struct {
Port int
}
func NewConfig() *Config {
return &Config{Port: 8080}
}
`)
// vendor/ should be excluded.
vendorDir := filepath.Join(dir, "vendor")
require.NoError(t, os.MkdirAll(vendorDir, 0o755))
writeFile(t, filepath.Join(vendorDir, "lib.go"), `package vendor
func Ignored() {}
`)
return dir
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
func newTestIndexer(g graph.Store) *Indexer {
reg := parser.NewRegistry()
reg.Register(languages.NewGoExtractor())
cfg := config.Default().Index
cfg.Workers = 2
return New(g, reg, cfg, zap.NewNop())
}
// newTestIndexerGoJava registers both the Go and Java extractors — used by
// the cross-language Temporal join tests.
func newTestIndexerGoJava(g graph.Store) *Indexer {
reg := parser.NewRegistry()
reg.Register(languages.NewGoExtractor())
reg.Register(languages.NewJavaExtractor())
cfg := config.Default().Index
cfg.Workers = 2
return New(g, reg, cfg, zap.NewNop())
}
func TestIndex_SingleGoFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 1, result.FileCount)
assert.Greater(t, result.NodeCount, 0)
assert.Greater(t, result.EdgeCount, 0)
}
// TestIndexCtx_FlagsFullReindexForEnrichForce locks the wiring the enrichment
// completion marker depends on: a whole-repo IndexCtx re-parse evicts and
// re-creates every node/edge (dropping the repo's LSP hover-enrichment edges),
// so it must flag fullReindexed. runDeferredEnrich threads that into
// RepoEnrichState.Force to re-run enrichment past the marker gate even at an
// unchanged clean HEAD — without it a full re-track durably drops the repo's
// enrichment edges.
func TestIndexCtx_FlagsFullReindexForEnrichForce(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Hello() {}\n")
g := graph.New()
idx := newTestIndexer(g)
require.False(t, idx.fullReindexed.Load(), "a fresh indexer has re-parsed nothing")
_, err := idx.Index(dir)
require.NoError(t, err)
assert.True(t, idx.fullReindexed.Load(),
"a whole-repo re-parse must flag fullReindexed so deferred enrichment forces past the completion marker")
}
// TestIncrementalReindexPaths_LeavesFullReindexClear is the boundary: a scoped
// incremental re-parse touches only the changed files and leaves every other
// file's enrichment edges intact, so it must NOT force a whole-repo re-enrich
// (which would re-run the multi-minute hover pass on every scoped save). It
// must, however, flag reparsedThisRun — the re-parsed file's own hover edges
// WERE evicted, so the deferred pass still has to run past the completion
// marker, just scoped to that file rather than the whole repo.
func TestIncrementalReindexPaths_LeavesFullReindexClear(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "main.go")
writeFile(t, main, "package main\n\nfunc Hello() {}\n")
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.IncrementalReindexPaths(dir, []string{main})
require.NoError(t, err)
require.Positive(t, result.StaleFileCount, "the fresh file must re-parse")
assert.False(t, idx.fullReindexed.Load(),
"a scoped incremental re-parse must leave fullReindexed clear")
assert.True(t, idx.reparsedThisRun.Load(),
"a scoped incremental re-parse still evicted the changed file's hover edges, so it must flag reparsedThisRun to force the deferred enrich past the completion marker")
}
// TestIncrementalReindexPaths_MtimeBumpFlagsReparse mirrors the durable-loss
// scenario: after a repo is indexed and its enrichment marker recorded at a
// clean HEAD, a git checkout-and-back / stash-pop / no-op save bumps a file's
// mtime without changing its content or moving HEAD. The scoped warm-restart
// route re-parses the mtime-drifted file (dropping its hover edges), so it must
// flag reparsedThisRun — otherwise runDeferredEnrich builds Force=false, the
// completion marker still matches on the clean tree, and the re-parsed file's
// LSP edges stay durably gone.
func TestIncrementalReindexPaths_MtimeBumpFlagsReparse(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "main.go")
const content = "package main\n\nfunc Hello() {}\n"
writeFile(t, main, content)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
require.False(t, idx.reparsedThisRun.Load(),
"reparsedThisRun is a scoped-path flag; a full Index uses fullReindexed instead")
// Same bytes, later mtime — a touch / checkout-roundtrip, content unchanged.
bumpMtime(t, main, content)
result, err := idx.IncrementalReindexPaths(dir, []string{main})
require.NoError(t, err)
require.Positive(t, result.StaleFileCount,
"an mtime bump must classify the content-identical file as stale and re-parse it")
assert.True(t, idx.reparsedThisRun.Load(),
"a scoped re-parse of an mtime-drifted file must force the deferred enrich past the completion marker")
}
// TestIncrementalReindex_MtimeBumpFlagsReparse is the whole-root sibling of
// TestIncrementalReindexPaths_MtimeBumpFlagsReparse: the non-scoped incremental
// path also re-parses mtime-drifted files and must flag reparsedThisRun.
func TestIncrementalReindex_MtimeBumpFlagsReparse(t *testing.T) {
dir := t.TempDir()
main := filepath.Join(dir, "main.go")
const content = "package main\n\nfunc Hello() {}\n"
writeFile(t, main, content)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
require.False(t, idx.reparsedThisRun.Load())
bumpMtime(t, main, content)
result, err := idx.IncrementalReindex(dir)
require.NoError(t, err)
require.Positive(t, result.StaleFileCount)
assert.True(t, idx.reparsedThisRun.Load(),
"a whole-root incremental re-parse must also force the deferred enrich past the completion marker")
}
// TestIncrementalReindex_NoChangeLeavesReparseClear guards the other side: a
// zero-change reconcile re-parses nothing, so reparsedThisRun stays clear and an
// unchanged repo at a current marker is legitimately skipped.
func TestIncrementalReindex_NoChangeLeavesReparseClear(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Hello() {}\n")
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
result, err := idx.IncrementalReindex(dir)
require.NoError(t, err)
require.Zero(t, result.StaleFileCount, "nothing changed on disk")
assert.False(t, idx.reparsedThisRun.Load(),
"a zero-change reconcile must not force re-enrichment")
}
func TestIndex_MultipleFiles(t *testing.T) {
dir := setupTestDir(t)
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 2, result.FileCount) // main.go + pkg/util.go (vendor excluded)
assert.Greater(t, result.NodeCount, 4)
}
func TestIndex_ExcludePatterns(t *testing.T) {
dir := setupTestDir(t)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
// Vendor file should not be indexed.
nodes := g.FindNodesByName("Ignored")
assert.Empty(t, nodes)
}
func TestIndex_RipgrepIgnoreFiles(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), "package main\n\nfunc Kept() {}\n")
writeFile(t, filepath.Join(dir, "gen.go"), "package main\n\nfunc FromGen() {}\n")
writeFile(t, filepath.Join(dir, ".ignore"), "gen.go\n")
// A nested directory carries its own ripgrep ignore file; it must
// scope to that subtree only.
sub := filepath.Join(dir, "sub")
require.NoError(t, os.MkdirAll(sub, 0o755))
writeFile(t, filepath.Join(sub, "keep.go"), "package sub\n\nfunc SubKept() {}\n")
writeFile(t, filepath.Join(sub, "drop.go"), "package sub\n\nfunc SubDropped() {}\n")
writeFile(t, filepath.Join(sub, ".rgignore"), "drop.go\n")
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
assert.NotEmpty(t, g.FindNodesByName("Kept"), "main.go should be indexed")
assert.NotEmpty(t, g.FindNodesByName("SubKept"), "sub/keep.go should be indexed")
assert.Empty(t, g.FindNodesByName("FromGen"), ".ignore should exclude gen.go")
assert.Empty(t, g.FindNodesByName("SubDropped"), "sub/.rgignore should exclude sub/drop.go")
}
func TestEdgeSanityViolated(t *testing.T) {
// A populated index with no edges trips the check.
broken := &IndexResult{FileCount: 5, NodeCount: 40, EdgeCount: 0}
assert.True(t, broken.EdgeSanityViolated(),
"files+nodes but zero edges should violate the edge-sanity check")
// A healthy index does not.
healthy := &IndexResult{FileCount: 5, NodeCount: 40, EdgeCount: 60}
assert.False(t, healthy.EdgeSanityViolated(),
"an index with edges must not trip the check")
// An empty repo (no files) is not a violation — nothing to index.
empty := &IndexResult{FileCount: 0, NodeCount: 0, EdgeCount: 0}
assert.False(t, empty.EdgeSanityViolated(),
"an empty repo must not trip the check")
// nil is safe.
var nilResult *IndexResult
assert.False(t, nilResult.EdgeSanityViolated(), "nil result must not trip the check")
}
func TestGrepText(t *testing.T) {
dir := setupTestDir(t)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
// A literal present in main.go.
hits := idx.GrepText("func helper", 0)
require.NotEmpty(t, hits, "func helper should be found")
found := false
for _, h := range hits {
if h.Path == "main.go" {
found = true
}
}
assert.True(t, found, "func helper should be located in main.go")
// A literal present nowhere.
assert.Empty(t, idx.GrepText("nonexistent_zzz_literal", 0))
// After an incremental reindex the warm cache is rebuilt, so a
// freshly added literal is visible.
bumpMtime(t, filepath.Join(dir, "main.go"),
"package main\n\nfunc main() {}\n\nfunc BrandNew() {}\n")
_, err = idx.IncrementalReindex(dir)
require.NoError(t, err)
assert.NotEmpty(t, idx.GrepText("func BrandNew", 0),
"GrepText must reflect content added by an incremental reindex")
}
func TestIndex_EdgeSanityHolds(t *testing.T) {
// A real index of even a tiny repo produces edges, so the
// edge-sanity check passes — guards the invariant against false
// positives on legitimate indexes.
dir := setupTestDir(t)
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.False(t, result.EdgeSanityViolated(),
"a normal index must not trip the edge-sanity check (files=%d nodes=%d edges=%d)",
result.FileCount, result.NodeCount, result.EdgeCount)
// A graph built only through the indexer's sanctioned mutation
// paths is provenance-consistent: every edge's out- and in-edge
// adjacency views agree on its identity hash.
require.NoError(t, g.VerifyEdgeIdentities(),
"a normally-indexed graph must have internally consistent edge identities")
}
func TestIndex_UnsupportedFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "readme.txt"), "hello world")
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 0, result.FileCount)
}
func TestIndexFile_SingleFile(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "main.go")
writeFile(t, filePath, `package main
func Original() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
origNodes := g.FindNodesByName("Original")
require.Len(t, origNodes, 1)
// Modify and re-index single file.
writeFile(t, filePath, `package main
func Replaced() {}
`)
require.NoError(t, idx.IndexFile(filePath))
assert.Empty(t, g.FindNodesByName("Original"))
assert.Len(t, g.FindNodesByName("Replaced"), 1)
}
func TestMtimeTracking(t *testing.T) {
dir := t.TempDir()
goFile := filepath.Join(dir, "main.go")
writeFile(t, goFile, `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 1, result.FileCount)
// FileMtimes should be populated with the indexed file.
mtimes := idx.FileMtimes()
assert.NotEmpty(t, mtimes, "fileMtimes should be populated after Index()")
assert.Contains(t, mtimes, "main.go", "fileMtimes should contain the indexed file")
assert.Greater(t, mtimes["main.go"], int64(0), "mtime should be a positive unix nano value")
}
func TestMtimeIsStale_FreshFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
// A just-indexed file should not be stale.
assert.False(t, idx.IsStale("main.go"), "file should not be stale immediately after indexing")
}
func TestMtimeIsStale_ModifiedFile(t *testing.T) {
dir := t.TempDir()
goFile := filepath.Join(dir, "main.go")
writeFile(t, goFile, `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
// Modify the file after indexing — ensure mtime actually changes.
// Some filesystems have coarse mtime resolution, so we sleep briefly.
time.Sleep(50 * time.Millisecond)
writeFile(t, goFile, `package main
func HelloModified() {}
`)
assert.True(t, idx.IsStale("main.go"), "file should be stale after modification")
}
func TestMtimeIsStale_UnknownFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
// A file not in the index should be treated as stale.
assert.True(t, idx.IsStale("unknown.go"), "unknown file should be treated as stale")
}
func TestMtimeUpdatedAfterIndexFile(t *testing.T) {
dir := t.TempDir()
goFile := filepath.Join(dir, "main.go")
writeFile(t, goFile, `package main
func Original() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
originalMtime := idx.FileMtimes()["main.go"]
// Modify and re-index the single file.
time.Sleep(50 * time.Millisecond)
writeFile(t, goFile, `package main
func Replaced() {}
`)
require.NoError(t, idx.IndexFile(goFile))
updatedMtime := idx.FileMtimes()["main.go"]
assert.Greater(t, updatedMtime, originalMtime, "mtime should be updated after IndexFile")
assert.False(t, idx.IsStale("main.go"), "file should not be stale after re-indexing")
}
func TestEvictFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Foo() {}
`)
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("Foo"))
n, e := idx.EvictFile(filepath.Join(dir, "main.go"))
assert.Greater(t, n, 0)
assert.Greater(t, e, 0)
assert.Empty(t, g.FindNodesByName("Foo"))
}
func TestIndex_WithRepoPrefix(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
idx.SetRepoPrefix("myrepo")
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 1, result.FileCount)
assert.Greater(t, result.NodeCount, 0)
// Verify node IDs are prefixed.
nodes := g.FindNodesByName("Hello")
require.Len(t, nodes, 1)
assert.Equal(t, "myrepo/main.go::Hello", nodes[0].ID)
assert.Equal(t, "myrepo/main.go", nodes[0].FilePath)
assert.Equal(t, "myrepo", nodes[0].RepoPrefix)
// Verify file node is also prefixed.
fileNodes := g.GetFileNodes("myrepo/main.go")
assert.NotEmpty(t, fileNodes)
// Verify repo index is populated.
repoNodes := g.GetRepoNodes("myrepo")
assert.NotEmpty(t, repoNodes)
for _, n := range repoNodes {
assert.Equal(t, "myrepo", n.RepoPrefix)
}
}
func TestIndex_WithoutRepoPrefix_BackwardCompatible(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
// No SetRepoPrefix — single-repo mode.
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 1, result.FileCount)
nodes := g.FindNodesByName("Hello")
require.Len(t, nodes, 1)
assert.Equal(t, "main.go::Hello", nodes[0].ID)
assert.Equal(t, "main.go", nodes[0].FilePath)
assert.Equal(t, "", nodes[0].RepoPrefix)
}
func TestIndexFile_WithRepoPrefix(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "main.go")
writeFile(t, filePath, `package main
func Original() {}
`)
g := graph.New()
idx := newTestIndexer(g)
idx.SetRepoPrefix("myrepo")
_, err := idx.Index(dir)
require.NoError(t, err)
origNodes := g.FindNodesByName("Original")
require.Len(t, origNodes, 1)
assert.Equal(t, "myrepo/main.go::Original", origNodes[0].ID)
assert.Equal(t, "myrepo", origNodes[0].RepoPrefix)
// Modify and re-index single file.
writeFile(t, filePath, `package main
func Replaced() {}
`)
require.NoError(t, idx.IndexFile(filePath))
assert.Empty(t, g.FindNodesByName("Original"))
replaced := g.FindNodesByName("Replaced")
require.Len(t, replaced, 1)
assert.Equal(t, "myrepo/main.go::Replaced", replaced[0].ID)
assert.Equal(t, "myrepo", replaced[0].RepoPrefix)
}
func TestEvictFile_WithRepoPrefix(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Foo() {}
`)
g := graph.New()
idx := newTestIndexer(g)
idx.SetRepoPrefix("myrepo")
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("Foo"))
n, e := idx.EvictFile(filepath.Join(dir, "main.go"))
assert.Greater(t, n, 0)
assert.Greater(t, e, 0)
assert.Empty(t, g.FindNodesByName("Foo"))
}
func TestRepoPrefix_EdgesArePrefixed(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
g := graph.New()
idx := newTestIndexer(g)
idx.SetRepoPrefix("myrepo")
_, err := idx.Index(dir)
require.NoError(t, err)
// All edges should have prefixed From/To and FilePath.
edges := g.AllEdges()
for _, e := range edges {
assert.True(t, strings.HasPrefix(e.From, "myrepo/"),
"edge From should be prefixed: %s", e.From)
assert.True(t, strings.HasPrefix(e.To, "myrepo/"),
"edge To should be prefixed: %s", e.To)
assert.True(t, strings.HasPrefix(e.FilePath, "myrepo/"),
"edge FilePath should be prefixed: %s", e.FilePath)
}
}
func TestRepoPrefix_SetterGetter(t *testing.T) {
g := graph.New()
idx := newTestIndexer(g)
assert.Equal(t, "", idx.RepoPrefix())
idx.SetRepoPrefix("myrepo")
assert.Equal(t, "myrepo", idx.RepoPrefix())
}