Files
zzet--gortex/internal/indexer/incremental_resolve_revert_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

193 lines
7.4 KiB
Go

package indexer
import (
"fmt"
"path/filepath"
"testing"
"time"
"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/graph/store_sqlite"
"github.com/zzet/gortex/internal/search"
)
// newSqliteGraph opens a temp-file sqlite-backed graph store for a test.
// A disk file (not :memory:) is required because the store pools
// connections and an in-memory sqlite DB is per-connection.
func newSqliteGraph(t *testing.T) graph.Store {
t.Helper()
st, err := store_sqlite.Open(filepath.Join(t.TempDir(), "graph.sqlite"))
require.NoError(t, err)
t.Cleanup(func() { _ = st.Close() })
return st
}
// incomingCallEdges returns the EdgeCalls edges currently bound INTO node id
// (its resolved callers).
func incomingCallEdges(t *testing.T, g graph.Store, id string) []*graph.Edge {
t.Helper()
var out []*graph.Edge
for _, e := range g.GetInEdges(id) {
if e != nil && e.Kind == graph.EdgeCalls {
out = append(out, e)
}
}
return out
}
// stampLSP marks an edge compiler-grade and persists it (the tier the
// track-time semantic-enrichment pass mints once).
func stampLSP(t *testing.T, g graph.Store, e *graph.Edge) {
t.Helper()
g.SetEdgeProvenance(e, graph.OriginLSPResolved)
e.Tier = graph.ResolvedBy(graph.OriginLSPResolved)
e.Confidence = 1.0
g.ReindexEdges([]graph.EdgeReindex{{Edge: e, OldTo: e.To}})
}
// TestIncrementalReindex_DoubleCycle_Sqlite_IntraFileCaller models the live
// staleness-probe REVERT conditions the in-memory double-cycle test omits: a
// sqlite backend (GetInEdges returns freshly-decoded edge pointers, so an
// in-place provenance restore that is not persisted is lost) AND an appended
// symbol that CALLS the target (an intra-file caller added on the ADD cycle and
// removed on the REVERT cycle). After BOTH cycles the cross-file caller edge
// must still resolve to the definition and keep its lsp_resolved provenance —
// find_usages hides text_matched by default, so a demotion here reads as a
// silent zero.
func TestIncrementalReindex_DoubleCycle_Sqlite_IntraFileCaller(t *testing.T) {
dir := t.TempDir()
defPath := filepath.Join(dir, "debug.go")
callerPath := filepath.Join(dir, "caller.go")
writeFile(t, defPath, "package p\n\nfunc Foo() {}\n")
writeFile(t, callerPath, "package p\n\nfunc Bar() { Foo() }\n")
g := newSqliteGraph(t)
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(dir)
_, err := idx.IndexCtx(testCtx(), dir)
require.NoError(t, err)
fooID := fnNodeID(t, g, "debug.go", "Foo")
barID := fnNodeID(t, g, "caller.go", "Bar")
require.Equal(t, fooID, callTargetFrom(t, g, barID))
stampLSP(t, g, callEdgeFrom(t, g, barID))
cycles := []string{
"package p\n\nfunc Foo() {}\n\nfunc Probe() { Foo() }\n", // append + intra-file caller
"package p\n\nfunc Foo() {}\n", // revert (removes intra-file caller)
}
for i, content := range cycles {
writeFile(t, defPath, content)
require.NoError(t, idx.IndexFile(defPath))
after := callEdgeFrom(t, g, fnNodeID(t, g, "caller.go", "Bar"))
assert.Falsef(t, graph.IsUnresolvedTarget(after.To),
"cycle %d: Bar's cross-file caller edge must not remain a stub", i)
assert.Equalf(t, fnNodeID(t, g, "debug.go", "Foo"), after.To,
"cycle %d: Bar's cross-file caller edge must still resolve to Foo", i)
assert.Equalf(t, graph.OriginLSPResolved, after.Origin,
"cycle %d: incoming edge must keep its lsp origin (no ratchet-down)", i)
}
}
// TestReresolveFileScoped_RebindsDroppedIncomingEdges is the recovery half of
// the WS-1 self-heal: whatever mechanism drops a surviving definition's
// incoming callers to stubs, the forced scoped re-resolve the incoming-aware
// regression guard enqueues must rebind every one and restore its lsp
// provenance — on a disk backend, where the restore has to be persisted, not
// just mutated in place.
func TestReresolveFileScoped_RebindsDroppedIncomingEdges(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "def.go"), "package p\n\nfunc Foo() {}\n")
callers := []string{"c0.go", "c1.go", "c2.go"}
for i, c := range callers {
writeFile(t, filepath.Join(dir, c), fmt.Sprintf("package p\n\nfunc Bar%d() { Foo() }\n", i))
}
g := newSqliteGraph(t)
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
idx.search = search.NewBM25()
idx.SetRootPath(dir)
_, err := idx.IndexCtx(testCtx(), dir)
require.NoError(t, err)
fooID := fnNodeID(t, g, "def.go", "Foo")
for i, c := range callers {
e := callEdgeFrom(t, g, fnNodeID(t, g, c, fmt.Sprintf("Bar%d", i)))
require.Equal(t, fooID, e.To)
stampLSP(t, g, e)
}
require.Len(t, incomingCallEdges(t, g, fooID), len(callers), "baseline: all callers bound")
// Simulate the live drop: restub Foo's incoming caller edges to
// unresolved::Foo (what a re-parse of def.go does before it re-resolves).
idx.restubIncomingRefs("def.go")
require.Empty(t, incomingCallEdges(t, g, fooID), "precondition: callers dropped to stubs")
// The forced scoped re-resolve must rebind every dropped caller AND restore
// its lsp provenance on the sqlite backend.
require.NoError(t, idx.ReresolveFileScoped("def.go"))
got := incomingCallEdges(t, g, fooID)
require.Len(t, got, len(callers), "every dropped caller rebound")
for _, e := range got {
assert.Equal(t, graph.OriginLSPResolved, e.Origin,
"rebound caller must recover its lsp origin (restore persisted)")
}
}
// TestGuardResolvedEdgeRegression_IncomingArm_FiresOnRevert asserts the
// incoming arm of the regression guard: an external revert removes the appended
// probe symbol (nodesAfter < nodesBefore, so the out-edge arm stays silent) yet
// the surviving definition lost every caller — the guard must still enqueue a
// forced scoped re-resolve so the drop self-heals.
func TestGuardResolvedEdgeRegression_IncomingArm_FiresOnRevert(t *testing.T) {
g := newSqliteGraph(t)
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
w, err := NewWatcher(idx, config.WatchConfig{}, zap.NewNop())
require.NoError(t, err)
fired := make(chan map[string]struct{}, 1)
w.reresolveFn = func(files map[string]struct{}) { fired <- files }
w.guardResolvedEdgeRegression("def.go",
/*nodesBefore*/ 6, /*nodesAfter*/ 5, // probe removed
/*resolvedBefore*/ 0, /*resolvedAfter*/ 0, // out-edges never regressed
/*incomingBefore*/ 8, /*incomingAfter*/ 0) // callers all dropped
select {
case files := <-fired:
_, ok := files["def.go"]
assert.True(t, ok, "revert must enqueue a forced re-resolve of the definition file")
case <-time.After(2 * time.Second):
t.Fatal("incoming-edge regression did not enqueue a re-resolve")
}
}
// TestGuardResolvedEdgeRegression_NoFireWhenStable proves the guard does not
// churn: a re-parse that kept both its out-edges and its incoming callers must
// not enqueue anything.
func TestGuardResolvedEdgeRegression_NoFireWhenStable(t *testing.T) {
g := newSqliteGraph(t)
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
w, err := NewWatcher(idx, config.WatchConfig{}, zap.NewNop())
require.NoError(t, err)
fired := make(chan struct{}, 1)
w.reresolveFn = func(map[string]struct{}) { fired <- struct{}{} }
w.guardResolvedEdgeRegression("def.go", 5, 5, 10, 10, 8, 8)
select {
case <-fired:
t.Fatal("a stable re-parse must not enqueue a re-resolve")
case <-time.After(300 * time.Millisecond):
}
}