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

391 lines
15 KiB
Go

package indexer
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/sgtdi/fswatcher"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"golang.org/x/text/unicode/norm"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/pathkey"
"github.com/zzet/gortex/internal/search"
)
// Non-ASCII filename fixtures. The accented-Latin name "café.go" is the
// one with a genuine NFC/NFD split, so wherever a decomposed form is
// needed the test derives it in code (decompose) rather than typing
// it, guaranteeing the two byte forms differ. CJK
// ideographs and Cyrillic letters have no canonical decomposition —
// they are stable in every normal form — so they are written directly
// (as \u escapes, keeping this source file pure-ASCII).
const (
cjkFile = "日本語.go" // 日本語.go
cyrFile = "кириллица.go" // кириллица.go
accentedDir = "résumé" // résumé/ — accented directory
accentFile = "café.go" // café.go
)
// decompose returns the NFD (canonically decomposed) form of s — the
// byte form macOS APFS / HFS+ hands back for a filename.
func decompose(s string) string { return norm.NFD.String(s) }
// goSrc returns a tiny compilable Go file declaring one uniquely-named
// function, so a test can assert the symbol made it into the graph.
func goSrc(funcName string) string {
return "package main\n\nfunc " + funcName + "() {}\n"
}
// fileKindNodes returns only the file-kind nodes the graph holds for
// the given key — used to detect a duplicate file-node leaking after a
// re-index.
func fileKindNodes(g graph.Store, key string) []*graph.Node {
var out []*graph.Node
for _, n := range g.GetFileNodes(key) {
if n.Kind == graph.KindFile {
out = append(out, n)
}
}
return out
}
// fakeModify / fakeDelete build minimal fswatcher events for driving
// handleEvent directly, mirroring fakeCreate in storm_test.go.
func fakeModify(path string) fswatcher.WatchEvent {
return fswatcher.WatchEvent{
Path: path,
Types: []fswatcher.EventType{fswatcher.EventMod},
Time: time.Now(),
}
}
func fakeDelete(path string) fswatcher.WatchEvent {
return fswatcher.WatchEvent{
Path: path,
Types: []fswatcher.EventType{fswatcher.EventRemove},
Time: time.Now(),
}
}
// TestDiscovery_IndexesNonASCIIFilenames proves the bulk index walk
// discovers and indexes files whose names use CJK, Cyrillic, and
// accented-Latin characters, including a file inside an accented
// directory. Each file's symbol must land in the graph.
func TestDiscovery_IndexesNonASCIIFilenames(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, cjkFile), goSrc("CJKFunc"))
writeFile(t, filepath.Join(dir, cyrFile), goSrc("CyrFunc"))
writeFile(t, filepath.Join(dir, accentFile), goSrc("AccentFunc"))
subDir := filepath.Join(dir, accentedDir)
require.NoError(t, os.MkdirAll(subDir, 0o755))
writeFile(t, filepath.Join(subDir, "nested.go"), goSrc("NestedFunc"))
g := graph.New()
idx := newTestIndexer(g)
result, err := idx.Index(dir)
require.NoError(t, err)
assert.Equal(t, 4, result.FileCount, "all four non-ASCII-path files must be discovered")
for _, fn := range []string{"CJKFunc", "CyrFunc", "AccentFunc", "NestedFunc"} {
assert.NotEmptyf(t, g.FindNodesByName(fn), "%s must be indexed", fn)
}
}
// TestDiscovery_NonASCIIFileNodesKeyedCanonically checks that a file
// with a non-ASCII name is reachable through GetFileNodes under the
// canonical (NFC, slash) key — i.e. the key relKey produces. This is
// the contract every other subsystem depends on: if the graph keys a
// non-ASCII file under a non-canonical form, the watcher and the
// incremental reconcile cannot find it.
func TestDiscovery_NonASCIIFileNodesKeyedCanonically(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, cjkFile), goSrc("CJKFunc"))
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
canonicalKey := idx.RelKey(filepath.Join(dir, cjkFile))
require.Equal(t, pathkey.Normalize(cjkFile), canonicalKey,
"relKey must yield the NFC slash-form of the filename")
nodes := g.GetFileNodes(canonicalKey)
assert.NotEmpty(t, nodes, "the CJK file's nodes must be reachable under the canonical key")
}
// TestRelKey_NFCvsNFDConverge is the heart of the cross-platform fix:
// the same file path handed to the indexer in decomposed (macOS) and
// precomposed (git / Linux) Unicode forms must reduce to one graph
// key. Without the NFC fold in relKey the two forms diverge and a
// single file splits across two keys.
func TestRelKey_NFCvsNFDConverge(t *testing.T) {
dir := t.TempDir()
idx := newTestIndexer(graph.New())
idx.SetRootPath(dir)
nfc := pathkey.Normalize(accentFile) // precomposed
nfd := decompose(accentFile) // "e" + combining acute
require.NotEqual(t, nfc, nfd, "fixture invalid: NFC and NFD forms identical")
keyNFC := idx.RelKey(filepath.Join(dir, nfc))
keyNFD := idx.RelKey(filepath.Join(dir, nfd))
assert.Equal(t, keyNFC, keyNFD,
"relKey must fold NFC and NFD spellings of one filename to the same key")
}
// TestIncrementalReindex_NonASCIIFileNoDuplicate is the regression
// test for the duplicate-node bug. A file with a non-ASCII name is
// indexed, then modified and re-indexed incrementally. The graph must
// hold exactly one file-node for it afterwards — if the incremental
// path keyed the file under a different Unicode form than the bulk
// walk, a stale duplicate would survive alongside the fresh node.
func TestIncrementalReindex_NonASCIIFileNoDuplicate(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, cjkFile)
writeFile(t, target, goSrc("Before"))
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
key := idx.RelKey(target)
fileNodesBefore := fileKindNodes(g, key)
require.Len(t, fileNodesBefore, 1, "exactly one file-node after the initial index")
require.NotEmpty(t, g.FindNodesByName("Before"))
// Modify and re-index incrementally.
bumpMtime(t, target, goSrc("After"))
_, err = idx.IncrementalReindex(dir)
require.NoError(t, err)
fileNodesAfter := fileKindNodes(g, key)
assert.Len(t, fileNodesAfter, 1,
"incremental re-index of a non-ASCII file must not leave a duplicate file-node")
assert.NotEmpty(t, g.FindNodesByName("After"), "the new symbol must be indexed")
assert.Empty(t, g.FindNodesByName("Before"), "the old symbol must be evicted")
}
// TestIncrementalReindex_NonASCIIFileNotSpuriouslyDeleted proves an
// unchanged non-ASCII file survives an incremental reconcile. The
// deletion-detection pass intersects the freshly-walked disk set
// against the mtime map; if the two were keyed in different Unicode
// forms, the file would be misclassified as deleted and purged.
func TestIncrementalReindex_NonASCIIFileNotSpuriouslyDeleted(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, cjkFile), goSrc("Survivor"))
writeFile(t, filepath.Join(dir, "ascii.go"), goSrc("AsciiSurvivor"))
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("Survivor"))
// Re-index with nothing changed on disk.
_, err = idx.IncrementalReindex(dir)
require.NoError(t, err)
assert.NotEmpty(t, g.FindNodesByName("Survivor"),
"an untouched non-ASCII file must not be evicted by deletion detection")
assert.NotEmpty(t, g.FindNodesByName("AsciiSurvivor"))
}
// TestEvictFile_NonASCIIFormMismatch is the direct regression test for
// the git-watcher path. The git watcher derives paths from `git diff`
// output (NFC) while the bulk filesystem walk on macOS stores NFD. An
// EvictFile call made with one Unicode form must still evict a file
// the graph indexed under the other — otherwise a branch-switch
// change to a non-ASCII file silently leaves a stale subtree.
func TestEvictFile_NonASCIIFormMismatch(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, accentFile)
writeFile(t, target, goSrc("DoomedFunc"))
g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("DoomedFunc"))
// Evict using an explicitly decomposed (NFD) spelling of the path.
// The graph keyed the file under the canonical NFC form; without
// the fold in EvictFile this call would no-op and leak the nodes.
nfdPath := filepath.Join(dir, decompose(accentFile))
require.NotEqual(t, filepath.Join(dir, pathkey.Normalize(accentFile)), nfdPath,
"fixture invalid: NFC and NFD path spellings identical")
nodesRemoved, _ := idx.EvictFile(nfdPath)
assert.Positive(t, nodesRemoved,
"EvictFile must remove nodes even when handed a different Unicode form than the walk used")
assert.Empty(t, g.FindNodesByName("DoomedFunc"),
"the file's symbol must be gone after the cross-form evict")
}
// TestWatcher_NonASCIIFileModify drives the watcher's event path with
// a non-ASCII-named file and asserts the modify is routed to the right
// graph node. handleEvent is exercised directly (no real fsnotify
// backend) for determinism, mirroring the storm-mode unit tests.
func TestWatcher_NonASCIIFileModify(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, cyrFile)
writeFile(t, target, goSrc("OriginalCyr"))
g := graph.New()
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.SetRootPath(dir)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("OriginalCyr"))
w, err := NewWatcher(idx, config.WatchConfig{DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
// Modify on disk, then deliver a synthetic modify event.
writeFile(t, target, goSrc("ModifiedCyr"))
w.handleEvent(fakeModify(target))
ev := waitForEvent(t, w, 2*time.Second)
assert.Equal(t, ChangeModified, ev.Kind)
assert.NotEmpty(t, g.FindNodesByName("ModifiedCyr"),
"a modify of a non-ASCII-named file must reach the graph")
assert.Empty(t, g.FindNodesByName("OriginalCyr"))
}
// TestWatcher_NonASCIIFileDelete proves a delete event for a non-ASCII
// file evicts the right node — the watcher derives the relative key
// from the event path, which must fold to the graph's stored key.
func TestWatcher_NonASCIIFileDelete(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, cjkFile)
writeFile(t, target, goSrc("DeleteMe"))
g := graph.New()
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.SetRootPath(dir)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("DeleteMe"))
require.NoError(t, os.Remove(target))
w, err := NewWatcher(idx, config.WatchConfig{DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
w.handleEvent(fakeDelete(target))
ev := waitForEvent(t, w, 2*time.Second)
assert.Equal(t, ChangeDeleted, ev.Kind)
assert.Empty(t, g.FindNodesByName("DeleteMe"),
"a delete of a non-ASCII-named file must evict its node")
}
// TestWatcher_NonASCIIEventPathCanonicalised checks the watcher's path
// normalisation: an event path delivered in decomposed (NFD) form —
// the form macOS FSEvents produces — must be folded so a downstream
// modify lands on the NFC-keyed node rather than missing it.
func TestWatcher_NonASCIIEventPathCanonicalised(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, accentFile)
writeFile(t, target, goSrc("EventOriginal"))
g := graph.New()
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.SetRootPath(dir)
_, err := idx.Index(dir)
require.NoError(t, err)
require.NotEmpty(t, g.FindNodesByName("EventOriginal"))
w, err := NewWatcher(idx, config.WatchConfig{DebounceMs: 10}, zap.NewNop())
require.NoError(t, err)
// Deliver the event under an explicitly decomposed path. The
// graph node was keyed NFC by the index walk; the watcher must
// reconcile the form before dispatching.
writeFile(t, target, goSrc("EventModified"))
nfdEventPath := filepath.Join(dir, decompose(accentFile))
require.NotEqual(t, target, nfdEventPath, "fixture invalid: NFC and NFD paths identical")
w.handleEvent(fakeModify(nfdEventPath))
ev := waitForEvent(t, w, 2*time.Second)
assert.Equal(t, ChangeModified, ev.Kind)
assert.NotEmpty(t, g.FindNodesByName("EventModified"),
"an NFD-form event path must still route the change to the NFC-keyed node")
assert.Empty(t, g.FindNodesByName("EventOriginal"))
}
// TestGitWatcher_NonASCIIFileBranchSwitch is the end-to-end proof for
// the git watcher: a real repo whose feature branch adds a CJK-named
// file must, on checkout, have that file's symbol indexed. git emits
// the path in NFC; the reconcile must key it consistently with the
// rest of the graph.
func TestGitWatcher_NonASCIIFileBranchSwitch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git binary not available in PATH")
}
repoDir := t.TempDir()
runGit(t, repoDir, "init", "-q", "-b", "main")
runGit(t, repoDir, "config", "user.email", "test@example.com")
runGit(t, repoDir, "config", "user.name", "Test")
runGit(t, repoDir, "config", "commit.gpgsign", "false")
// main: an ASCII file only.
writeFile(t, filepath.Join(repoDir, "main.go"), goSrc("MainFunc"))
runGit(t, repoDir, "add", ".")
runGit(t, repoDir, "commit", "-q", "-m", "main: MainFunc")
// feature: adds a CJK-named file.
runGit(t, repoDir, "checkout", "-q", "-b", "feature")
writeFile(t, filepath.Join(repoDir, cjkFile), goSrc("FeatureFunc"))
runGit(t, repoDir, "add", "-A")
runGit(t, repoDir, "commit", "-q", "-m", "feature: add CJK-named file")
runGit(t, repoDir, "checkout", "-q", "main")
g := graph.New()
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(repoDir)
_, err := idx.IndexCtx(testCtx(), repoDir)
require.NoError(t, err)
require.Empty(t, g.FindNodesByName("FeatureFunc"), "main branch lacks the CJK file")
gw, err := NewGitWatcher(repoDir, idx, zap.NewNop())
require.NoError(t, err)
gw.debounce = 50 * time.Millisecond
drained := make(chan int, 1)
gw.drained = func(n int) {
select {
case drained <- n:
default:
}
}
require.NoError(t, gw.Start())
t.Cleanup(func() { _ = gw.Stop() })
runGit(t, repoDir, "checkout", "-q", "feature")
select {
case n := <-drained:
assert.GreaterOrEqual(t, n, 1, "the reconcile must touch the CJK file")
case <-time.After(5 * time.Second):
t.Fatal("git watcher did not reconcile the branch switch within timeout")
}
assert.NotEmpty(t, g.FindNodesByName("FeatureFunc"),
"after checkout, the CJK-named file's symbol must be indexed")
canonicalKey := idx.RelKey(filepath.Join(repoDir, cjkFile))
assert.NotEmpty(t, g.GetFileNodes(canonicalKey),
"the CJK file's nodes must be reachable under the canonical key")
require.False(t, strings.Contains(canonicalKey, "\\"), "graph key must use forward slashes")
}