Files
zzet--gortex/cmd/gortex/daemon_status_unprefixed_test.go
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

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")
}