package resolver import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" "pgregory.net/rapid" ) // --- Unit tests for CrossRepoResolver (Task 7.1) --- // wireImport adds a resolved EdgeImports edge from callerFile into a // file node in targetRepo, plus the target file node itself. This is // the import-reachability *evidence* CrossRepoResolver now requires // before it will resolve a name-only call across a repo boundary — // without it, a bare name like `Helper` could land on any repo that // happens to define a `Helper`, which is the exact name-collision // false-positive class this guards against. func wireImport(g graph.Store, callerFile, targetRepo, targetFile string) { g.AddNode(&graph.Node{ ID: targetFile, Kind: graph.KindFile, Name: targetFile, FilePath: targetFile, Language: "go", RepoPrefix: targetRepo, }) g.AddEdge(&graph.Edge{ From: callerFile, To: targetFile, Kind: graph.EdgeImports, FilePath: callerFile, Line: 1, }) } func TestCrossRepoResolveAll_SameRepoPreferred(t *testing.T) { g := graph.New() // Repo A: caller and a target function. g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.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"}) // Repo B: same-named function. g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"}) edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 1, stats.Resolved) assert.Equal(t, 0, stats.CrossRepoEdges) assert.Equal(t, "repoA/pkg/b.go::Helper", edge.To) assert.False(t, edge.CrossRepo) } // With an import edge proving repoA reaches repoB, the cross-repo // fallback resolves — this is the legitimate cross-repo call case. func TestCrossRepoResolveAll_CrossRepoFallback(t *testing.T) { g := graph.New() // Repo A: caller, no matching function. g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"}) // Repo B: target function. g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"}) // Evidence: repoA's caller file imports repoB. wireImport(g, "repoA/pkg/a.go", "repoB", "repoB/lib/c.go") edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 1, stats.Resolved) assert.Equal(t, 1, stats.CrossRepoEdges) assert.Equal(t, "repoB/lib/c.go::Helper", edge.To) assert.True(t, edge.CrossRepo) assert.Equal(t, 1, stats.ByRepo["repoB"]) } // Without an import edge, the SAME graph must NOT resolve the call // across the repo boundary — it stays unresolved. This is the // regression guard for the M3 false-positive class: a name-only match // in a repo the caller never imports is never selected. func TestCrossRepoResolveAll_RefusesUnreachableCrossRepo(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"}) g.AddNode(&graph.Node{ID: "repoB/lib/c.go::Helper", Kind: graph.KindFunction, Name: "Helper", FilePath: "repoB/lib/c.go", Language: "go", RepoPrefix: "repoB"}) edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::Helper", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 5} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 0, stats.Resolved) assert.Equal(t, 0, stats.CrossRepoEdges) assert.Equal(t, "unresolved::Helper", edge.To, "no import edge → must stay unresolved") assert.False(t, edge.CrossRepo) } func TestCrossRepoResolveAll_Unresolvable(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "repoA/a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"}) edge := &graph.Edge{From: "repoA/a.go::Foo", To: "unresolved::NonExistent", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 0, stats.Resolved) assert.Equal(t, 1, stats.Unresolved) } func TestCrossRepoResolveAll_ImportCrossRepo(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "repoA/main.go", Kind: graph.KindFile, Name: "main.go", FilePath: "repoA/main.go", Language: "go", RepoPrefix: "repoA"}) g.AddNode(&graph.Node{ID: "repoB/utils/utils.go", Kind: graph.KindPackage, Name: "utils", QualName: "utils", FilePath: "repoB/utils/utils.go", Language: "go", RepoPrefix: "repoB"}) edge := &graph.Edge{From: "repoA/main.go", To: "unresolved::import::utils", Kind: graph.EdgeImports, FilePath: "repoA/main.go", Line: 3} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 1, stats.Resolved) assert.Equal(t, 1, stats.CrossRepoEdges) assert.Equal(t, "repoB/utils/utils.go", edge.To) assert.True(t, edge.CrossRepo) } func TestCrossRepoResolveAll_MethodCrossRepo(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"}) g.AddNode(&graph.Node{ID: "repoB/lib/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start", FilePath: "repoB/lib/b.go", Language: "go", RepoPrefix: "repoB"}) // Evidence: repoA's caller file imports repoB. wireImport(g, "repoA/pkg/a.go", "repoB", "repoB/lib/b.go") edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::*.Start", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 10} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 1, stats.Resolved) assert.Equal(t, 1, stats.CrossRepoEdges) assert.Equal(t, "repoB/lib/b.go::Server.Start", edge.To) assert.True(t, edge.CrossRepo) } // A method call into a repo the caller never imports must stay // unresolved — the receiver-type name alone is not evidence the call // crosses to that particular repo. func TestCrossRepoResolveAll_RefusesUnreachableMethodCall(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "repoA/pkg/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/pkg/a.go", Language: "go", RepoPrefix: "repoA"}) g.AddNode(&graph.Node{ID: "repoB/lib/b.go::Server.Start", Kind: graph.KindMethod, Name: "Start", FilePath: "repoB/lib/b.go", Language: "go", RepoPrefix: "repoB"}) edge := &graph.Edge{From: "repoA/pkg/a.go::Caller", To: "unresolved::*.Start", Kind: graph.EdgeCalls, FilePath: "repoA/pkg/a.go", Line: 10} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 0, stats.Resolved) assert.Equal(t, "unresolved::*.Start", edge.To) assert.False(t, edge.CrossRepo) } func TestCrossRepoResolveForRepo(t *testing.T) { g := graph.New() // Repo A: caller with unresolved edge. g.AddNode(&graph.Node{ID: "repoA/a.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"}) // Repo B: caller with unresolved edge + target. g.AddNode(&graph.Node{ID: "repoB/b.go::Bar", Kind: graph.KindFunction, Name: "Bar", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"}) g.AddNode(&graph.Node{ID: "repoB/b.go::Baz", Kind: graph.KindFunction, Name: "Baz", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"}) // Evidence: repoA's file imports repoB. wireImport(g, "repoA/a.go", "repoB", "repoB/b.go") edgeA := &graph.Edge{From: "repoA/a.go::Foo", To: "unresolved::Baz", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5} edgeB := &graph.Edge{From: "repoB/b.go::Bar", To: "unresolved::Foo", Kind: graph.EdgeCalls, FilePath: "repoB/b.go", Line: 5} g.AddEdge(edgeA) g.AddEdge(edgeB) cr := NewCrossRepo(g) // Resolve only repoA edges. stats := cr.ResolveForRepo("repoA") assert.Equal(t, 1, stats.Resolved) assert.Equal(t, 1, stats.CrossRepoEdges) assert.Equal(t, "repoB/b.go::Baz", edgeA.To) assert.True(t, edgeA.CrossRepo) // edgeB should still be unresolved. assert.Equal(t, "unresolved::Foo", edgeB.To) } // --- Property test for Task 7.2 --- // Feature: multi-repo-support, Property 10: Cross-repo resolution with same-repo preference func TestPropertyCrossRepoResolutionSameRepoPreference(t *testing.T) { rapid.Check(t, func(rt *rapid.T) { g := graph.New() // Generate a function name. funcName := "Func" + rapid.StringMatching(`[A-Z][a-z]{2,8}`).Draw(rt, "funcName") // Generate two repo prefixes. repoA := "repo-" + rapid.StringMatching(`[a-z]{3,6}`).Draw(rt, "repoA") repoB := "repo-" + rapid.StringMatching(`[a-z]{3,6}`).Draw(rt, "repoB") // Ensure distinct repos. if repoA == repoB { repoB = repoB + "x" } // Decide whether the caller's repo has a same-repo match. hasSameRepoMatch := rapid.Bool().Draw(rt, "hasSameRepoMatch") // Always add the caller in repoA. callerFile := repoA + "/src/caller.go" callerID := callerFile + "::" + "Caller" g.AddNode(&graph.Node{ ID: callerID, Kind: graph.KindFunction, Name: "Caller", FilePath: callerFile, Language: "go", RepoPrefix: repoA, }) // Always add the target in repoB (cross-repo candidate). crossRepoTargetID := repoB + "/lib/target.go::" + funcName g.AddNode(&graph.Node{ ID: crossRepoTargetID, Kind: graph.KindFunction, Name: funcName, FilePath: repoB + "/lib/target.go", Language: "go", RepoPrefix: repoB, }) // Evidence: the caller's file imports repoB — without this the // cross-repo fallback is (correctly) refused. wireImport(g, callerFile, repoB, repoB+"/lib/target.go") // Optionally add a same-repo target in repoA. sameRepoTargetID := repoA + "/src/target.go::" + funcName if hasSameRepoMatch { g.AddNode(&graph.Node{ ID: sameRepoTargetID, Kind: graph.KindFunction, Name: funcName, FilePath: repoA + "/src/target.go", Language: "go", RepoPrefix: repoA, }) } // Add unresolved edge from caller. edge := &graph.Edge{ From: callerID, To: "unresolved::" + funcName, Kind: graph.EdgeCalls, FilePath: callerFile, Line: 10, } g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() require.Equal(rt, 1, stats.Resolved, "edge should be resolved") require.Equal(rt, 0, stats.Unresolved, "no edges should remain unresolved") if hasSameRepoMatch { // Same-repo match preferred. require.Equal(rt, sameRepoTargetID, edge.To, "same-repo match should be preferred") require.False(rt, edge.CrossRepo, "same-repo edge should not be marked cross-repo") require.Equal(rt, 0, stats.CrossRepoEdges, "no cross-repo edges when same-repo match exists") } else { // Cross-repo fallback — eligible because the caller imports repoB. require.Equal(rt, crossRepoTargetID, edge.To, "cross-repo target should be used when no same-repo match") require.True(rt, edge.CrossRepo, "cross-repo edge must have CrossRepo == true") require.Equal(rt, 1, stats.CrossRepoEdges, "one cross-repo edge expected") // Target ID should be a Qualified_Node_ID containing the target's RepoPrefix. require.Contains(rt, edge.To, repoB+"/", "cross-repo target should use Qualified_Node_ID with target RepoPrefix") } }) } // TestCrossRepoResolveAll_RefusesCrossWorkspaceNameCollision is the // end-to-end regression guard for the M3 false-positive report: a // caller in one workspace must never resolve a bare-name call to a // same-named symbol in an unrelated workspace it does not import. func TestCrossRepoResolveAll_RefusesCrossWorkspaceNameCollision(t *testing.T) { g := graph.New() // Workspace "gortex": a type method named `string`. g.AddNode(&graph.Node{ID: "gortex/edge.go::EdgeKind", Kind: graph.KindType, Name: "EdgeKind", FilePath: "gortex/edge.go", Language: "go", RepoPrefix: "gortex", WorkspaceID: "gortex"}) g.AddNode(&graph.Node{ID: "gortex/edge.go::caller", Kind: graph.KindFunction, Name: "caller", FilePath: "gortex/edge.go", Language: "go", RepoPrefix: "gortex", WorkspaceID: "gortex"}) // Unrelated workspace "rcd": a method literally named `string`. g.AddNode(&graph.Node{ID: "rcd/models/bot.go::BotClass.string", Kind: graph.KindMethod, Name: "string", FilePath: "rcd/models/bot.go", Language: "go", RepoPrefix: "rcd", WorkspaceID: "rcd"}) // gortex calls something named `string` — no import into rcd. edge := &graph.Edge{From: "gortex/edge.go::caller", To: "unresolved::string", Kind: graph.EdgeCalls, FilePath: "gortex/edge.go", Line: 3} g.AddEdge(edge) cr := NewCrossRepo(g) stats := cr.ResolveAll() assert.Equal(t, 0, stats.CrossRepoEdges, "must not cross into an unimported, unrelated workspace") assert.Equal(t, "unresolved::string", edge.To) assert.False(t, edge.CrossRepo) } // TestCrossRepoResolveAll_BindsInboundEdgeIntoChangedRepo locks the warm-restart // completeness guarantee: when an unchanged consumer repo already holds a bare // unresolved reference and a provider repo later adds the matching definition, // the whole-graph cross-repo resolve must bind that inbound reference. A pass // scoped to only the changed provider's OWN out-edges would never see the // consumer-owned edge, which is why the pre-enrich cross-repo resolve stays // whole-graph. func TestCrossRepoResolveAll_BindsInboundEdgeIntoChangedRepo(t *testing.T) { g := graph.New() // Unchanged consumer repoA: a call with no local definition — bare unresolved. g.AddNode(&graph.Node{ID: "repoA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "repoA/a.go", Language: "go", RepoPrefix: "repoA"}) // Changed provider repoB: just added Foo. g.AddNode(&graph.Node{ID: "repoB/b.go::Foo", Kind: graph.KindFunction, Name: "Foo", FilePath: "repoB/b.go", Language: "go", RepoPrefix: "repoB"}) wireImport(g, "repoA/a.go", "repoB", "repoB/b.go") inbound := &graph.Edge{From: "repoA/a.go::Caller", To: "unresolved::Foo", Kind: graph.EdgeCalls, FilePath: "repoA/a.go", Line: 5} g.AddEdge(inbound) // The edge is owned by the unchanged consumer, so it is absent from the // changed provider's own out-edge set (graph.GetRepoEdges("repoB")). require.Empty(t, g.GetRepoEdges("repoB"), "the inbound edge must not appear in the changed provider's own out-edges") NewCrossRepo(g).ResolveAll() assert.Equal(t, "repoB/b.go::Foo", inbound.To, "the whole-graph cross-repo resolve must bind the unchanged consumer's inbound reference into the changed provider") assert.True(t, inbound.CrossRepo) }