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
268 lines
12 KiB
Go
268 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"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/daemon"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
"github.com/zzet/gortex/internal/parser/languages"
|
|
"github.com/zzet/gortex/internal/search"
|
|
)
|
|
|
|
// TestStatus_UnprefixedSoloRepo_ReportsLiveNodeCount is the regression for
|
|
// issues #261/#270: a lone tracked repo indexes with RepoPrefix="" (see
|
|
// TrackRepoCtx/ReconcileRepoCtx's willBeMultiRepo gate), and both graph
|
|
// backends make that empty-prefix bucket invisible to a lookup keyed by the
|
|
// repo's real prefix — AllRepoMemoryEstimates on the in-memory backend keys
|
|
// its map by literal RepoPrefix (so a "" entry never matches the repo's
|
|
// resolved prefix string), and the SQLite backend's GROUP BY excludes
|
|
// repo_prefix="" rows outright. Either way, `gortex daemon status` used to
|
|
// fall back to whatever RepoMetadata.NodeCount was frozen at by the last
|
|
// operation that replaced the metadata entry, which goes stale the moment
|
|
// the live graph changes out from under it without retouching mi.repos.
|
|
func TestStatus_UnprefixedSoloRepo_ReportsLiveNodeCount(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "lib.go"),
|
|
[]byte("package lib\n\nfunc A() {}\n"), 0o644))
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: dir}}}
|
|
gc.SetConfigPath(cfgPath)
|
|
require.NoError(t, gc.Save())
|
|
cm, err := config.NewConfigManager(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
g := graph.New()
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
mi := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop())
|
|
_, err = mi.IndexAll()
|
|
require.NoError(t, err)
|
|
|
|
metas := mi.AllMetadata()
|
|
require.Len(t, metas, 1, "exactly one repo is configured")
|
|
var prefix string
|
|
var frozenNodes int
|
|
for p, m := range metas {
|
|
prefix = p
|
|
frozenNodes = m.NodeCount
|
|
require.True(t, m.Unprefixed, "a lone tracked repo must index unprefixed")
|
|
}
|
|
|
|
// Simulate the live graph growing out from under the frozen
|
|
// RepoMetadata snapshot — e.g. an LSP-enrichment or watcher-driven
|
|
// write that lands nodes without going through a path that re-stamps
|
|
// mi.repos[prefix]. A real node (not a bare struct) so both backends'
|
|
// per-shard/column bookkeeping picks it up identically.
|
|
g.AddNode(&graph.Node{
|
|
ID: "unprefixed::Extra",
|
|
Kind: graph.KindFunction,
|
|
Name: "Extra",
|
|
FilePath: "extra.go",
|
|
Language: "go",
|
|
})
|
|
|
|
c := &realController{graph: g, multiIndexer: mi, configManager: cm, logger: zap.NewNop()}
|
|
resp, err := c.Status(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.TrackedRepos, 1)
|
|
|
|
// Ground truth: the in-memory backend's per-repo shard counters
|
|
// intentionally skip nodes with no RepoPrefix (shard.repoNodeAdd),
|
|
// so RepoMemoryEstimate("") is always zero there — the whole
|
|
// store's NodeCount is the only accurate read for unprefixed mode,
|
|
// which is also what Indexer.repoNodeEdgeCount falls back to.
|
|
liveNodes := g.NodeCount()
|
|
require.Greater(t, liveNodes, frozenNodes,
|
|
"sanity: the live graph must have outgrown the frozen metadata snapshot for this test to be meaningful")
|
|
assert.Equal(t, liveNodes, resp.TrackedRepos[0].Nodes,
|
|
"a solo (unprefixed) tracked repo's status row must reflect the live graph count, not the frozen RepoMetadata snapshot")
|
|
assert.Equal(t, prefix, resp.TrackedRepos[0].Prefix)
|
|
}
|
|
|
|
// TestStatus_SoleRepoDesyncedToPrefixed_ReportsWholeStore reproduces the
|
|
// remaining #261/#270 case f8436fab did not cover: a lone repo indexed
|
|
// unprefixed (nodes carry repo_prefix="") whose metadata later flips to
|
|
// prefixed (Unprefixed=false) WITHOUT its nodes being restamped, leaving an
|
|
// empty per-prefix bucket. This is what a macOS path-case duplicate does — a
|
|
// second config entry makes willBeMultiRepo true at the next warm restart, but
|
|
// migrateLoneUnprefixedRepoCtx's guard (len(repos)==1) is false mid-warmup-loop
|
|
// so the "" nodes never migrate. `query stats` still reports the full store,
|
|
// while `daemon status` used to fall back to the frozen ~0 RepoMetadata.NodeCount
|
|
// and render the reported near-empty (nodes=1) row.
|
|
func TestStatus_SoleRepoDesyncedToPrefixed_ReportsWholeStore(t *testing.T) {
|
|
dir := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "lib.go"),
|
|
[]byte("package lib\n\nfunc A() {}\nfunc B() { A() }\n"), 0o644))
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: dir}}}
|
|
gc.SetConfigPath(cfgPath)
|
|
require.NoError(t, gc.Save())
|
|
cm, err := config.NewConfigManager(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
|
|
// First index: sole configured repo → indexed unprefixed.
|
|
g := graph.New()
|
|
mi1 := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop())
|
|
_, err = mi1.IndexAll()
|
|
require.NoError(t, err)
|
|
|
|
var priorMtimes map[string]int64
|
|
for _, m := range mi1.AllMetadata() {
|
|
require.True(t, m.Unprefixed, "a lone tracked repo must index unprefixed")
|
|
priorMtimes = m.FileMtimes
|
|
}
|
|
require.NotEmpty(t, priorMtimes, "the first index must have recorded file mtimes to reconcile against")
|
|
fullNodes := g.NodeCount()
|
|
fullEdges := g.EdgeCount()
|
|
require.Greater(t, fullNodes, 1, "sanity: the repo must have produced a real graph")
|
|
|
|
// A second config entry (the macOS path-case duplicate) makes the next
|
|
// warm restart see a multi-repo config and flip the lone repo to prefixed
|
|
// metadata mode.
|
|
require.NoError(t, cm.Global().AddRepo(config.RepoEntry{Path: t.TempDir()}))
|
|
require.GreaterOrEqual(t, len(cm.Global().Repos), 2)
|
|
|
|
// Warm restart: a fresh MultiIndexer over the SAME graph store reconciles
|
|
// the repo from the persisted mtimes. mi2.repos is empty, so
|
|
// migrateLoneUnprefixedRepoCtx is a no-op (guard needs len(repos)==1);
|
|
// willBeMultiRepo is true, so the metadata is stamped prefixed — but with
|
|
// nothing stale to reindex, the existing repo_prefix="" nodes are never
|
|
// restamped, leaving an empty per-prefix bucket.
|
|
mi2 := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop())
|
|
_, err = mi2.ReconcileRepoCtx(context.Background(), config.RepoEntry{Path: dir}, priorMtimes)
|
|
require.NoError(t, err)
|
|
|
|
metas := mi2.AllMetadata()
|
|
require.Len(t, metas, 1, "the path-case duplicate collapses to one tracked repo identity")
|
|
var prefix string
|
|
for p, m := range metas {
|
|
prefix = p
|
|
require.False(t, m.Unprefixed, "the reconcile under a 2-entry config flips the repo to prefixed mode")
|
|
}
|
|
require.NotEmpty(t, prefix, "the desynced repo must carry a real (non-empty) prefix")
|
|
|
|
// The desync: the store still holds every node under repo_prefix="", so
|
|
// the prefixed bucket is empty — the exact condition that made the old
|
|
// per-prefix lookup miss and fall back to the near-empty frozen count.
|
|
require.Less(t, g.RepoMemoryEstimate(prefix).NodeCount, fullNodes,
|
|
"sanity: the prefixed bucket must be short of the store total — nodes still carry repo_prefix=''")
|
|
|
|
c := &realController{graph: g, multiIndexer: mi2, configManager: cm, logger: zap.NewNop()}
|
|
resp, err := c.Status(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.TrackedRepos, 1)
|
|
|
|
assert.Equal(t, fullNodes, resp.TrackedRepos[0].Nodes,
|
|
"a sole tracked repo's status row must reflect the whole store even when its nodes are unprefixed but its metadata says prefixed")
|
|
assert.Equal(t, fullEdges, resp.TrackedRepos[0].Edges,
|
|
"edges must likewise reflect the whole store, matching `gortex query stats`")
|
|
}
|
|
|
|
// TestStatus_MultiRepoDesync_AttributesUnaccountedPool covers the multi-repo
|
|
// variant: repo A is indexed solo (nodes under repo_prefix=""), then repo B is
|
|
// added while the daemon is down. At the next warm restart A is reconciled
|
|
// first with an empty mi.repos, so the lone-repo migration never fires and A's
|
|
// metadata is stamped prefixed while its nodes stay unprefixed — leaving A's
|
|
// per-prefix bucket empty. B indexes normally. Because exactly one tracked
|
|
// repo lacks a live bucket, `daemon status` attributes the unaccounted (mostly
|
|
// "") node pool to A instead of falling back to its near-empty frozen count.
|
|
func TestStatus_MultiRepoDesync_AttributesUnaccountedPool(t *testing.T) {
|
|
dirA := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dirA, "a.go"),
|
|
[]byte("package a\n\nfunc A() {}\nfunc A2() { A() }\n"), 0o644))
|
|
dirB := t.TempDir()
|
|
require.NoError(t, os.WriteFile(filepath.Join(dirB, "b.go"),
|
|
[]byte("package b\n\nfunc B() {}\nfunc B2() { B() }\nfunc B3() { B2() }\n"), 0o644))
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
gc := &config.GlobalConfig{Repos: []config.RepoEntry{{Path: dirA}}}
|
|
gc.SetConfigPath(cfgPath)
|
|
require.NoError(t, gc.Save())
|
|
cm, err := config.NewConfigManager(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
reg := parser.NewRegistry()
|
|
languages.RegisterAll(reg)
|
|
|
|
// First index: A is the sole configured repo → indexed unprefixed.
|
|
g := graph.New()
|
|
mi1 := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop())
|
|
_, err = mi1.IndexAll()
|
|
require.NoError(t, err)
|
|
var priorA map[string]int64
|
|
for _, m := range mi1.AllMetadata() {
|
|
require.True(t, m.Unprefixed)
|
|
priorA = m.FileMtimes
|
|
}
|
|
require.NotEmpty(t, priorA)
|
|
|
|
// B is added to the config while the daemon is "down".
|
|
require.NoError(t, cm.Global().AddRepo(config.RepoEntry{Path: dirB}))
|
|
|
|
// Warm restart over the same graph store: reconcile A (from its persisted
|
|
// mtimes — stays unprefixed in the store), then track B fresh.
|
|
mi2 := indexer.NewMultiIndexer(g, reg, search.NewBM25(), cm, zap.NewNop())
|
|
_, err = mi2.ReconcileRepoCtx(context.Background(), config.RepoEntry{Path: dirA}, priorA)
|
|
require.NoError(t, err)
|
|
_, err = mi2.TrackRepoCtx(context.Background(), config.RepoEntry{Path: dirB})
|
|
require.NoError(t, err)
|
|
|
|
metas := mi2.AllMetadata()
|
|
require.Len(t, metas, 2)
|
|
var prefixA, prefixB string
|
|
for p, m := range metas {
|
|
if m.RootPath == dirA {
|
|
prefixA = p
|
|
require.False(t, m.Unprefixed, "A desynced to prefixed metadata")
|
|
} else {
|
|
prefixB = p
|
|
}
|
|
}
|
|
require.NotEmpty(t, prefixA)
|
|
require.NotEmpty(t, prefixB)
|
|
|
|
// A's bucket is empty (its nodes are still under ""); B's is populated.
|
|
bucketA := g.RepoMemoryEstimate(prefixA).NodeCount
|
|
bucketB := g.RepoMemoryEstimate(prefixB).NodeCount
|
|
require.Zero(t, bucketA, "A's per-prefix bucket must be empty — its nodes carry repo_prefix=''")
|
|
require.Positive(t, bucketB, "B must have a populated per-prefix bucket")
|
|
|
|
// Expected attribution: the unaccounted pool (whole store minus B's
|
|
// bucket) goes to A, the lone empty-bucket repo.
|
|
expectedA := g.NodeCount() - bucketB
|
|
require.Greater(t, expectedA, 1, "sanity: A must own a real (non-near-empty) node pool for this test to be meaningful")
|
|
|
|
c := &realController{graph: g, multiIndexer: mi2, configManager: cm, logger: zap.NewNop()}
|
|
resp, err := c.Status(context.Background())
|
|
require.NoError(t, err)
|
|
require.Len(t, resp.TrackedRepos, 2)
|
|
|
|
rows := map[string]daemon.TrackedRepoStatus{}
|
|
for _, r := range resp.TrackedRepos {
|
|
rows[r.Prefix] = r
|
|
}
|
|
assert.Equal(t, expectedA, rows[prefixA].Nodes,
|
|
"A's row must reflect the unaccounted node pool it owns, not the near-empty frozen count")
|
|
assert.Equal(t, bucketB, rows[prefixB].Nodes,
|
|
"B's row must keep reflecting its own live per-prefix bucket")
|
|
assert.Greater(t, rows[prefixA].Nodes, bucketA,
|
|
"A's attributed pool must beat its former near-empty per-prefix bucket count")
|
|
assert.Equal(t, g.NodeCount(), rows[prefixA].Nodes+rows[prefixB].Nodes,
|
|
"the two rows together must account for the whole store")
|
|
}
|