Files
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

348 lines
15 KiB
Go

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)
}