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

234 lines
8.3 KiB
Go

package indexer
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"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"
)
// setupGoRepoWithTypes creates a Go repo with a Handler type and a method —
// enough to exercise multi-repo indexing without needing the Go toolchain
// for semantic enrichment.
func setupGoRepoWithTypes(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(filepath.Join(dir, "api"), 0o755))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "go.mod"),
[]byte("module example.com/"+name+"\n\ngo 1.21\n"),
0o644,
))
require.NoError(t, os.WriteFile(
filepath.Join(dir, "api", "handler.go"),
[]byte(`package api
type Handler struct{}
func (h *Handler) CreateTuck() string { return "ok" }
`),
0o644,
))
return dir
}
// TestMultiRepo_ResolvesCallEdges guards two invariants of multi-repo
// resolution:
//
// 1. Sentinel hygiene. applyRepoPrefix used to prefix the
// "unresolved::" sentinel (producing "web/unresolved::X") which the
// resolver's HasPrefix check wouldn't recognize — leaving every
// call edge permanently unresolved. No edge target may carry a
// prefixed sentinel.
// 2. Repo-boundary discipline. repo B's Main() calls Greet() with NO
// import of repo A — two unrelated `package main` files in separate
// repos. That is not a real dependency (and not valid Go), so the
// resolver must NOT resolve the call across the repo boundary on a
// bare name match. It stays a clean "unresolved::Greet" sentinel.
// Resolving it would be the M3 cross-repo name-collision false
// positive. (Genuine cross-repo resolution — caller imports callee
// — is covered by the resolver package's own tests.)
func TestMultiRepo_ResolvesCallEdges(t *testing.T) {
// repo A defines Greet(); repo B's Main() calls a bare Greet() with
// no import linking the two repos.
repoA := filepath.Join(t.TempDir(), "lib-svc")
require.NoError(t, os.MkdirAll(repoA, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(repoA, "greet.go"),
[]byte("package main\n\nfunc Greet() string { return \"hi\" }\n"),
0o644))
repoB := filepath.Join(t.TempDir(), "app-svc")
require.NoError(t, os.MkdirAll(repoB, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(repoB, "main.go"),
[]byte("package main\n\nfunc Main() {\n\t_ = Greet()\n}\n"),
0o644))
tmpCfg := filepath.Join(t.TempDir(), "config.yaml")
gc := &config.GlobalConfig{
Repos: []config.RepoEntry{
{Path: repoA, Name: "lib-svc"},
{Path: repoB, Name: "app-svc"},
},
}
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 _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "track %s", entry.Name)
}
// Zero edge targets may remain under the unresolved sentinel with any
// prefix applied. The invariant is stricter than "Main → Greet resolved"
// because the original bug showed up as contains("unresolved::") for
// the vast majority of call edges.
var leaked []string
for _, e := range g.AllEdges() {
if strings.Contains(e.To, "/unresolved::") {
leaked = append(leaked, e.From+" → "+e.To)
}
}
assert.Empty(t, leaked,
"edges with repo-prefixed unresolved targets indicate applyRepoPrefix "+
"polluted the resolver sentinel:\n %s",
strings.Join(leaked, "\n "))
// Repo-boundary check: Main()'s call to Greet() must NOT resolve to
// repo A's Greet — repo B never imports repo A, so a bare-name match
// across the repo line is the cross-repo false positive. The call
// edge must remain a clean "unresolved::Greet" sentinel.
main := "app-svc/main.go::Main"
crossRepoGreet := "lib-svc/greet.go::Greet"
var callEdge *graph.Edge
for _, e := range g.GetOutEdges(main) {
if e.Kind == graph.EdgeCalls {
callEdge = e
break
}
}
if assert.NotNil(t, callEdge, "Main should have a call edge; out-edges: %v", outEdgeSummaries(g, main)) {
assert.NotEqual(t, crossRepoGreet, callEdge.To,
"call must not resolve across the repo boundary without an import")
assert.False(t, callEdge.CrossRepo,
"call edge must not be flagged cross-repo")
assert.Equal(t, "unresolved::Greet", callEdge.To,
"unimported cross-repo call stays a clean unresolved sentinel; got %q", callEdge.To)
}
}
func outEdgeSummaries(g graph.Store, id string) []string {
var out []string
for _, e := range g.GetOutEdges(id) {
out = append(out, string(e.Kind)+":"+e.To)
}
return out
}
// TestTrackRepoCtx_FirstOfManyStillGetsPrefix guards against the bug where
// the first repo tracked via TrackRepoCtx at daemon warmup was indexed
// without a RepoPrefix because willBeMultiRepo was decided by counting
// `mi.repos` (which is empty at iteration 0). The symptom was asymmetric
// IDs across repos: one repo's nodes under "internal/api/handler.go::X",
// the rest under "worker/internal/api/handler.go::X". Halved Go edge
// density in multi-repo graphs.
func TestTrackRepoCtx_FirstOfManyStillGetsPrefix(t *testing.T) {
repoA := setupGoRepoWithTypes(t, "repo-a")
repoB := setupGoRepoWithTypes(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())
// Simulate warmupDaemonState's loop: TrackRepoCtx each config'd repo
// in order. The first call is the one that used to skip prefixing.
for _, entry := range cm.Global().Repos {
_, err := mi.TrackRepoCtx(context.Background(), entry)
require.NoError(t, err, "tracking %s", entry.Name)
}
require.True(t, mi.IsMultiRepo(), "setup must produce multi-repo mode")
// Every node must carry a non-empty RepoPrefix and its FilePath must
// live under that prefix. Any violation means a code path bypassed
// applyRepoPrefix. KindModule and KindBuiltin are deliberately
// cross-repo singletons (one `module::pypi:requests` /
// `builtin::go::type::string` shared across every repo that uses
// them) so they're exempt from the per-repo prefix rule.
var missingPrefix, badFilePaths []string
for _, n := range g.AllNodes() {
if n.Kind == graph.KindModule || n.Kind == graph.KindBuiltin {
continue
}
if ext, _ := n.Meta["external"].(bool); ext {
// External call targets the resolver materialises as
// KindFunction with meta.external=true are cross-repo
// singletons (one `stdlib::fmt::Sprintf` shared across
// every repo that calls it) — same as KindModule.
continue
}
if n.RepoPrefix == "" {
missingPrefix = append(missingPrefix, n.ID)
continue
}
if n.FilePath != "" && !strings.HasPrefix(n.FilePath, n.RepoPrefix+"/") {
badFilePaths = append(badFilePaths,
n.ID+" (FilePath="+n.FilePath+", RepoPrefix="+n.RepoPrefix+")")
}
}
assert.Empty(t, missingPrefix,
"nodes without RepoPrefix leaked into multi-repo graph (first-repo prefix bug):\n %s",
strings.Join(missingPrefix, "\n "))
assert.Empty(t, badFilePaths,
"nodes with FilePath outside their RepoPrefix:\n %s",
strings.Join(badFilePaths, "\n "))
// No node ID should begin with an absolute filesystem path — that's
// the shape stale snapshot nodes take, and no current indexing path
// should produce it.
for _, n := range g.AllNodes() {
assert.False(t, strings.HasPrefix(n.ID, "/"),
"node ID begins with absolute path: %s", n.ID)
}
// Both repos must have contributed Handler.CreateTuck, each under its
// own prefix. This is the positive counterpart to the prefix check.
want := map[string]bool{
"repo-a/api/handler.go::Handler.CreateTuck": false,
"repo-b/api/handler.go::Handler.CreateTuck": false,
}
for _, n := range g.AllNodes() {
if _, ok := want[n.ID]; ok {
want[n.ID] = true
}
}
for id, found := range want {
assert.True(t, found, "expected prefixed node %s not found in graph", id)
}
}