From c1b4ec6d129c135d883c2f2eb75cc6fcee2d67a6 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:53:58 +0200 Subject: [PATCH 1/5] resolver: derived tests edges never route through call resolution The test-linkage pass clones a test caller's calls edges into the tests layer meta-free, so an unresolved clone reaching the resolve cascades is re-bound WITHOUT the original's receiver evidence - every receiver-gated guard is bypassed. Observed in the field: a List call site whose calls edge the extension shape guard correctly refuses had its naked tests clone bound to the repo's lone `this List` extension by the untyped pool-unique fallback at 0.75. Same gate shape as the type-position kinds: the tests layer follows its calls edge, the resolver never binds it independently. --- internal/resolver/resolver.go | 10 +++ .../resolver/tests_edge_resolution_test.go | 68 +++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 internal/resolver/tests_edge_resolution_test.go diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 0bd4b6f50..ab5238351 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3095,6 +3095,16 @@ func (r *Resolver) resolveEdge(e *graph.Edge, stats *ResolveStats) (oldTo string } switch { + case e.Kind == graph.EdgeTests: + // A tests edge is DERIVED: the test-linkage pass clones a test + // caller's calls edges, meta-free. Routing such a clone through + // the call cascades re-runs the bind WITHOUT the original's + // receiver evidence, bypassing every receiver-gated guard (a + // List site's clone bound a `this List` extension + // the guarded calls edge itself refuses). The tests layer + // follows its calls edge; the resolver never binds it + // independently. Same shape as the type-position gate below. + return oldTo, false case strings.HasPrefix(target, "grpc::"): // gRPC client-stub call placeholder // (`unresolved::grpc::::`). Landed on the diff --git a/internal/resolver/tests_edge_resolution_test.go b/internal/resolver/tests_edge_resolution_test.go new file mode 100644 index 000000000..490b17c6c --- /dev/null +++ b/internal/resolver/tests_edge_resolution_test.go @@ -0,0 +1,68 @@ +package resolver + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" +) + +// A tests edge is DERIVED: the test-linkage pass clones a test caller's +// calls edges, meta-free. Routing such a clone through call resolution +// re-runs the bind WITHOUT the original's receiver evidence, bypassing +// every receiver-gated guard. Field shape: a `List` receiver whose +// calls edge the extension shape guard correctly refuses (`this +// List` conflicts) - the naked tests clone of the same site was +// bound by the untyped pool-unique fallback at 0.75. The resolver must +// never bind a tests edge; the tests layer follows its calls edge. +func TestResolveAll_NeverBindsATestsEdge(t *testing.T) { + g := buildCSharpResolverGraph(t, map[string]string{ + "SpecGenArgs.cs": `using System.Collections.Generic; + +namespace Probe.Spec.GenArgs { + public static class ListStretch { + public static int Total(this List xs, int pad) { return pad; } + } + + public class GenRunner { + public int Run() { + var xs = new List(); + return xs.Total(3); + } + } +}`, + }) + + callerID := "SpecGenArgs.cs::GenRunner.Run" + // The clone the test-linkage pass would have minted while the call + // was unresolved: same site, no receiver meta. + g.AddEdge(&graph.Edge{ + From: callerID, To: "unresolved::*.Total", Kind: graph.EdgeTests, + FilePath: "SpecGenArgs.cs", Line: 11, Origin: graph.OriginASTInferred, + }) + + New(g).ResolveAll() + + var testsEdge *graph.Edge + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeTests { + testsEdge = e + } + } + require.NotNil(t, testsEdge, "fixture: the tests clone must survive resolution") + assert.True(t, graph.IsUnresolvedTarget(testsEdge.To), + "the resolver bound a derived tests edge (to %s) - the naked clone bypasses the receiver-gated guards", testsEdge.To) + + // Control: the CALLS edge at the same site keeps its own verdict - + // the shape guard refuses the List vs `this List` + // conflict, so it stays honestly unresolved too, WITH its receiver + // evidence intact. + for _, e := range g.GetOutEdges(callerID) { + if e != nil && e.Kind == graph.EdgeCalls && e.Line == 11 { + assert.True(t, graph.IsUnresolvedTarget(e.To), + "control drifted: the guarded calls edge bound to %s", e.To) + } + } +} From 9971bb03d91ba11bc4f13ccefdf4974957820cce Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:53:58 +0200 Subject: [PATCH 2/5] indexer: test-linkage never clones unresolved calls A tests edge to an unresolved stub can never say what is tested, is dead weight at scale (tens of thousands on a large store - assertion-framework calls from test files dominate), and is the naked-clone hazard the resolver-side gate closes from the other end. Only resolved subjects clone; a call that resolves later simply links on the next reconciliation of its caller. --- internal/indexer/test_edges.go | 11 +++++++++++ internal/indexer/test_edges_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/internal/indexer/test_edges.go b/internal/indexer/test_edges.go index 9ee8e702a..f57055e48 100644 --- a/internal/indexer/test_edges.go +++ b/internal/indexer/test_edges.go @@ -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 @@ -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 diff --git a/internal/indexer/test_edges_test.go b/internal/indexer/test_edges_test.go index c1edcb514..4acb6a066 100644 --- a/internal/indexer/test_edges_test.go +++ b/internal/indexer/test_edges_test.go @@ -349,3 +349,29 @@ 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) + } + } +} From 7f5ac349f1b48f3a3ecd2ca4a2d8eaa5e91a7af2 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:38:37 +0200 Subject: [PATCH 3/5] resolver: tests-edge refusal covers every resolution path The EdgeTests refusal sat inside resolveEdge's heuristic dispatch, so three paths still bound derived tests clones by name with no receiver evidence: the inline LSP hot-path runs before the dispatch, bulk-mode ResolveAll collects its deferred LSP batch before resolveEdge sees the clone at all, and CrossRepoResolver has an independent resolveEdge with no equivalent case. resolutionExempt is now the shared eligibility predicate: resolveEdge consults it first (ahead of the inline LSP lookup), lspDeferTarget refuses exempt edges at collection, and the cross-repository resolveEdge refuses them before its tiers. Regressions pin all three paths: ResolveFile with an answering LSP helper, ResolveAll through the deferred batch, and the cross-repository same-repo name tier. --- internal/resolver/cross_repo.go | 6 ++ internal/resolver/lsp_resolve.go | 7 ++ internal/resolver/resolver.go | 26 +++--- .../resolver/tests_edge_resolution_test.go | 84 +++++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/internal/resolver/cross_repo.go b/internal/resolver/cross_repo.go index 3e969ae0d..7d0830638 100644 --- a/internal/resolver/cross_repo.go +++ b/internal/resolver/cross_repo.go @@ -1043,6 +1043,12 @@ func (cr *CrossRepoResolver) cachedFindNodesByQualName(qualName string) []*graph func (cr *CrossRepoResolver) resolveEdge(e *graph.Edge, stats *CrossRepoStats, batch *[]graph.EdgeReindex) { oldTo := e.To + // Shared with the master resolver: a derived tests clone is never + // bound independently on any path (see resolutionExempt). + if resolutionExempt(e) { + stats.Unresolved++ + return + } // UnresolvedName handles BOTH the bare `unresolved::X` and the // multi-repo `::unresolved::X` forms; a plain TrimPrefix only // strips the bare form, leaving prefixed stubs (which fix-1's widened diff --git a/internal/resolver/lsp_resolve.go b/internal/resolver/lsp_resolve.go index 3be2f0714..33352f636 100644 --- a/internal/resolver/lsp_resolve.go +++ b/internal/resolver/lsp_resolve.go @@ -278,6 +278,13 @@ func (r *Resolver) lspDeferTarget(e *graph.Edge) (string, bool) { if r.lspHelper == nil || e == nil || e.FilePath == "" || e.Line <= 0 { return "", false } + // A resolution-exempt edge (derived tests clone) must not enter the + // batch: the collector runs BEFORE resolveEdge refuses the edge, and + // the deferred bind would take the same receiver-evidence-free lookup + // the refusal exists to prevent. + if resolutionExempt(e) { + return "", false + } if !graph.IsUnresolvedTarget(e.To) { return "", false } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index ab5238351..196cf5a93 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -3057,8 +3057,24 @@ func releaseResolverClone(clone *graph.Edge) { // caller decides whether to call graph.ReindexEdge immediately // (single-threaded ResolveFile) or to defer the reindex (parallel // ResolveAll). When nothing changed the returned bool is false. +// resolutionExempt reports whether the resolver must never bind this edge +// independently, on ANY path — the heuristic cascade, the inline LSP +// hot-path, the deferred bulk LSP batch, and the cross-repository pass all +// consult it. A tests edge is DERIVED: the test-linkage pass clones a test +// caller's calls edges, meta-free. Re-running the bind WITHOUT the +// original's receiver evidence bypasses every receiver-gated guard (a +// List site's clone bound a `this List` extension the guarded +// calls edge itself refuses). The tests layer follows its calls edge; the +// resolver never binds it. +func resolutionExempt(e *graph.Edge) bool { + return e != nil && e.Kind == graph.EdgeTests +} + func (r *Resolver) resolveEdge(e *graph.Edge, stats *ResolveStats) (oldTo string, changed bool) { oldTo = e.To + if resolutionExempt(e) { + return oldTo, false + } // graph.UnresolvedName handles both `unresolved::Name` (legacy) // and `::unresolved::Name` (multi-repo COPY rewrite). // strings.TrimPrefix only stripped the bare form, leaving every @@ -3095,16 +3111,6 @@ func (r *Resolver) resolveEdge(e *graph.Edge, stats *ResolveStats) (oldTo string } switch { - case e.Kind == graph.EdgeTests: - // A tests edge is DERIVED: the test-linkage pass clones a test - // caller's calls edges, meta-free. Routing such a clone through - // the call cascades re-runs the bind WITHOUT the original's - // receiver evidence, bypassing every receiver-gated guard (a - // List site's clone bound a `this List` extension - // the guarded calls edge itself refuses). The tests layer - // follows its calls edge; the resolver never binds it - // independently. Same shape as the type-position gate below. - return oldTo, false case strings.HasPrefix(target, "grpc::"): // gRPC client-stub call placeholder // (`unresolved::grpc::::`). Landed on the diff --git a/internal/resolver/tests_edge_resolution_test.go b/internal/resolver/tests_edge_resolution_test.go index 490b17c6c..3270da6a4 100644 --- a/internal/resolver/tests_edge_resolution_test.go +++ b/internal/resolver/tests_edge_resolution_test.go @@ -66,3 +66,87 @@ namespace Probe.Spec.GenArgs { } } } + +// The refusal must hold on EVERY resolver path, not just the heuristic +// cascade: the inline LSP hot-path answers by (file, line, name) - exactly +// the receiver-evidence-free lookup the tests clone must never take. +func TestResolveFile_LSPNeverBindsATestsEdge(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "src/spec.ts", Kind: graph.KindFile, Name: "spec.ts", FilePath: "src/spec.ts", Language: "typescript"}) + g.AddNode(&graph.Node{ + ID: "src/spec.ts::specRun", Kind: graph.KindFunction, Name: "specRun", + FilePath: "src/spec.ts", StartLine: 3, EndLine: 5, Language: "typescript", + }) + g.AddNode(&graph.Node{ + ID: "src/real.ts::doWork", Kind: graph.KindFunction, Name: "doWork", + FilePath: "src/real.ts", StartLine: 7, EndLine: 9, Language: "typescript", + }) + testsEdge := &graph.Edge{ + From: "src/spec.ts::specRun", To: "unresolved::doWork", + Kind: graph.EdgeTests, FilePath: "src/spec.ts", Line: 4, + } + g.AddEdge(testsEdge) + + helper := &fakeLSPHelper{ + exts: []string{".ts"}, + defs: map[lspKey]lspAnswer{ + {path: "src/spec.ts", line: 4, name: "doWork"}: {defPath: "src/real.ts", defLine: 7}, + }, + } + r := New(g) + r.SetLSPHelper(helper) + r.ResolveFile("src/spec.ts") + + assert.True(t, graph.IsUnresolvedTarget(testsEdge.To), + "the inline LSP path bound a derived tests edge (to %s)", testsEdge.To) +} + +// Bulk-mode ResolveAll defers LSP lookups to a post-loop batch collected +// BEFORE resolveEdge sees the edge - the batch must refuse tests clones too. +func TestResolveAll_DeferredLSPNeverBindsATestsEdge(t *testing.T) { + g := graph.New() + g.AddNode(&graph.Node{ID: "src/spec.ts", Kind: graph.KindFile, Name: "spec.ts", FilePath: "src/spec.ts", Language: "typescript"}) + g.AddNode(&graph.Node{ + ID: "src/spec.ts::specRun", Kind: graph.KindFunction, Name: "specRun", + FilePath: "src/spec.ts", StartLine: 3, EndLine: 5, Language: "typescript", + }) + g.AddNode(&graph.Node{ + ID: "src/real.ts::doWork", Kind: graph.KindFunction, Name: "doWork", + FilePath: "src/real.ts", StartLine: 7, EndLine: 9, Language: "typescript", + }) + testsEdge := &graph.Edge{ + From: "src/spec.ts::specRun", To: "unresolved::doWork", + Kind: graph.EdgeTests, FilePath: "src/spec.ts", Line: 4, + } + g.AddEdge(testsEdge) + + helper := &fakeLSPHelper{ + exts: []string{".ts"}, + defs: map[lspKey]lspAnswer{ + {path: "src/spec.ts", line: 4, name: "doWork"}: {defPath: "src/real.ts", defLine: 7}, + }, + } + r := New(g) + r.SetLSPHelper(helper) + r.ResolveAll() + + assert.True(t, graph.IsUnresolvedTarget(testsEdge.To), + "the deferred LSP batch bound a derived tests edge (to %s)", testsEdge.To) +} + +// CrossRepoResolver has its own independent resolveEdge; the same-repo +// name tier would happily bind the naked clone by bare name. +func TestCrossRepoResolveAll_NeverBindsATestsEdge(t *testing.T) { + g := graph.New() + 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"}) + g.AddNode(&graph.Node{ID: "repoA/pkg/b.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoA/pkg/b.go", Language: "go", RepoPrefix: "repoA"}) + + testsEdge := &graph.Edge{From: "repoA/pkg/a_test.go::TestCaller", To: "unresolved::Helper", Kind: graph.EdgeTests, FilePath: "repoA/pkg/a_test.go", Line: 5} + g.AddEdge(testsEdge) + + cr := NewCrossRepo(g) + cr.ResolveAll() + + assert.True(t, graph.IsUnresolvedTarget(testsEdge.To), + "the cross-repository pass bound a derived tests edge (to %s)", testsEdge.To) +} From 78358bbba381e7aace27b3ea99cfa75094d61c58 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:57:47 +0200 Subject: [PATCH 4/5] indexer: reconcile tests projections for calls that resolve later The test projection skips unresolved calls, so a call that binds AFTER its caller was projected needs the caller reconciled - but the definition-side derived plan never names the caller file, and its flags can be declaration-only, so the coordinator skipped test projection entirely. The valid EdgeTests was then permanently absent: the caller file is not necessarily ever revisited. Resolution passes now report their retargeted test-call frontier: every core apply site (the parallel ResolveAll batch, the incremental/file apply, the deferred LSP batch, and the cross-repository resolveEdge) notes the caller file of a calls edge it bound when that caller is test-classified (test file path, or an is_test-stamped source symbol - annotation-marked tests live outside test-named paths). The frontier is instance-scoped and drained destructively, so throwaway cold-warmup resolvers leak nothing and the whole-graph test emission that follows warmup stays the single owner of that scale. Consumers: the derived coordinator drains the per-repo resolvers and reconciles the frontier regardless of plan flags; the receipt-exact catch-up lanes (master file/incoming resolves, both cross-repository wrappers) drain their own resolver instances and reconcile inline, since no global test pass runs behind them. Regressions: the declaration-only plan shape (the reported repro), the incremental add and rebind flows end to end (rebind also pins that the stale projection to the evicted subject disappears), and the cross-repository inbound test call. --- internal/indexer/incremental_derived.go | 38 +++++- internal/indexer/multi.go | 16 +++ internal/indexer/test_edges_test.go | 153 ++++++++++++++++++++++++ internal/indexer/workspace_resolve.go | 4 + internal/resolver/cross_repo.go | 45 +++++++ internal/resolver/lsp_resolve.go | 3 + internal/resolver/resolver.go | 18 +++ internal/resolver/testpath.go | 59 ++++++++- 8 files changed, 334 insertions(+), 2 deletions(-) diff --git a/internal/indexer/incremental_derived.go b/internal/indexer/incremental_derived.go index 5a49b1d81..fda57d986 100644 --- a/internal/indexer/incremental_derived.go +++ b/internal/indexer/incremental_derived.go @@ -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. @@ -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 diff --git a/internal/indexer/multi.go b/internal/indexer/multi.go index 80a2237e0..b53379069 100644 --- a/internal/indexer/multi.go +++ b/internal/indexer/multi.go @@ -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 diff --git a/internal/indexer/test_edges_test.go b/internal/indexer/test_edges_test.go index 4acb6a066..92fc35ff8 100644 --- a/internal/indexer/test_edges_test.go +++ b/internal/indexer/test_edges_test.go @@ -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) { @@ -375,3 +380,151 @@ func TestMarkTestSymbolsAndEmitEdges_SkipsUnresolvedCallTargets(t *testing.T) { } } } + +// 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") + } +} diff --git a/internal/indexer/workspace_resolve.go b/internal/indexer/workspace_resolve.go index b16d81a52..42ea5a696 100644 --- a/internal/indexer/workspace_resolve.go +++ b/internal/indexer/workspace_resolve.go @@ -483,6 +483,9 @@ func (mi *MultiIndexer) runCrossRepoResolveContext(ctx context.Context, reconcil if reconcileContracts { mi.ReconcileContractEdges() } + // Bounded by the calls this pass actually bound; the deferred + // fallback lane runs with no global test-edges pass behind it. + mi.reconcileRetargetedTestCalls(cr.TakeRetargetedTestCallFiles()) return nil } @@ -507,6 +510,7 @@ func (mi *MultiIndexer) runCrossRepoResolveMutationFrontiers(resolutionFiles, ed return } cr.ResolveMutationFrontiers(resolutionFiles, edgeSourceFiles, definitionFiles) + mi.reconcileRetargetedTestCalls(cr.TakeRetargetedTestCallFiles()) } // crossWorkspaceLookup builds a resolver.CrossWorkspaceDepLookup from diff --git a/internal/resolver/cross_repo.go b/internal/resolver/cross_repo.go index 7d0830638..afb3470c4 100644 --- a/internal/resolver/cross_repo.go +++ b/internal/resolver/cross_repo.go @@ -158,6 +158,50 @@ type CrossRepoResolver struct { edgesEnabled bool prober RemoteDeclarationProber proxyBudget int + + // retargetedTestCallFiles mirrors Resolver.retargetedTestCallFiles for + // the cross-repository pass: caller files of test-classified calls this + // pass bound, drained via TakeRetargetedTestCallFiles so the indexer + // can reconcile their tests projections. + retargetedMu sync.Mutex + retargetedTestCallFiles map[string]struct{} +} + +// noteRetargetedCall mirrors Resolver.noteRetargetedCall for the +// cross-repository pass. +func (cr *CrossRepoResolver) noteRetargetedCall(e *graph.Edge) { + if e == nil || e.Kind != graph.EdgeCalls || e.FilePath == "" { + return + } + if graph.IsUnresolvedTarget(e.To) { + return + } + if !isTestFilePath(e.FilePath) && !nodeStampedTest(cr.cachedGetNode(e.From)) { + return + } + cr.retargetedMu.Lock() + if cr.retargetedTestCallFiles == nil { + cr.retargetedTestCallFiles = make(map[string]struct{}) + } + cr.retargetedTestCallFiles[e.FilePath] = struct{}{} + cr.retargetedMu.Unlock() +} + +// TakeRetargetedTestCallFiles drains the accumulated test-caller frontier, +// sorted for determinism. +func (cr *CrossRepoResolver) TakeRetargetedTestCallFiles() []string { + cr.retargetedMu.Lock() + defer cr.retargetedMu.Unlock() + if len(cr.retargetedTestCallFiles) == 0 { + return nil + } + files := make([]string, 0, len(cr.retargetedTestCallFiles)) + for file := range cr.retargetedTestCallFiles { + files = append(files, file) + } + cr.retargetedTestCallFiles = nil + sort.Strings(files) + return files } // NewCrossRepo creates a CrossRepoResolver for the given graph. @@ -1088,6 +1132,7 @@ func (cr *CrossRepoResolver) resolveEdge(e *graph.Edge, stats *CrossRepoStats, b if e.To != oldTo { *batch = append(*batch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) + cr.noteRetargetedCall(e) } } diff --git a/internal/resolver/lsp_resolve.go b/internal/resolver/lsp_resolve.go index 33352f636..4d9e2142c 100644 --- a/internal/resolver/lsp_resolve.go +++ b/internal/resolver/lsp_resolve.go @@ -490,6 +490,9 @@ func (r *Resolver) resolveDeferredLSPWithPassBudget( r.noteImportEdgeReindexes(reindexBatch) r.graph.ReindexEdges(reindexBatch) reconcilePlaceholderSources(r.graph, &r.placeholderSrcIdx, reindexBatch) + for _, ri := range reindexBatch { + r.noteRetargetedCall(ri.Edge) + } } return result } diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go index 196cf5a93..290ccd965 100644 --- a/internal/resolver/resolver.go +++ b/internal/resolver/resolver.go @@ -218,6 +218,18 @@ type Resolver struct { // (filepathlite.Dir/Clean dominate). Read-only after build, so the // workers share it lock-free. dirByFilePath map[string]string + // retargetedTestCallFiles accumulates the caller files of EdgeCalls + // edges a resolution pass bound where the caller is test-classified + // (test file path, or an is_test-stamped source symbol). The test + // projection skips unresolved calls, so a call that resolves LATER + // needs its caller reconciled — but the definition-side derived plan + // never names the caller file. Consumers drain this frontier via + // TakeRetargetedTestCallFiles after resolution and re-run the scoped + // test projection over it. Guarded by retargetedMu: the parallel + // apply loop holds r.mu, but single-file passes and the deferred LSP + // apply note entries on their own paths. + retargetedMu sync.Mutex + retargetedTestCallFiles map[string]struct{} // importEdgeGen counts imports-kind edge writes noted while a resolve // pass may hold pass-scoped import-adjacency retention. Write-site // verdicts live at noteImportEdgeWrite's callers. @@ -1057,6 +1069,9 @@ func (r *Resolver) ResolveAllContext(ctx context.Context) (*ResolveStats, error) placeholderStart := time.Now() reconcilePlaceholderSources(r.graph, &r.placeholderSrcIdx, reindexBatch) applyPlaceholderElapsed += time.Since(placeholderStart) + for _, ri := range reindexBatch { + r.noteRetargetedCall(ri.Edge) + } reindexTotal += len(reindexBatch) if pageRevisionKnown { // Ignore this pass's own committed mutations. A later delta @@ -2750,6 +2765,9 @@ func (r *Resolver) applyIncrementalReindexesLocked( // nil index: incremental batches are file-sized, direct probes // stay under the single-save latency budget. reconcilePlaceholderSources(r.graph, nil, reindexBatch) + for _, ri := range reindexBatch { + r.noteRetargetedCall(ri.Edge) + } } // Cross-package name-match guard — same contract as in ResolveAll. if len(jobs) == 0 { diff --git a/internal/resolver/testpath.go b/internal/resolver/testpath.go index c6ba995f0..6936c4237 100644 --- a/internal/resolver/testpath.go +++ b/internal/resolver/testpath.go @@ -1,6 +1,11 @@ package resolver -import "github.com/zzet/gortex/internal/testpath" +import ( + "sort" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/testpath" +) // isTestFilePath reports whether a source path follows a recognised test // convention. @@ -18,3 +23,55 @@ import "github.com/zzet/gortex/internal/testpath" // // KEYWORDS: test-file, predicate, temporal, broken_dispatch, no-cycle func isTestFilePath(path string) bool { return testpath.IsTestFile(path) } + +// noteRetargetedCall records the caller file of a calls edge a resolution +// pass just bound, when that caller is test-classified — by file path or +// by an is_test stamp on the source symbol (annotation-marked tests can +// live outside test-named paths). The test projection skips unresolved +// calls, so a call that binds later must have its caller's projection +// reconciled; the drained frontier (TakeRetargetedTestCallFiles) is how +// the indexer learns which callers those are. Bounded: only calls edges, +// only test-classified callers, one entry per file. +func (r *Resolver) noteRetargetedCall(e *graph.Edge) { + if e == nil || e.Kind != graph.EdgeCalls || e.FilePath == "" { + return + } + if graph.IsUnresolvedTarget(e.To) { + return + } + if !isTestFilePath(e.FilePath) && !nodeStampedTest(r.cachedGetNode(e.From)) { + return + } + r.retargetedMu.Lock() + if r.retargetedTestCallFiles == nil { + r.retargetedTestCallFiles = make(map[string]struct{}) + } + r.retargetedTestCallFiles[e.FilePath] = struct{}{} + r.retargetedMu.Unlock() +} + +// TakeRetargetedTestCallFiles drains the accumulated test-caller frontier, +// sorted for determinism. The caller re-runs the scoped test projection +// over the returned files. +func (r *Resolver) TakeRetargetedTestCallFiles() []string { + r.retargetedMu.Lock() + defer r.retargetedMu.Unlock() + if len(r.retargetedTestCallFiles) == 0 { + return nil + } + files := make([]string, 0, len(r.retargetedTestCallFiles)) + for file := range r.retargetedTestCallFiles { + files = append(files, file) + } + r.retargetedTestCallFiles = nil + sort.Strings(files) + return files +} + +func nodeStampedTest(n *graph.Node) bool { + if n == nil || n.Meta == nil { + return false + } + value, _ := n.Meta["is_test"].(bool) + return value +} From 5910a1de723bea1ba3cb1f515f4ac3ef1efc1870 Mon Sep 17 00:00:00 2001 From: pbednarcik <29061766+pbednarcik@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:07:04 +0200 Subject: [PATCH 5/5] store_sqlite: purge legacy unresolved derived tests edges on upgrade The emission guard prevents new unresolved EdgeTests clones, but rows persisted by older versions stay: whole-graph emission only adds current projections, warm startup may skip file-scoped reconciliation entirely, and the resolver now refuses to bind a tests edge - so a pre-guard store keeps its naked stub rows and their resolver-scan cost forever. Schema v13 deletes tests-kind edges whose target is an unresolved stub in either spelling (bare and repo-prefixed COPY-rewrite), leaving pending calls and resolved projections untouched. Seed/close/reopen upgrade regression included. --- internal/graph/store_sqlite/schema_version.go | 19 +++++- .../store_sqlite/tests_edge_purge_test.go | 62 +++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 internal/graph/store_sqlite/tests_edge_purge_test.go diff --git a/internal/graph/store_sqlite/schema_version.go b/internal/graph/store_sqlite/schema_version.go index 22ce83d7f..43618960b 100644 --- a/internal/graph/store_sqlite/schema_version.go +++ b/internal/graph/store_sqlite/schema_version.go @@ -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 @@ -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 +// `::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 diff --git a/internal/graph/store_sqlite/tests_edge_purge_test.go b/internal/graph/store_sqlite/tests_edge_purge_test.go new file mode 100644 index 000000000..4d107bcb6 --- /dev/null +++ b/internal/graph/store_sqlite/tests_edge_purge_test.go @@ -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") +}