Files
zzet--gortex/internal/resolver/cross_pkg_call_guard_test.go
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

423 lines
15 KiB
Go

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