Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion internal/graph/store_sqlite/schema_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import (
// index changes in a way an old on-disk DB would not already have, and append a
// matching schemaMigrations entry describing how to bring an older store
// forward (in place, or by rebuild).
const currentSchemaVersion = 12
const currentSchemaVersion = 13

// schemaMigration is one forward step. Exactly one strategy applies:
// - rebuild=true: the change introduces structure/data that can only come
Expand Down Expand Up @@ -80,6 +80,23 @@ var schemaMigrations = []schemaMigration{
{version: 10, name: "rebuild vector corpus ownership and parents", inPlace: rebuildVectorCorpusSchema},
{version: 11, name: "add symbol FTS normalization state", inPlace: createSymbolFTSNormalizationStateTable},
{version: 12, name: "normalize dir column separators", inPlace: normalizeDirColumnSeparators},
{version: 13, name: "purge unresolved derived tests edges", inPlace: purgeUnresolvedTestsEdges},
}

// purgeUnresolvedTestsEdges removes derived EdgeTests rows whose target is
// an unresolved stub, in both spellings (`unresolved::X` and the multi-repo
// `<repo>::unresolved::X` COPY-rewrite form). The test-linkage pass cloned
// them from unresolved calls before the emission guard existed; stripped of
// the call's receiver evidence they are naked stubs the resolver now
// refuses to bind, new emission never re-creates them, and warm startup may
// skip file-scoped reconciliation entirely — so an old store keeps paying
// their resolver-scan cost forever without this explicit purge. Idempotent
// and bounded to the tests kind: pending calls and resolved projections are
// untouched.
func purgeUnresolvedTestsEdges(tx *sql.Tx) error {
_, err := tx.Exec(`DELETE FROM edges WHERE kind = 'tests'
AND (to_id LIKE 'unresolved::%' OR to_id LIKE '%::unresolved::%')`)
return err
}

// normalizeDirColumnSeparators rebuilds the two generated dir columns whose
Expand Down
62 changes: 62 additions & 0 deletions internal/graph/store_sqlite/tests_edge_purge_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package store_sqlite

import (
"database/sql"
"path/filepath"
"testing"

"github.com/stretchr/testify/require"

"github.com/zzet/gortex/internal/graph"
)

// TestOpenPurgesLegacyUnresolvedTestsEdges is the upgrade proof for the
// unresolved-tests-edge purge: stores written before the emission guard
// hold derived EdgeTests rows cloned from unresolved calls (naked stubs
// nothing may ever bind). New emission never re-creates them, and warm
// startup may skip file-scoped reconciliation entirely, so an explicit
// versioned migration removes them. Every other row — resolved tests
// projections and ordinary unresolved calls — must survive untouched.
func TestOpenPurgesLegacyUnresolvedTestsEdges(t *testing.T) {
path := filepath.Join(t.TempDir(), "store.sqlite")

s, err := Open(path)
require.NoError(t, err)
s.AddNode(&graph.Node{ID: "r/a_test.go::TestFoo", Kind: graph.KindFunction, Name: "TestFoo", FilePath: "r/a_test.go", RepoPrefix: "r"})
s.AddNode(&graph.Node{ID: "r/b.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "r/b.go", RepoPrefix: "r"})
s.AddBatch(nil, []*graph.Edge{
// A healthy resolved projection: must survive.
{From: "r/a_test.go::TestFoo", To: "r/b.go::Foo", Kind: graph.EdgeTests, FilePath: "r/a_test.go", Line: 5},
// Legacy unresolved clones in both stub spellings: must be purged.
{From: "r/a_test.go::TestFoo", To: graph.UnresolvedMarker + "Gone", Kind: graph.EdgeTests, FilePath: "r/a_test.go", Line: 6},
{From: "r/a_test.go::TestFoo", To: "r::" + graph.UnresolvedMarker + "*.Gone", Kind: graph.EdgeTests, FilePath: "r/a_test.go", Line: 7},
// An ordinary pending call: NOT a tests edge, must survive.
{From: "r/a_test.go::TestFoo", To: graph.UnresolvedMarker + "Gone", Kind: graph.EdgeCalls, FilePath: "r/a_test.go", Line: 6},
})
require.NoError(t, s.Close())

// Simulate a store written before the purge shipped.
withRawDB(t, path, func(db *sql.DB) {
_, err := db.Exec(`PRAGMA user_version = 12`)
require.NoError(t, err, "reset to the pre-purge version")
})

s2, err := Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = s2.Close() })

var kinds []string
for _, e := range s2.GetOutEdges("r/a_test.go::TestFoo") {
if e == nil {
continue
}
if e.Kind == graph.EdgeTests {
require.False(t, graph.IsUnresolvedTarget(e.To),
"legacy unresolved tests edge survived the upgrade: %+v", e)
}
kinds = append(kinds, string(e.Kind)+"->"+e.To)
}
require.Contains(t, kinds, "tests->r/b.go::Foo", "the resolved projection must survive")
require.Contains(t, kinds, "calls->"+graph.UnresolvedMarker+"Gone", "the pending call must survive")
require.Len(t, kinds, 2, "exactly the two healthy edges remain")
}
38 changes: 37 additions & 1 deletion internal/indexer/incremental_derived.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ func (idx *Indexer) runStandaloneIncrementalDerivedPasses(plan DerivedInvalidati
})
}

// drainRetargetedTestCallFiles collects, from each named repo's resolver,
// the caller files of test-classified calls the resolution step just bound
// (see resolver.Resolver.TakeRetargetedTestCallFiles). Destructive: the
// per-resolver frontier is cleared.
func (mi *MultiIndexer) drainRetargetedTestCallFiles(prefixSet map[string]struct{}) []string {
mi.mu.RLock()
resolvers := make([]*resolver.Resolver, 0, len(prefixSet))
for prefix := range prefixSet {
if idx := mi.indexers[prefix]; idx != nil && idx.resolver != nil {
resolvers = append(resolvers, idx.resolver)
}
}
mi.mu.RUnlock()
var files []string
for _, r := range resolvers {
files = append(files, r.TakeRetargetedTestCallFiles()...)
}
if len(files) == 0 {
return nil
}
return appendUniqueSorted(nil, files...)
}

// RunIncrementalDerivedPasses executes only the derived families invalidated
// by the exact per-file plans. A legacy database without persisted fingerprints
// takes the old scoped-global path once; ordinary body/metadata edits never do.
Expand Down Expand Up @@ -189,8 +212,21 @@ func (mi *MultiIndexer) runIncrementalDerivedPassesTopologyHeld(
report.Overrides = r.InferOverridesScoped(typeFrontier)
}

// The resolution step of this same apply may have bound pending calls
// whose TEST callers live outside merged.Files (the definition-side
// plan never names the caller file, and its flags may be declaration-
// only). Drain that retargeted frontier and reconcile those callers
// regardless of plan flags — without it a call that resolves later
// than its projection pass never gains its EdgeTests.
retargeted := mi.drainRetargetedTestCallFiles(prefixSet)
if merged.Flags.Has(DerivedInvalidatesRuntime) || merged.Flags.Has(DerivedInvalidatesTests) {
report.TestSymbols, report.TestEdges = markTestSymbolsAndEmitEdgesScoped(mi.graph, scopedPrefixes, merged.Files...)
files := merged.Files
if len(files) > 0 && len(retargeted) > 0 {
files = appendUniqueSorted(append([]string(nil), files...), retargeted...)
}
report.TestSymbols, report.TestEdges = markTestSymbolsAndEmitEdgesScoped(mi.graph, scopedPrefixes, files...)
} else if len(retargeted) > 0 {
report.TestSymbols, report.TestEdges = markTestSymbolsAndEmitEdgesScoped(mi.graph, scopedPrefixes, retargeted...)
}
if merged.Flags.Has(DerivedInvalidatesDeclarations) && len(merged.TypeIDs) > 0 {
// A save that adds/re-parents a type can hang a new derived type
Expand Down
16 changes: 16 additions & 0 deletions internal/indexer/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,22 @@ func (mi *MultiIndexer) runMasterResolveFiles(files []string, useLSP bool) {
zap.Int("files", len(files)),
zap.Int("pending_scanned", stats.PendingBefore),
zap.Int("pending_admitted", stats.PendingAfter))
mi.reconcileRetargetedTestCalls(master.TakeRetargetedTestCallFiles())
}

// reconcileRetargetedTestCalls re-runs the scoped test projection over the
// caller files of test-classified calls a resolution pass just bound. The
// receipt-exact catch-up lanes run with no global test-edges pass behind
// them, so a call that binds later than its caller's projection would
// otherwise never gain its EdgeTests. No-op on an empty frontier.
func (mi *MultiIndexer) reconcileRetargetedTestCalls(files []string) {
if len(files) == 0 {
return
}
_, emitted := markTestSymbolsAndEmitEdgesScoped(mi.graph, nil, files...)
mi.logger.Info("DEFERRED-TIMING test-edges reconcile for retargeted callers",
zap.Int("files", len(files)),
zap.Int("edges", emitted))
}

// RunPreEnrichResolve runs the resolution stage that makes references queryable
Expand Down
11 changes: 11 additions & 0 deletions internal/indexer/test_edges.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,10 @@ func markTestSymbolsAndEmitEdgesForFilesLocked(g graph.Store, changedFiles []str
if edge == nil || edge.Kind != graph.EdgeCalls || isTestTarget(edge.To) {
continue
}
// Unresolved calls never clone — see emitTestEdgesAndPersistLocked.
if graph.IsUnresolvedTarget(edge.To) {
continue
}
key := edge.From + "\x00" + edge.To
if seenEdges[key] {
continue
Expand Down Expand Up @@ -705,6 +709,13 @@ func emitTestEdgesAndPersistLocked(g graph.Store, testNodes map[string]bool, cha
if testNodes[to] {
return // test → test calls are infrastructure, not subject coverage
}
// An unresolved call clones into a tests edge that can never say
// what is tested — and, stripped of the call's receiver evidence,
// it is a naked stub a later resolve pass would bind without the
// guards that protect the calls edge. Only resolved subjects link.
if graph.IsUnresolvedTarget(to) {
return
}
key := graph.EdgeEndpoint{From: from, To: to}
if _, duplicate := seen[key]; duplicate {
return
Expand Down
179 changes: 179 additions & 0 deletions internal/indexer/test_edges_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package indexer

import (
"context"
"os"
"path/filepath"
"testing"

"go.uber.org/zap"

"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/graph/store_sqlite"
"github.com/zzet/gortex/internal/search"
)

func TestMarkTestSymbolsAndEmitEdges_GoStyle(t *testing.T) {
Expand Down Expand Up @@ -349,3 +354,177 @@ func TestMarkTestSymbolsAndEmitEdges_PersistsMetadataAcrossSQLiteReload(t *testi
t.Fatalf("persisted EdgeTests = %d, want 2", persistedEdges)
}
}

func TestMarkTestSymbolsAndEmitEdges_SkipsUnresolvedCallTargets(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "pkg/foo.go", Kind: graph.KindFile, Name: "pkg/foo.go", FilePath: "pkg/foo.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg/foo_test.go", Kind: graph.KindFile, Name: "pkg/foo_test.go", FilePath: "pkg/foo_test.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg/foo.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "pkg/foo.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg/foo_test.go::TestFoo", Kind: graph.KindFunction, Name: "TestFoo", FilePath: "pkg/foo_test.go", Language: "go"})

// One resolved subject call, one still-unresolved call (an assertion
// framework member the repo can never bind). Cloning the unresolved
// call would mint a tests edge with NONE of the original's receiver
// evidence - a later resolve pass then re-binds the naked clone
// without the guards that protect the calls edge.
g.AddEdge(&graph.Edge{From: "pkg/foo_test.go::TestFoo", To: "pkg/foo.go::Foo", Kind: graph.EdgeCalls, FilePath: "pkg/foo_test.go", Line: 10})
g.AddEdge(&graph.Edge{From: "pkg/foo_test.go::TestFoo", To: "unresolved::*.Equal", Kind: graph.EdgeCalls, FilePath: "pkg/foo_test.go", Line: 11})

_, emitted := markTestSymbolsAndEmitEdges(g)
if emitted != 1 {
t.Fatalf("expected 1 EdgeTests (the resolved subject only), got %d", emitted)
}
for _, e := range g.AllEdges() {
if e.Kind == graph.EdgeTests && graph.IsUnresolvedTarget(e.To) {
t.Fatalf("tests edge cloned an unresolved call: %+v", e)
}
}
}

// findEdge returns the first edge with the given kind, from, and to.
func findEdge(g graph.Store, kind graph.EdgeKind, from, to string) *graph.Edge {
for _, e := range g.AllEdges() {
if e != nil && e.Kind == kind && e.From == from && e.To == to {
return e
}
}
return nil
}

// A call that is unresolved at projection time is correctly skipped - but
// when the subject is added later and the incoming frontier binds the
// call, the tests projection must be reconciled for the caller. The
// definition-side derived plan never names the caller file, so without a
// retarget frontier the valid EdgeTests is permanently absent.
func TestIncrementalReindex_LaterResolvedCallGainsTestsProjection(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "foo_test.go"),
"package pkg\n\nimport \"testing\"\n\nfunc TestFoo(t *testing.T) { Foo() }\n")
writeFile(t, filepath.Join(dir, "foo.go"), "package pkg\n\nfunc Unrelated() {}\n")
g := graph.New()
idx := newTestIndexer(g)
if _, err := idx.Index(dir); err != nil {
t.Fatalf("cold index: %v", err)
}
if e := findEdge(g, graph.EdgeTests, "foo_test.go::TestFoo", "foo.go::Foo"); e != nil {
t.Fatalf("tests projection minted for an unresolved call: %+v", e)
}

// The subject arrives later as an EDIT to an existing production
// file - a declaration-level delta whose derived plan never names
// the caller file. The incremental pass binds the pending caller
// through the incoming frontier.
bumpMtime(t, filepath.Join(dir, "foo.go"),
"package pkg\n\nfunc Unrelated() {}\n\nfunc Foo() {}\n")
if _, err := idx.IncrementalReindexPaths(dir, nil); err != nil {
t.Fatalf("incremental reindex: %v", err)
}

if e := findEdge(g, graph.EdgeCalls, "foo_test.go::TestFoo", "foo.go::Foo"); e == nil {
t.Fatalf("fixture: the pending call must bind once the subject exists")
}
if e := findEdge(g, graph.EdgeTests, "foo_test.go::TestFoo", "foo.go::Foo"); e == nil {
t.Fatalf("resolved test call has no EdgeTests projection")
}
}

// The rebind sibling: the subject moves to another file, the caller file
// itself never changes - the projection must follow the retargeted call.
func TestIncrementalReindex_RebindMovesTestsProjection(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "foo_test.go"),
"package pkg\n\nimport \"testing\"\n\nfunc TestFoo(t *testing.T) { Foo() }\n")
writeFile(t, filepath.Join(dir, "foo.go"), "package pkg\n\nfunc Foo() {}\n")
g := graph.New()
idx := newTestIndexer(g)
if _, err := idx.Index(dir); err != nil {
t.Fatalf("cold index: %v", err)
}
if e := findEdge(g, graph.EdgeTests, "foo_test.go::TestFoo", "foo.go::Foo"); e == nil {
t.Fatalf("fixture: cold index must project the resolved test call")
}

// Move the subject: delete foo.go, define Foo in bar.go.
if err := os.Remove(filepath.Join(dir, "foo.go")); err != nil {
t.Fatalf("remove foo.go: %v", err)
}
writeFile(t, filepath.Join(dir, "bar.go"), "package pkg\n\nfunc Foo() {}\n")
if _, err := idx.IncrementalReindexPaths(dir, nil); err != nil {
t.Fatalf("incremental reindex: %v", err)
}

if e := findEdge(g, graph.EdgeCalls, "foo_test.go::TestFoo", "bar.go::Foo"); e == nil {
t.Fatalf("fixture: the restubbed call must rebind to the moved subject")
}
if e := findEdge(g, graph.EdgeTests, "foo_test.go::TestFoo", "bar.go::Foo"); e == nil {
t.Fatalf("rebound test call has no EdgeTests projection")
}
if e := findEdge(g, graph.EdgeTests, "foo_test.go::TestFoo", "foo.go::Foo"); e != nil {
t.Fatalf("stale projection to the evicted subject survived: %+v", e)
}
}

// The declaration-only shape: a definition-side derived plan carries
// neither the runtime nor the tests flag, so the coordinator previously
// skipped test projection entirely - even though the resolution step of
// the same apply just bound a pending TEST caller to the new subject.
// The resolution pass must report its retargeted test-call frontier and
// the coordinator must reconcile those callers regardless of plan flags.
func TestDeclarationOnlyPlanReconcilesLaterResolvedTestCall(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "pkg/foo_test.go", Kind: graph.KindFile, Name: "pkg/foo_test.go", FilePath: "pkg/foo_test.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg/foo_test.go::TestFoo", Kind: graph.KindFunction, Name: "TestFoo", FilePath: "pkg/foo_test.go", Language: "go"})
call := &graph.Edge{From: "pkg/foo_test.go::TestFoo", To: graph.UnresolvedMarker + "Foo", Kind: graph.EdgeCalls, FilePath: "pkg/foo_test.go", Line: 5}
g.AddEdge(call)
if _, emitted := markTestSymbolsAndEmitEdges(g); emitted != 0 {
t.Fatalf("fixture: an unresolved call must not project, emitted %d", emitted)
}

// The subject arrives; the incoming pass binds the pending caller.
g.AddNode(&graph.Node{ID: "pkg/foo.go", Kind: graph.KindFile, Name: "pkg/foo.go", FilePath: "pkg/foo.go", Language: "go"})
g.AddNode(&graph.Node{ID: "pkg/foo.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "pkg/foo.go", Language: "go"})
idx := newTestIndexer(g)
idx.resolver.ResolveIncomingForFile("pkg/foo.go")
if call.To != "pkg/foo.go::Foo" {
t.Fatalf("fixture: the pending call must bind, got %q", call.To)
}

idx.runStandaloneIncrementalDerivedPasses(DerivedInvalidationPlan{
Flags: DerivedInvalidatesDeclarations,
Files: []string{"pkg/foo.go"},
})

if e := findEdge(g, graph.EdgeTests, "pkg/foo_test.go::TestFoo", "pkg/foo.go::Foo"); e == nil {
t.Fatalf("resolved test call has no EdgeTests projection")
}
}

// The cross-repository sibling: an inbound test call from repoA binds into
// a repoB subject through the cross-repo pass, which has no derived plan
// at all - the reconcile must ride the pass itself.
func TestCrossRepoResolveReconcilesRetargetedTestCall(t *testing.T) {
g := graph.New()
g.AddNode(&graph.Node{ID: "repoA/pkg/a_test.go", Kind: graph.KindFile, Name: "a_test.go", FilePath: "repoA/pkg/a_test.go", Language: "go", RepoPrefix: "repoA", WorkspaceID: "ws"})
g.AddNode(&graph.Node{ID: "repoA/pkg/a_test.go::TestCaller", Kind: graph.KindFunction, Name: "TestCaller", FilePath: "repoA/pkg/a_test.go", Language: "go", RepoPrefix: "repoA", WorkspaceID: "ws"})
g.AddNode(&graph.Node{ID: "repoB/lib/c.go", Kind: graph.KindFile, Name: "c.go", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB", WorkspaceID: "ws"})
g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB", WorkspaceID: "ws"})
// Import-reachability evidence for the cross-repo fallback.
g.AddEdge(&graph.Edge{From: "repoA/pkg/a_test.go", To: "repoB/lib/c.go", Kind: graph.EdgeImports, FilePath: "repoA/pkg/a_test.go", Line: 1})
call := &graph.Edge{From: "repoA/pkg/a_test.go::TestCaller", To: graph.UnresolvedMarker + "Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a_test.go", Line: 5}
g.AddEdge(call)

if _, emitted := markTestSymbolsAndEmitEdges(g); emitted != 0 {
t.Fatalf("fixture: an unresolved call must not project, emitted %d", emitted)
}

mi := NewMultiIndexer(g, newTestRegistry(), search.NewNull(), nil, zap.NewNop())
if err := mi.runCrossRepoResolveContext(context.Background(), false); err != nil {
t.Fatalf("cross-repo resolve: %v", err)
}
if call.To != "repoB/lib/c.go::Helper" {
t.Fatalf("fixture: the cross-repo pass must bind the call, got %q", call.To)
}
if e := findEdge(g, graph.EdgeTests, "repoA/pkg/a_test.go::TestCaller", "repoB/lib/c.go::Helper"); e == nil {
t.Fatalf("cross-repo resolved test call has no EdgeTests projection")
}
}
Loading
Loading