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
193 lines
7.4 KiB
Go
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):
|
|
}
|
|
}
|