package resolver import ( "strings" "testing" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/parser/languages" ) // buildGraphFromSources extracts every fixture file with the extractor // matching its suffix (.ts/.tsx → TypeScript, otherwise JavaScript) and // loads the resulting nodes and edges into a fresh graph. It is the // faithful end-to-end harness for the resolver tests below: a real // extractor produces the unresolved edges, then ResolveAll runs against // them exactly as it does on a live index. func buildGraphFromSources(t *testing.T, files map[string]string) graph.Store { t.Helper() g := graph.New() ts := languages.NewTypeScriptExtractor() js := languages.NewJavaScriptExtractor() for path, src := range files { if strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".tsx") { r, err := ts.Extract(path, []byte(src)) if err != nil { t.Fatalf("ts extract %s: %v", path, err) } for _, n := range r.Nodes { g.AddNode(n) } for _, e := range r.Edges { g.AddEdge(e) } continue } r, err := js.Extract(path, []byte(src)) if err != nil { t.Fatalf("js extract %s: %v", path, err) } for _, n := range r.Nodes { g.AddNode(n) } for _, e := range r.Edges { g.AddEdge(e) } } return g } // callEdgeTo returns the resolved To-end of the call/reference edge that // leaves fromID at the given 1-based line. Empty string when no such // edge exists. func callEdgeTo(g graph.Store, fromID string, line int) string { for _, e := range g.GetOutEdges(fromID) { if (e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences) && e.Line == line { return e.To } } return "" } // TestJSTSCallEdges_FalsePositivesAndNegatives drives the three mis- // resolution patterns through a real extract → resolve pipeline. Each // row asserts both halves of the contract: the genuine edge that must // still resolve, and the false-positive edge that must be suppressed. func TestJSTSCallEdges_FalsePositivesAndNegatives(t *testing.T) { cases := []struct { name string files map[string]string // callerID + callLine identify the call edge under test. callerID string callLine int // wantTo, when set, is the node the call MUST resolve to. wantTo string // forbidTo, when set, is a node the call must NOT resolve to. forbidTo string // wantUnresolved requires the edge to stay an `unresolved::` // placeholder (the false positive was suppressed, with no // reachable genuine target to fall back to). wantUnresolved bool }{ { // Pattern 1, true positive: a call to an object-literal // shorthand method must bind to that member. name: "object-literal shorthand resolves to member", files: map[string]string{ "svc/api.ts": `function doWork(): number { return 1; } export const api = { process(): number { return doWork(); }, }; function caller(): void { api.process(); } `, }, callerID: "svc/api.ts::caller", callLine: 6, wantTo: "svc/api.ts::api.process@3", }, { // Pattern 1, false positive: an unrelated free `process` in // another package must NOT capture `api.process()`. name: "object-literal shorthand does not bind to free function", files: map[string]string{ "svc/api.ts": `export const api = { process(): number { return 1; }, }; function caller(): void { api.process(); } `, "other/free.ts": `export function process(): number { return 99; }`, }, callerID: "svc/api.ts::caller", callLine: 5, wantTo: "svc/api.ts::api.process@2", forbidTo: "other/free.ts::process", }, { // Pattern 2, true positive: a factory result whose handler // implementations are same-package must still resolve. name: "factory dispatch resolves same-package handler", files: map[string]string{ "app/run.ts": `function run(): void { const h = makeHandler('a'); h.handle(); } `, "app/handlers.ts": `export class AlphaHandler { handle(): number { return 1; } } `, }, callerID: "app/run.ts::run", callLine: 3, wantTo: "app/handlers.ts::AlphaHandler.handle", }, { // Pattern 2, false positive: a factory result whose only // `handle` candidate lives in an un-imported package must // not produce a call edge to it. name: "factory dispatch does not bind across un-imported package", files: map[string]string{ "app/run.ts": `function run(): void { const h = makeHandler('a'); h.handle(); } `, "vendor/other.ts": `export class OtherHandler { handle(): number { return 1; } } `, }, callerID: "app/run.ts::run", callLine: 3, forbidTo: "vendor/other.ts::OtherHandler.handle", wantUnresolved: true, }, { // Pattern 3, false positive: a `ns.foo()` call where `ns` is // a plain local object must not resolve to a same-named free // function in an un-imported module. name: "namespace member call does not bind to free function", files: map[string]string{ "app/main.ts": `function run(): void { const ns = { other: 1 }; ns.lookup('x'); } `, "lib/lookup.ts": `export function lookup(s: string): string { return s; }`, }, callerID: "app/main.ts::run", callLine: 3, forbidTo: "lib/lookup.ts::lookup", wantUnresolved: true, }, { // Pattern 3, true positive: when the namespace is genuinely // imported, the member call resolves to the imported symbol. name: "imported namespace member call resolves", files: map[string]string{ "app/main.ts": `import * as helpers from './helpers'; function run(): void { helpers.lookup('x'); } `, "app/helpers/index.ts": `export function lookup(s: string): string { return s; }`, }, callerID: "app/main.ts::run", callLine: 3, wantTo: "app/helpers/index.ts::lookup", }, { // Pattern 1 in JavaScript: the shorthand method must resolve // and must not fall through to a free function. name: "javascript object-literal shorthand resolves to member", files: map[string]string{ "svc/api.js": `export const api = { process() { return 1; }, }; function caller() { api.process(); } `, "other/free.js": `export function process() { return 99; }`, }, callerID: "svc/api.js::caller", callLine: 5, wantTo: "svc/api.js::api.process@2", forbidTo: "other/free.js::process", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := buildGraphFromSources(t, tc.files) New(g).ResolveAll() got := callEdgeTo(g, tc.callerID, tc.callLine) if got == "" { t.Fatalf("no call edge found from %s at line %d", tc.callerID, tc.callLine) } if tc.wantTo != "" && got != tc.wantTo { t.Errorf("call resolved to %q, want %q", got, tc.wantTo) } if tc.forbidTo != "" && got == tc.forbidTo { t.Errorf("call mis-resolved to forbidden cross-package target %q", tc.forbidTo) } if tc.wantUnresolved && !strings.HasPrefix(got, "unresolved::") { t.Errorf("call resolved to %q, expected it to stay unresolved (false positive should be suppressed)", got) } }) } } // TestCrossPackageGuard_RevertsUnreachableNameMatch exercises the guard // directly on a hand-built graph: a function call whose only same-name // candidate lives in a package the caller never imports must be // reverted to its unresolved placeholder, while the same call resolves // and stays when the candidate's package is imported or same-package. func TestCrossPackageGuard_RevertsUnreachableNameMatch(t *testing.T) { cases := []struct { name string // importedDir, when non-empty, adds an EdgeImports from the // caller file to that directory. importedDir string // targetDir is the directory the only `helper` candidate lives in. targetDir string // wantResolved is the expected resolved To (empty → must stay // unresolved). wantResolved string }{ { name: "same-package candidate is kept", targetDir: "pkgA", wantResolved: "pkgA/b.go::helper", }, { name: "imported-package candidate is kept", importedDir: "pkgB", targetDir: "pkgB", wantResolved: "pkgB/b.go::helper", }, { name: "un-imported-package candidate is reverted", importedDir: "pkgB", targetDir: "pkgC", wantResolved: "", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkgA/a.go", Kind: graph.KindFile, FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"}) // The only `helper` candidate lives in targetDir. targetFile := tc.targetDir + "/b.go" targetID := targetFile + "::helper" g.AddNode(&graph.Node{ID: targetFile, Kind: graph.KindFile, FilePath: targetFile, Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: targetID, Kind: graph.KindFunction, Name: "helper", FilePath: targetFile, Language: "go", RepoPrefix: "r"}) // A decoy file in the imported package so the import resolves // to a real directory even when the candidate lives elsewhere. if tc.importedDir != "" && tc.importedDir != tc.targetDir { decoy := tc.importedDir + "/x.go" g.AddNode(&graph.Node{ID: decoy, Kind: graph.KindFile, FilePath: decoy, Language: "go", RepoPrefix: "r"}) } if tc.importedDir != "" { g.AddEdge(&graph.Edge{ From: "pkgA/a.go", To: "unresolved::import::" + tc.importedDir, Kind: graph.EdgeImports, FilePath: "pkgA/a.go", Line: 1, }) } call := &graph.Edge{ From: "pkgA/a.go::Caller", To: "unresolved::helper", Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5, } g.AddEdge(call) New(g).ResolveAll() // Whatever the resolver and guard did, the edge's identity // stays internally consistent — the guard routes its Origin // revert through SetEdgeProvenance, so the out- and in-edge // views never disagree on provenance. if err := g.VerifyEdgeIdentities(); err != nil { t.Fatalf("edge identities inconsistent after resolve: %v", err) } if tc.wantResolved == "" { if call.To != "unresolved::helper" { t.Errorf("guard should have reverted the edge; To = %q, want unresolved::helper", call.To) } // A reverted edge must carry no resolution provenance. if call.Origin != "" { t.Errorf("reverted edge kept Origin %q, want empty", call.Origin) } return } if call.To != tc.wantResolved { t.Errorf("call resolved to %q, want %q", call.To, tc.wantResolved) } }) } } // TestCrossPackageGuard_RevertRoutedThroughProvenance proves the guard's // provenance revert goes through Graph.SetEdgeProvenance rather than a // bare Origin write: when the edge being reverted carries a resolution // Origin, clearing it is counted as an edge-identity revision, and the // resulting graph stays identity-consistent across both adjacency views. // // The guard internally resets To and Origin together; this test stamps // a weak Origin on the same logical edge through the sanctioned path, // then re-derives the guard's exact revert sequence (SetEdgeProvenance // to drop the Origin, then the target revert + re-bucket) to assert // that path records the churn. func TestCrossPackageGuard_RevertRoutedThroughProvenance(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: "pkgC/b.go::helper", Kind: graph.KindFunction, Name: "helper", FilePath: "pkgC/b.go", Language: "go", RepoPrefix: "r"}) // A call edge as it sits post-resolution: pointed at a (to be // rejected) cross-package target with a weak resolution Origin. call := &graph.Edge{ From: "pkgA/a.go::Caller", To: "pkgC/b.go::helper", Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5, Origin: graph.OriginTextMatched, } g.AddEdge(call) baseline := g.EdgeIdentityRevisions() // The guard's revert: drop provenance via SetEdgeProvenance, then // revert the target and re-bucket — mirrors cross_pkg_guard.go. oldResolved := call.To if !g.SetEdgeProvenance(call, "") { t.Fatal("clearing a non-empty resolution Origin must change identity") } call.To = "unresolved::helper" call.Confidence = 0 g.ReindexEdge(call, oldResolved) if g.EdgeIdentityRevisions() != baseline+1 { t.Errorf("guard revert must record exactly one identity revision: got %d, want %d", g.EdgeIdentityRevisions(), baseline+1) } if err := g.VerifyEdgeIdentities(); err != nil { t.Fatalf("edge identities inconsistent after guarded revert: %v", err) } if got := g.GetOutEdges("pkgA/a.go::Caller"); len(got) != 1 || got[0].To != "unresolved::helper" || got[0].Origin != "" { t.Errorf("reverted edge has wrong state: %+v", got) } } // TestCrossPackageGuard_KeepsImportedMethodCall verifies the guard does // not strip a genuine cross-package method call: a `*.handle` member // call resolving to a method whose package the caller imports survives. func TestCrossPackageGuard_KeepsImportedMethodCall(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "pkgA/a.go", Kind: graph.KindFile, FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: "pkgA/a.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "pkgA/a.go", Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: "pkgB/b.go", Kind: graph.KindFile, FilePath: "pkgB/b.go", Language: "go", RepoPrefix: "r"}) g.AddNode(&graph.Node{ID: "pkgB/b.go::Worker.handle", Kind: graph.KindMethod, Name: "handle", FilePath: "pkgB/b.go", Language: "go", RepoPrefix: "r"}) g.AddEdge(&graph.Edge{From: "pkgA/a.go", To: "unresolved::import::pkgB", Kind: graph.EdgeImports, FilePath: "pkgA/a.go", Line: 1}) call := &graph.Edge{From: "pkgA/a.go::Caller", To: "unresolved::*.handle", Kind: graph.EdgeCalls, FilePath: "pkgA/a.go", Line: 5} g.AddEdge(call) New(g).ResolveAll() if call.To != "pkgB/b.go::Worker.handle" { t.Errorf("imported-package method call was dropped; To = %q, want pkgB/b.go::Worker.handle", call.To) } } // TestCrossPackageGuard_LeavesExternEdges confirms the guard never // touches `extern::`-shaped resolutions: those carry an explicit import // path as evidence and are not name-only guesses, so a cross-package // extern call to an indexed symbol must stay resolved. func TestCrossPackageGuard_LeavesExternEdges(t *testing.T) { g := graph.New() g.AddNode(&graph.Node{ID: "consumer/main.go", Kind: graph.KindFile, FilePath: "consumer/main.go", Language: "go"}) g.AddNode(&graph.Node{ID: "consumer/main.go::Caller", Kind: graph.KindFunction, Name: "Caller", FilePath: "consumer/main.go", Language: "go"}) g.AddNode(&graph.Node{ID: "lib/pkg/pkg.go", Kind: graph.KindFile, FilePath: "lib/pkg/pkg.go", Language: "go"}) g.AddNode(&graph.Node{ID: "lib/pkg/pkg.go::DoThing", Kind: graph.KindFunction, Name: "DoThing", FilePath: "lib/pkg/pkg.go", Language: "go"}) call := &graph.Edge{ From: "consumer/main.go::Caller", To: "unresolved::extern::lib/pkg::DoThing", Kind: graph.EdgeCalls, FilePath: "consumer/main.go", Line: 5, } g.AddEdge(call) New(g).ResolveAll() if call.To != "lib/pkg/pkg.go::DoThing" { t.Errorf("extern-qualified call was wrongly reverted by the guard; To = %q", call.To) } }