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

293 lines
12 KiB
Go

package indexer
import (
"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/graph"
"github.com/zzet/gortex/internal/search"
)
// fnNodeID returns the function/method node ID named `name` defined in
// graph file `file`, failing the test if it is absent.
func fnNodeID(t *testing.T, g graph.Store, file, name string) string {
t.Helper()
for _, n := range g.GetFileNodes(file) {
if n.Name == name && (n.Kind == graph.KindFunction || n.Kind == graph.KindMethod) {
return n.ID
}
}
t.Fatalf("function %q in %s not found", name, file)
return ""
}
// callTargetFrom returns the To of the (single) EdgeCalls edge leaving
// node `fromID`.
func callTargetFrom(t *testing.T, g graph.Store, fromID string) string {
t.Helper()
return callEdgeFrom(t, g, fromID).To
}
// callEdgeFrom returns the (single) EdgeCalls edge pointer leaving node
// `fromID`, failing the test if there is none.
func callEdgeFrom(t *testing.T, g graph.Store, fromID string) *graph.Edge {
t.Helper()
for _, e := range g.GetOutEdges(fromID) {
if e.Kind == graph.EdgeCalls {
return e
}
}
t.Fatalf("no call edge from %s", fromID)
return nil
}
// TestIncrementalReindex_PreservesIncomingCallerEdges is the proof of
// the reverse-resolution + un-resolve fix. When file A defines Foo and
// file B calls it, B's call edge resolves to A.Foo. Re-indexing or
// deleting A must NOT silently drop B's edge:
//
// - re-indexing A (Foo unchanged): restubIncomingRefs re-stubs B's
// edge to unresolved::Foo before A is evicted, then
// ResolveIncomingForFile rebinds it to A's fresh Foo — so B's caller
// edge survives a definition edit.
// - deleting A: B's edge survives as an unresolved::Foo stub (the
// correct state for a call to a now-missing symbol), not dropped.
// - re-creating A: ResolveIncomingForFile rebinds the pending stub.
//
// Against the pre-fix code, step (1) FAILS: evicting A drops B's
// incoming caller edge wholesale and ResolveFile(A) only touches A's
// outgoing edges, so get_callers(Foo) goes blank until a cold reindex.
func TestIncrementalReindex_PreservesIncomingCallerEdges(t *testing.T) {
dir := t.TempDir()
aPath := filepath.Join(dir, "a.go")
bPath := filepath.Join(dir, "b.go")
writeFile(t, aPath, "package p\n\nfunc Foo() {}\n")
writeFile(t, bPath, "package p\n\nfunc Bar() { Foo() }\n")
g := graph.New()
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, "a.go", "Foo")
barID := fnNodeID(t, g, "b.go", "Bar")
require.Equal(t, fooID, callTargetFrom(t, g, barID),
"baseline: Bar's call must resolve to Foo")
// (1) Re-index the DEFINITION file with Foo unchanged. The caller
// edge in b.go must survive.
require.NoError(t, idx.IndexFile(aPath))
assert.Equal(t, fooID, callTargetFrom(t, g, barID),
"re-indexing Foo's own file must not drop Bar's caller edge")
// (2) Delete the definition. Bar's edge must revert to an unresolved
// stub, not vanish.
idx.EvictFile(aPath)
deletedTarget := callTargetFrom(t, g, barID)
assert.True(t, graph.IsUnresolvedTarget(deletedTarget),
"deleting Foo must leave Bar's call as an unresolved stub, not drop it")
assert.Equal(t, "Foo", graph.UnresolvedName(deletedTarget),
"the re-stubbed target must carry Foo's name")
// (3) Re-create the definition. The pending stub must rebind.
require.NoError(t, idx.IndexFile(aPath))
rebound := fnNodeID(t, g, "a.go", "Foo")
assert.Equal(t, rebound, callTargetFrom(t, g, barID),
"re-adding Foo must rebind Bar's pending caller edge via the reverse pass")
}
// TestIncrementalReindex_DoubleCycle_PreservesIncomingTier is the F3
// regression: re-parsing a definition file twice (append then revert — the
// exact staleness-probe sequence) must not ratchet an incoming caller edge's
// tier down or leave it a stub. Before the fix the first cycle preserved the
// caller edge but the second could collapse it; this asserts stability across
// BOTH cycles.
func TestIncrementalReindex_DoubleCycle_PreservesIncomingTier(t *testing.T) {
dir := t.TempDir()
aPath := filepath.Join(dir, "a.go")
bPath := filepath.Join(dir, "b.go")
writeFile(t, aPath, "package p\n\nfunc Foo() {}\n")
writeFile(t, bPath, "package p\n\nfunc Bar() { Foo() }\n")
g := graph.New()
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, "a.go", "Foo")
barID := fnNodeID(t, g, "b.go", "Bar")
require.Equal(t, fooID, callTargetFrom(t, g, barID))
// Stamp B's incoming call edge compiler-grade.
e := callEdgeFrom(t, g, barID)
g.SetEdgeProvenance(e, graph.OriginLSPResolved)
e.Tier = graph.ResolvedBy(graph.OriginLSPResolved)
cycles := []string{
"package p\n\nfunc Foo() {}\n\nfunc Extra() {}\n", // append
"package p\n\nfunc Foo() {}\n", // revert
}
for i, content := range cycles {
writeFile(t, aPath, content)
require.NoError(t, idx.IndexFile(aPath))
after := callEdgeFrom(t, g, fnNodeID(t, g, "b.go", "Bar"))
assert.Falsef(t, graph.IsUnresolvedTarget(after.To),
"cycle %d: Bar's caller edge must not remain a stub", i)
assert.Equalf(t, fnNodeID(t, g, "a.go", "Foo"), after.To,
"cycle %d: Bar's 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)
}
}
// TestIncrementalReindex_DeletedDefinition_NoStaleTierOnStub locks F3's
// provenance honesty: when a definition is genuinely deleted, the incoming
// caller edge reverts to an unresolved stub and must NOT keep claiming the
// compiler-grade tier it carried while bound — an unresolved stub advertising
// lsp_resolved is a provenance lie.
func TestIncrementalReindex_DeletedDefinition_NoStaleTierOnStub(t *testing.T) {
dir := t.TempDir()
aPath := filepath.Join(dir, "a.go")
bPath := filepath.Join(dir, "b.go")
writeFile(t, aPath, "package p\n\nfunc Foo() {}\n")
writeFile(t, bPath, "package p\n\nfunc Bar() { Foo() }\n")
g := graph.New()
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)
barID := fnNodeID(t, g, "b.go", "Bar")
e := callEdgeFrom(t, g, barID)
g.SetEdgeProvenance(e, graph.OriginLSPResolved)
e.Tier = graph.ResolvedBy(graph.OriginLSPResolved)
idx.EvictFile(aPath)
stub := callEdgeFrom(t, g, barID)
require.True(t, graph.IsUnresolvedTarget(stub.To),
"deleting Foo must leave Bar's call as an unresolved stub")
assert.NotEqual(t, graph.OriginLSPResolved, stub.Origin,
"an unresolved stub must not keep advertising a compiler-grade origin")
assert.Empty(t, stub.Tier,
"an unresolved stub must not keep a resolved tier")
}
// TestEvictFile_DropsEnrichmentSidecars proves the change-A eviction
// cascade: deleting a file drops its nodes' churn/coverage/blame
// sidecar rows, leaving no orphan enrichment.
func TestEvictFile_DropsEnrichmentSidecars(t *testing.T) {
idx, _ := newToggleIndexer(t)
dir := t.TempDir()
idx.SetRootPath(dir)
g := idx.graph
g.AddBatch([]*graph.Node{
{ID: "main.fk", Kind: graph.KindFile, Name: "main.fk", FilePath: "main.fk"},
{ID: "main.fk::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "main.fk"},
}, nil)
require.NoError(t, g.(graph.ChurnEnrichmentWriter).BulkSetChurn("", []graph.ChurnEnrichment{{NodeID: "main.fk::Foo", CommitCount: 3}}))
require.NoError(t, g.(graph.CoverageEnrichmentWriter).BulkSetCoverage("", []graph.CoverageEnrichment{{NodeID: "main.fk::Foo", CoveragePct: 50}}))
require.NoError(t, g.(graph.BlameEnrichmentWriter).BulkSetBlame("", []graph.BlameEnrichment{{NodeID: "main.fk::Foo", Email: "x@y"}}))
require.NotEmpty(t, g.(graph.ChurnEnrichmentReader).ChurnRows(""), "churn seeded")
idx.EvictFile("main.fk")
assert.Empty(t, g.(graph.ChurnEnrichmentReader).ChurnRows(""), "churn rows must be evicted with the file")
assert.Empty(t, g.(graph.CoverageEnrichmentReader).CoverageRows(""), "coverage rows must be evicted")
assert.Empty(t, g.(graph.BlameEnrichmentReader).BlameRows(""), "blame rows must be evicted")
}
// TestIncrementalReuse_SameFileEdge_KeepsTier is the F1 regression: a call
// whose target lives in the SAME file must keep its resolved provenance across
// a structural re-parse of that file. Before the fix, eviction removed the
// target node before applyResolvedOutEdges ran, so the same-file edge missed
// reuse (GetNode(v.to) == nil) and the forward resolver rebound it at the
// heuristic default — silently demoting an lsp_resolved edge, which find_usages
// then suppresses. Node IDs are file::Name (line/content-independent), so an
// EOF append re-adds the target under an identical ID and the reuse must
// recover the prior resolution AND its tier.
func TestIncrementalReuse_SameFileEdge_KeepsTier(t *testing.T) {
dir := t.TempDir()
aPath := filepath.Join(dir, "a.go")
writeFile(t, aPath, "package p\n\nfunc Foo() {}\n\nfunc Baz() { Foo() }\n")
g := graph.New()
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, "a.go", "Foo")
bazID := fnNodeID(t, g, "a.go", "Baz")
require.Equal(t, fooID, callTargetFrom(t, g, bazID),
"baseline: Baz's same-file call must resolve to Foo")
// Stamp the same-file call edge compiler-grade (lsp), the tier the
// semantic-enrichment pass mints once at track time.
e := callEdgeFrom(t, g, bazID)
g.SetEdgeProvenance(e, graph.OriginLSPResolved)
e.Tier = graph.ResolvedBy(graph.OriginLSPResolved)
e.Confidence = 1.0
// EOF-append a new function: a structural edit that drives the single-file
// incremental reindex (evict -> re-parse -> reuse -> resolve) without
// shifting Foo's or Baz's lines.
writeFile(t, aPath, "package p\n\nfunc Foo() {}\n\nfunc Baz() { Foo() }\n\nfunc Extra() {}\n")
require.NoError(t, idx.IndexFile(aPath))
// The call still points at Foo AND keeps its resolved provenance — not
// demoted to the resolver's heuristic default.
after := callEdgeFrom(t, g, fnNodeID(t, g, "a.go", "Baz"))
assert.Equal(t, fnNodeID(t, g, "a.go", "Foo"), after.To,
"same-file call must still resolve to Foo after the re-parse")
assert.Equal(t, graph.OriginLSPResolved, after.Origin,
"same-file edge must keep its lsp_resolved origin across the re-parse (F1)")
assert.Equal(t, graph.ResolvedBy(graph.OriginLSPResolved), after.Tier,
"same-file edge must keep its lsp tier across the re-parse (F1)")
}
// TestSetReparsePendingEnrichment_SetAndClear covers the F2.2 file-node marker
// the MCP find_usages rider reads: it sets the marker on the KindFile node and
// clears it, and is a no-op when the marker is already in the desired state.
func TestSetReparsePendingEnrichment_SetAndClear(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "a.go", Kind: graph.KindFile, Name: "a.go", FilePath: "a.go"})
idx := New(g, newTestRegistry(), config.IndexConfig{Workers: 1}, zap.NewNop())
fileMeta := func() map[string]any {
for _, n := range g.GetFileNodes("a.go") {
if n.Kind == graph.KindFile {
return n.Meta
}
}
t.Fatalf("file node a.go not found")
return nil
}
_, had := fileMeta()[graph.MetaReparsePendingEnrichment]
require.False(t, had, "marker absent on a freshly-added file node")
idx.setReparsePendingEnrichment("a.go", true)
_, had = fileMeta()[graph.MetaReparsePendingEnrichment]
assert.True(t, had, "marker must be set when the live re-parse skipped enrichment")
idx.setReparsePendingEnrichment("a.go", false)
_, had = fileMeta()[graph.MetaReparsePendingEnrichment]
assert.False(t, had, "marker must be cleared when enrichment re-ran")
}