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

943 lines
30 KiB
Go

package indexer
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"pgregory.net/rapid"
"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"
"github.com/zzet/gortex/internal/search"
)
// newTestConfigManager creates a ConfigManager with an empty GlobalConfig
// pointing at a temp file so Save() doesn't touch the real config.
func newTestConfigManager(t *testing.T) *config.ConfigManager {
t.Helper()
tmpFile := filepath.Join(t.TempDir(), "config.yaml")
cm, err := config.NewConfigManager(tmpFile)
require.NoError(t, err)
return cm
}
// newTestRegistry returns a parser registry with Go support.
func newTestRegistry() *parser.Registry {
reg := parser.NewRegistry()
reg.Register(languages.NewGoExtractor())
return reg
}
// setupRepoDir creates a temp directory with a Go file for testing.
func setupRepoDir(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
writeFile(t, filepath.Join(dir, "main.go"), `package main
func Hello() {}
`)
return dir
}
func TestNewMultiIndexer(t *testing.T) {
g := graph.New()
reg := newTestRegistry()
s := search.NewBM25()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, reg, s, cm, zap.NewNop())
require.NotNil(t, mi)
assert.False(t, mi.IsMultiRepo())
assert.Empty(t, mi.AllMetadata())
assert.Same(t, g, mi.Graph())
assert.Same(t, s, mi.Search())
}
func TestMultiIndexer_IndexAll_SingleRepo(t *testing.T) {
dir := setupRepoDir(t, "myrepo")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{{Path: dir, Name: "myrepo"}},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
results, err := mi.IndexAll()
require.NoError(t, err)
require.Contains(t, results, "myrepo")
res := results["myrepo"]
assert.Greater(t, res.NodeCount, 0)
assert.Greater(t, res.FileCount, 0)
// Single repo: should NOT be multi-repo mode.
assert.False(t, mi.IsMultiRepo())
// Nodes should NOT have repo prefix in single-repo mode (backward compat).
for _, n := range g.AllNodes() {
assert.Empty(t, n.RepoPrefix, "single-repo nodes should not have RepoPrefix")
}
// Metadata should be populated.
meta := mi.GetMetadata("myrepo")
require.NotNil(t, meta)
assert.Equal(t, "myrepo", meta.RepoPrefix)
assert.Equal(t, dir, meta.RootPath)
assert.Greater(t, meta.FileCount, 0)
}
func TestMultiIndexer_IndexAll_MultiRepo(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
results, err := mi.IndexAll()
require.NoError(t, err)
assert.Len(t, results, 2)
assert.Contains(t, results, "repo-a")
assert.Contains(t, results, "repo-b")
// Multi-repo mode.
assert.True(t, mi.IsMultiRepo())
// Nodes should have repo prefix set.
for _, n := range g.AllNodes() {
assert.NotEmpty(t, n.RepoPrefix, "multi-repo nodes should have RepoPrefix")
assert.Contains(t, []string{"repo-a", "repo-b"}, n.RepoPrefix)
}
// Both repos should have metadata.
assert.NotNil(t, mi.GetMetadata("repo-a"))
assert.NotNil(t, mi.GetMetadata("repo-b"))
assert.Len(t, mi.AllMetadata(), 2)
}
func TestMultiIndexer_IndexAll_SingleRepoLoadsWorkspaceExclude(t *testing.T) {
dir := setupRepoDir(t, "myrepo")
require.NoError(t, os.MkdirAll(filepath.Join(dir, "ignored"), 0o755))
writeFile(t, filepath.Join(dir, "ignored", "ignored.go"), "package main\nfunc Ignored() {}\n")
writeFile(t, filepath.Join(dir, ".gortex.yaml"), "workspace: shared\nproject: app\nexclude:\n - ignored/**\n")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: dir, Name: "myrepo"}}}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
for _, n := range g.AllNodes() {
assert.NotContains(t, n.FilePath, "ignored/ignored.go")
assert.NotContains(t, n.ID, "Ignored")
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
assert.Equal(t, "shared", n.WorkspaceID)
assert.Equal(t, "app", n.ProjectID)
}
}
}
func TestMultiIndexer_IndexAll_MultiRepoLoadsWorkspaceExclude(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
require.NoError(t, os.MkdirAll(filepath.Join(repoA, "ignored"), 0o755))
writeFile(t, filepath.Join(repoA, "ignored", "ignored.go"), "package main\nfunc Ignored() {}\n")
writeFile(t, filepath.Join(repoA, ".gortex.yaml"), "workspace: shared\nproject: api\nexclude:\n - ignored/**\n")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
for _, n := range g.AllNodes() {
assert.NotContains(t, n.FilePath, "ignored/ignored.go")
assert.NotContains(t, n.ID, "Ignored")
if n.RepoPrefix == "repo-a" && n.Kind != graph.KindFile && n.Kind != graph.KindImport {
assert.Equal(t, "shared", n.WorkspaceID)
assert.Equal(t, "api", n.ProjectID)
}
}
}
func TestMultiIndexer_IndexRepo(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
writeFile(t, filepath.Join(repoA, ".gortex.yaml"), "workspace: shared\nproject: api\n")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
nodesBefore := g.NodeCount()
repoBNodes := len(g.GetRepoNodes("repo-b"))
// Re-index repo-a.
result, err := mi.IndexRepo("repo-a")
require.NoError(t, err)
assert.Greater(t, result.NodeCount, 0)
// Repo B should be unchanged.
assert.Equal(t, repoBNodes, len(g.GetRepoNodes("repo-b")))
// Total should be roughly the same (re-indexed, not duplicated).
assert.InDelta(t, nodesBefore, g.NodeCount(), 2)
for _, n := range g.GetRepoNodes("repo-a") {
if n.Kind != graph.KindFile && n.Kind != graph.KindImport {
assert.Equal(t, "shared", n.WorkspaceID)
assert.Equal(t, "api", n.ProjectID)
}
}
}
func TestMultiIndexer_IndexRepo_NotFound(t *testing.T) {
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err := mi.IndexRepo("nonexistent")
assert.Error(t, err)
assert.Contains(t, err.Error(), "repository not found")
}
func TestMultiIndexer_TrackRepo(t *testing.T) {
dir := setupRepoDir(t, "tracked")
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
result, err := mi.TrackRepo(config.RepoEntry{Path: dir, Name: "tracked"})
require.NoError(t, err)
require.NotNil(t, result)
assert.Greater(t, result.NodeCount, 0)
// Should be in metadata.
meta := mi.GetMetadata("tracked")
require.NotNil(t, meta)
assert.Equal(t, "tracked", meta.RepoPrefix)
assert.Equal(t, dir, meta.RootPath)
// Track again — should return nil (already tracked).
result2, err := mi.TrackRepo(config.RepoEntry{Path: dir, Name: "tracked"})
require.NoError(t, err)
assert.Nil(t, result2)
}
// TestMultiIndexer_TrackRepo_SearchSpansAllRepos verifies that scoping
// buildSearchIndex to the current repo (the perf fix that drops the
// O(N²) re-index of every prior repo's nodes on every TrackRepo call)
// does not regress search recall. After three repos are tracked, the
// shared search backend must still find symbols defined in the first,
// second, and third repos — earlier-tracked entries are not lost
// because each repo's TrackRepo pass already added its own nodes.
func TestMultiIndexer_TrackRepo_SearchSpansAllRepos(t *testing.T) {
mkRepo := func(name, symbol string) string {
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
writeFile(t, filepath.Join(dir, "main.go"), fmt.Sprintf("package main\n\nfunc %s() {}\n", symbol))
return dir
}
dirA := mkRepo("repo-aaa", "AlphaSymbolUnique")
dirB := mkRepo("repo-bbb", "BetaSymbolUnique")
dirC := mkRepo("repo-ccc", "GammaSymbolUnique")
// Pre-populate the global config so willBeMultiRepo trips on the
// first TrackRepo too — that's the production warmup-loop path.
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{Repos: []config.RepoEntry{
{Path: dirA, Name: "repo-aaa"},
{Path: dirB, Name: "repo-bbb"},
{Path: dirC, Name: "repo-ccc"},
}}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
for _, e := range []config.RepoEntry{
{Path: dirA, Name: "repo-aaa"},
{Path: dirB, Name: "repo-bbb"},
{Path: dirC, Name: "repo-ccc"},
} {
_, err := mi.TrackRepo(e)
require.NoError(t, err)
}
// Query the camelCase-split tokens individually — that's how the
// BM25 backend stores them at Add time, and Search.TokenizeQuery
// doesn't perform the same camelCase split.
for _, want := range []struct{ query, prefix string }{
{"alpha", "repo-aaa"},
{"beta", "repo-bbb"},
{"gamma", "repo-ccc"},
} {
hits := mi.Search().Search(want.query, 10)
require.NotEmpty(t, hits, "shared search backend must find %q after all repos tracked", want.query)
assert.True(t, strings.HasPrefix(hits[0].ID, want.prefix+"/"),
"top hit for %q should belong to %s, got %s", want.query, want.prefix, hits[0].ID)
}
}
// TestMultiIndexer_TrackRepo_EmptyAfterPopulated regresses the bug
// where IndexResult/RepoMetadata stamped the *whole multi-repo graph*
// counts onto an empty repo at TrackRepo time. A second tracked repo
// that contributed zero source files used to come back with the same
// node count as the populated repo because IndexResult.NodeCount was
// graph.NodeCount() rather than the per-repo contribution. Downstream
// daemon-status code multiplied search backend bytes by share = 1 for
// every empty row, attributing the entire workspace search budget to
// each empty repo. The fix: IndexResult counts come from
// graph.RepoMemoryEstimate(prefix) when repoPrefix is non-empty, and
// daemon_controller.Status no longer falls back to meta when
// RepoStats has no entry for the prefix.
func TestMultiIndexer_TrackRepo_EmptyAfterPopulated(t *testing.T) {
populated := setupRepoDir(t, "populated")
empty := filepath.Join(t.TempDir(), "empty")
require.NoError(t, os.MkdirAll(empty, 0o755))
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
first, err := mi.TrackRepo(config.RepoEntry{Path: populated, Name: "populated"})
require.NoError(t, err)
require.NotNil(t, first)
require.Greater(t, first.NodeCount, 0, "populated repo must contribute nodes")
globalNodesBefore := g.NodeCount()
require.Greater(t, globalNodesBefore, 0)
second, err := mi.TrackRepo(config.RepoEntry{Path: empty, Name: "empty"})
require.NoError(t, err)
require.NotNil(t, second)
// Per-repo IndexResult must not echo the workspace-wide graph total.
assert.Equal(t, 0, second.NodeCount,
"empty repo IndexResult.NodeCount must be 0, got %d (global graph has %d)",
second.NodeCount, globalNodesBefore)
assert.Equal(t, 0, second.EdgeCount,
"empty repo IndexResult.EdgeCount must be 0, got %d", second.EdgeCount)
// RepoMetadata is stamped from IndexResult, so it must agree.
emptyMeta := mi.GetMetadata("empty")
require.NotNil(t, emptyMeta)
assert.Equal(t, 0, emptyMeta.NodeCount, "empty repo metadata must record 0 nodes")
assert.Equal(t, 0, emptyMeta.EdgeCount, "empty repo metadata must record 0 edges")
// And the populated repo's metadata must reflect only its own
// contribution, not its size + everything that came after.
populatedMeta := mi.GetMetadata("populated")
require.NotNil(t, populatedMeta)
assert.Greater(t, populatedMeta.NodeCount, 0)
assert.LessOrEqual(t, populatedMeta.NodeCount, globalNodesBefore,
"populated repo metadata must not exceed graph total at its track time")
}
func TestMultiIndexer_TrackRepo_InvalidPath(t *testing.T) {
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err := mi.TrackRepo(config.RepoEntry{Path: "/nonexistent/path/xyz"})
assert.Error(t, err)
assert.Contains(t, err.Error(), "path does not exist")
}
func TestMultiIndexer_TrackRepo_NotADirectory(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "file.txt")
require.NoError(t, os.WriteFile(tmpFile, []byte("hello"), 0o644))
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err := mi.TrackRepo(config.RepoEntry{Path: tmpFile})
assert.Error(t, err)
assert.Contains(t, err.Error(), "not a directory")
}
func TestMultiIndexer_UntrackRepo(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
repoBNodesBefore := len(g.GetRepoNodes("repo-b"))
assert.Greater(t, len(g.GetRepoNodes("repo-a")), 0)
// Untrack repo-a.
nodesRemoved, edgesRemoved := mi.UntrackRepo("repo-a")
assert.Greater(t, nodesRemoved, 0)
_ = edgesRemoved
// Repo-a should be gone.
assert.Nil(t, mi.GetMetadata("repo-a"))
assert.Empty(t, g.GetRepoNodes("repo-a"))
// Repo-b should be unchanged.
assert.Equal(t, repoBNodesBefore, len(g.GetRepoNodes("repo-b")))
}
func TestMultiIndexer_UntrackRepo_NotFound(t *testing.T) {
g := graph.New()
cm := newTestConfigManager(t)
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
nodesRemoved, edgesRemoved := mi.UntrackRepo("nonexistent")
assert.Equal(t, 0, nodesRemoved)
assert.Equal(t, 0, edgesRemoved)
}
func TestMultiIndexer_RepoForFile(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
// File in repo-a.
assert.Equal(t, "repo-a", mi.RepoForFile(filepath.Join(repoA, "main.go")))
// File in repo-b.
assert.Equal(t, "repo-b", mi.RepoForFile(filepath.Join(repoB, "main.go")))
// Unknown file.
assert.Empty(t, mi.RepoForFile("/some/random/path.go"))
}
func TestMultiIndexer_GetIndexer(t *testing.T) {
dir := setupRepoDir(t, "myrepo")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{{Path: dir, Name: "myrepo"}},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
idx := mi.GetIndexer("myrepo")
assert.NotNil(t, idx)
assert.Nil(t, mi.GetIndexer("nonexistent"))
}
func TestMultiIndexer_IndexAll_EmptyRepos(t *testing.T) {
cm := newTestConfigManager(t)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
results, err := mi.IndexAll()
require.NoError(t, err)
assert.Nil(t, results)
}
// Feature: multi-repo-support, Property 6: Node ID format by mode
//
// TestPropertyNodeIDFormatByMode verifies that:
// - In multi-repo mode (2+ repos), node IDs match <repo_prefix>/<path>::<Symbol>
// and RepoPrefix is non-empty.
// - In single-repo mode (1 repo), node IDs match <path>::<Symbol>
// and RepoPrefix is empty.
func TestPropertyNodeIDFormatByMode(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
// Generate random function names for uniqueness per iteration.
funcNameA := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,6}`).Draw(rt, "funcNameA")
funcNameB := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,6}`).Draw(rt, "funcNameB")
// Decide mode: single-repo or multi-repo.
multiRepo := rapid.Bool().Draw(rt, "multiRepo")
tmpBase := t.TempDir()
if multiRepo {
// --- Multi-repo mode: 2 repos ---
repoA := filepath.Join(tmpBase, "repo-a")
repoB := filepath.Join(tmpBase, "repo-b")
require.NoError(t, os.MkdirAll(repoA, 0o755))
require.NoError(t, os.MkdirAll(repoB, 0o755))
writeFile(t, filepath.Join(repoA, "main.go"),
fmt.Sprintf("package main\n\nfunc %s() {}\n", funcNameA))
writeFile(t, filepath.Join(repoB, "main.go"),
fmt.Sprintf("package main\n\nfunc %s() {}\n", funcNameB))
tmpCfg := filepath.Join(tmpBase, "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
results, err := mi.IndexAll()
require.NoError(t, err)
require.Len(t, results, 2)
// Must be multi-repo mode.
if !mi.IsMultiRepo() {
rt.Error("expected IsMultiRepo() == true for 2 repos")
}
for _, n := range g.AllNodes() {
// RepoPrefix must be non-empty.
if n.RepoPrefix == "" {
rt.Errorf("multi-repo node %q has empty RepoPrefix", n.ID)
}
// ID must start with "<repo_prefix>/".
prefix := n.RepoPrefix + "/"
if !strings.HasPrefix(n.ID, prefix) {
rt.Errorf("multi-repo node ID %q does not start with prefix %q", n.ID, prefix)
}
// ID must contain "::" separating path from symbol (for non-file nodes).
if n.Kind != graph.KindFile && n.Kind != graph.KindPackage && n.Kind != graph.KindImport {
if !strings.Contains(n.ID, "::") {
rt.Errorf("multi-repo node ID %q missing '::' separator", n.ID)
}
}
}
} else {
// --- Single-repo mode: 1 repo ---
repoDir := filepath.Join(tmpBase, "solo-repo")
require.NoError(t, os.MkdirAll(repoDir, 0o755))
writeFile(t, filepath.Join(repoDir, "main.go"),
fmt.Sprintf("package main\n\nfunc %s() {}\n", funcNameA))
tmpCfg := filepath.Join(tmpBase, "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoDir, Name: "solo-repo"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
results, err := mi.IndexAll()
require.NoError(t, err)
require.Len(t, results, 1)
// Must NOT be multi-repo mode.
if mi.IsMultiRepo() {
rt.Error("expected IsMultiRepo() == false for 1 repo")
}
for _, n := range g.AllNodes() {
// RepoPrefix must be empty in single-repo mode.
if n.RepoPrefix != "" {
rt.Errorf("single-repo node %q has non-empty RepoPrefix %q", n.ID, n.RepoPrefix)
}
// ID must NOT contain a repo prefix slash before the path.
// In single-repo mode, IDs are <path>::<Symbol>, not <prefix>/<path>::<Symbol>.
// We verify by checking no known prefix appears.
if strings.HasPrefix(n.ID, "solo-repo/") {
rt.Errorf("single-repo node ID %q should not have repo prefix", n.ID)
}
}
}
})
}
// Feature: multi-repo-support, Property 9: Re-index isolation
//
// TestPropertyReindexIsolation verifies that re-indexing repo A does not
// modify, remove, or add any nodes or edges belonging to repo B.
// The node count and edge count for repo B before and after re-indexing A
// are identical.
func TestPropertyReindexIsolation(t *testing.T) {
rapid.Check(t, func(rt *rapid.T) {
// Generate random function names for uniqueness per iteration.
funcNameA := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,6}`).Draw(rt, "funcNameA")
funcNameB := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,6}`).Draw(rt, "funcNameB")
tmpBase := t.TempDir()
repoA := filepath.Join(tmpBase, "repo-a")
repoB := filepath.Join(tmpBase, "repo-b")
require.NoError(t, os.MkdirAll(repoA, 0o755))
require.NoError(t, os.MkdirAll(repoB, 0o755))
writeFile(t, filepath.Join(repoA, "main.go"),
fmt.Sprintf("package main\n\nfunc %s() {}\n", funcNameA))
writeFile(t, filepath.Join(repoB, "main.go"),
fmt.Sprintf("package main\n\nfunc %s() {}\n", funcNameB))
tmpCfg := filepath.Join(tmpBase, "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
// Record repo B's state before re-indexing A.
repoBNodesBefore := len(g.GetRepoNodes("repo-b"))
repoBEdgesBefore := countRepoEdges(g, "repo-b")
// Capture repo B node IDs for identity check.
repoBNodeIDsBefore := make(map[string]bool)
for _, n := range g.GetRepoNodes("repo-b") {
repoBNodeIDsBefore[n.ID] = true
}
// Re-index repo A.
_, err = mi.IndexRepo("repo-a")
require.NoError(t, err)
// Verify repo B node count unchanged.
repoBNodesAfter := len(g.GetRepoNodes("repo-b"))
if repoBNodesBefore != repoBNodesAfter {
rt.Errorf("repo-b node count changed: before=%d after=%d", repoBNodesBefore, repoBNodesAfter)
}
// Verify repo B edge count unchanged.
repoBEdgesAfter := countRepoEdges(g, "repo-b")
if repoBEdgesBefore != repoBEdgesAfter {
rt.Errorf("repo-b edge count changed: before=%d after=%d", repoBEdgesBefore, repoBEdgesAfter)
}
// Verify repo B node IDs are exactly the same.
repoBNodeIDsAfter := make(map[string]bool)
for _, n := range g.GetRepoNodes("repo-b") {
repoBNodeIDsAfter[n.ID] = true
}
for id := range repoBNodeIDsBefore {
if !repoBNodeIDsAfter[id] {
rt.Errorf("repo-b node %q disappeared after re-indexing repo-a", id)
}
}
for id := range repoBNodeIDsAfter {
if !repoBNodeIDsBefore[id] {
rt.Errorf("repo-b node %q appeared after re-indexing repo-a", id)
}
}
})
}
// countRepoEdges counts edges where at least one endpoint belongs to the given repo prefix.
func countRepoEdges(g graph.Store, repoPrefix string) int {
prefix := repoPrefix + "/"
count := 0
for _, e := range g.AllEdges() {
if strings.HasPrefix(e.From, prefix) || strings.HasPrefix(e.To, prefix) {
count++
}
}
return count
}
// --- Task 17.1: Backward compatibility verification ---
// TestSingleRepoMode_BackwardCompat verifies that single --index with no repos
// config behaves identically to the current implementation.
func TestSingleRepoMode_BackwardCompat(t *testing.T) {
t.Run("node_ids_use_existing_format", func(t *testing.T) {
dir := setupRepoDir(t, "myrepo")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{{Path: dir, Name: "myrepo"}},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
// Single-repo mode: IsMultiRepo should be false.
assert.False(t, mi.IsMultiRepo())
// Node IDs should use existing format without prefix.
for _, n := range g.AllNodes() {
assert.Empty(t, n.RepoPrefix, "single-repo nodes should not have RepoPrefix")
// IDs should NOT start with "myrepo/" prefix.
assert.False(t, strings.HasPrefix(n.ID, "myrepo/"),
"single-repo node ID %q should not have repo prefix", n.ID)
}
})
t.Run("existing_gortex_yaml_loads_without_errors", func(t *testing.T) {
// Create a minimal .gortex.yaml without repos/workspace sections.
tmpDir := t.TempDir()
cfgContent := `index:
exclude:
- "vendor/**"
workers: 4
watch:
enabled: true
debounce_ms: 200
guards:
rules:
- name: test-rule
kind: co-change
source: "internal/parser"
target: "internal/parser/languages"
message: "Parser changes require language extractor test updates"
`
cfgPath := filepath.Join(tmpDir, ".gortex.yaml")
require.NoError(t, os.WriteFile(cfgPath, []byte(cfgContent), 0o644))
cfg, err := config.Load(cfgPath)
require.NoError(t, err)
require.NotNil(t, cfg)
// New fields should have defaults.
assert.False(t, cfg.Multi.AutoDetect)
// Existing fields should be loaded.
assert.Equal(t, 4, cfg.Index.Workers)
assert.True(t, cfg.Watch.Enabled)
assert.Len(t, cfg.Guards.Rules, 1)
})
t.Run("no_global_config_required", func(t *testing.T) {
// Loading a non-existent GlobalConfig should not error.
gc, err := config.LoadGlobal("/nonexistent/path/config.yaml")
require.NoError(t, err)
require.NotNil(t, gc)
assert.Empty(t, gc.Repos)
assert.Empty(t, gc.Projects)
assert.Empty(t, gc.ActiveProject)
})
t.Run("mcp_tools_work_without_repo_param", func(t *testing.T) {
// In single-repo mode, the graph should be queryable without repo filtering.
dir := setupRepoDir(t, "solo")
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{{Path: dir, Name: "solo"}},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
// Query engine should work without repo scoping.
nodes := g.AllNodes()
assert.Greater(t, len(nodes), 0, "should have indexed nodes")
// All nodes should be accessible without repo prefix filtering.
for _, n := range nodes {
found := g.GetNode(n.ID)
assert.NotNil(t, found, "node %q should be findable by ID", n.ID)
}
})
}
// --- Task 17.2: Single-to-multi-repo transition ---
// TestSingleToMultiRepoTransition verifies that when upgrading from single-repo
// to multi-repo, a full re-index generates Qualified_Node_IDs.
func TestSingleToMultiRepoTransition(t *testing.T) {
repoA := setupRepoDir(t, "repo-a")
repoB := setupRepoDir(t, "repo-b")
// Step 1: Start in single-repo mode with just repo-a.
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{{Path: repoA, Name: "repo-a"}},
}
gc.SetConfigPath(tmpCfg)
require.NoError(t, gc.Save())
cm, err := config.NewConfigManager(tmpCfg)
require.NoError(t, err)
g := graph.New()
mi := NewMultiIndexer(g, newTestRegistry(), search.NewBM25(), cm, zap.NewNop())
_, err = mi.IndexAll()
require.NoError(t, err)
// Verify single-repo mode: no prefixes.
assert.False(t, mi.IsMultiRepo())
for _, n := range g.AllNodes() {
assert.Empty(t, n.RepoPrefix)
assert.False(t, strings.HasPrefix(n.ID, "repo-a/"))
}
// Step 2: Track a second repo to transition to multi-repo mode.
result, err := mi.TrackRepo(config.RepoEntry{Path: repoB, Name: "repo-b"})
require.NoError(t, err)
require.NotNil(t, result)
// Now we're in multi-repo mode.
assert.True(t, mi.IsMultiRepo())
// Step 3: Full re-index to generate Qualified_Node_IDs.
// Create a new graph and multi-indexer to simulate full re-index.
gc2 := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "repo-a"},
{Path: repoB, Name: "repo-b"},
},
}
tmpCfg2 := filepath.Join(t.TempDir(), "config2.yaml")
gc2.SetConfigPath(tmpCfg2)
require.NoError(t, gc2.Save())
cm2, err := config.NewConfigManager(tmpCfg2)
require.NoError(t, err)
g2 := graph.New()
mi2 := NewMultiIndexer(g2, newTestRegistry(), search.NewBM25(), cm2, zap.NewNop())
results, err := mi2.IndexAll()
require.NoError(t, err)
require.Len(t, results, 2)
// All nodes should now have Qualified_Node_IDs with repo prefix.
assert.True(t, mi2.IsMultiRepo())
for _, n := range g2.AllNodes() {
assert.NotEmpty(t, n.RepoPrefix, "multi-repo node %q should have RepoPrefix", n.ID)
prefix := n.RepoPrefix + "/"
assert.True(t, strings.HasPrefix(n.ID, prefix),
"multi-repo node ID %q should start with %q", n.ID, prefix)
}
}