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
417 lines
14 KiB
Go
417 lines
14 KiB
Go
package resolver
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser/languages"
|
|
)
|
|
|
|
// buildMultiLangGraph extracts each fixture file with the extractor
|
|
// matching its extension (.go / .py / .ts / .js) and loads the result
|
|
// into a fresh graph. Unlike buildGraphFromSources (TS/JS only) this
|
|
// builder spans every ecosystem the external-call synthesis pass
|
|
// classifies, so one table can exercise Go modules, pip packages, and
|
|
// npm packages through the same real extract → resolve pipeline.
|
|
func buildMultiLangGraph(t *testing.T, files map[string]string) graph.Store {
|
|
t.Helper()
|
|
g := graph.New()
|
|
for path, src := range files {
|
|
var (
|
|
nodes []*graph.Node
|
|
edges []*graph.Edge
|
|
)
|
|
switch {
|
|
case strings.HasSuffix(path, ".go"):
|
|
r, err := languages.NewGoExtractor().Extract(path, []byte(src))
|
|
require.NoError(t, err, "go extract %s", path)
|
|
nodes, edges = r.Nodes, r.Edges
|
|
case strings.HasSuffix(path, ".py"):
|
|
r, err := languages.NewPythonExtractor().Extract(path, []byte(src))
|
|
require.NoError(t, err, "py extract %s", path)
|
|
nodes, edges = r.Nodes, r.Edges
|
|
case strings.HasSuffix(path, ".ts"), strings.HasSuffix(path, ".tsx"):
|
|
r, err := languages.NewTypeScriptExtractor().Extract(path, []byte(src))
|
|
require.NoError(t, err, "ts extract %s", path)
|
|
nodes, edges = r.Nodes, r.Edges
|
|
default:
|
|
r, err := languages.NewJavaScriptExtractor().Extract(path, []byte(src))
|
|
require.NoError(t, err, "js extract %s", path)
|
|
nodes, edges = r.Nodes, r.Edges
|
|
}
|
|
for _, n := range nodes {
|
|
g.AddNode(n)
|
|
}
|
|
for _, e := range edges {
|
|
g.AddEdge(e)
|
|
}
|
|
}
|
|
return g
|
|
}
|
|
|
|
// resolveAndSynthesize runs the production resolution pipeline against g
|
|
// — the per-edge ResolveAll pass plus the cross-package guard it ends
|
|
// with — and then the opt-in external-call synthesis pass. It mirrors
|
|
// the indexer settle point: synthesis runs strictly after resolution +
|
|
// guard, so the test exercises the same ordering the daemon uses.
|
|
func resolveAndSynthesize(g graph.Store, enabled bool) int {
|
|
New(g).ResolveAll()
|
|
return SynthesizeExternalCalls(g, enabled)
|
|
}
|
|
|
|
// callTargetsFrom collects the To-end of every call/reference edge
|
|
// leaving fromID, so a test can assert on the post-resolution shape of
|
|
// a caller's outbound calls.
|
|
func callTargetsFrom(g graph.Store, fromID string) []string {
|
|
var out []string
|
|
for _, e := range g.GetOutEdges(fromID) {
|
|
if e.Kind == graph.EdgeCalls || e.Kind == graph.EdgeReferences {
|
|
out = append(out, e.To)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// TestSynthesizeExternalCalls drives the pass through a real
|
|
// extract → resolve → synthesize pipeline for each ecosystem. Every row
|
|
// fixes one caller's call into an un-indexed external target and
|
|
// asserts, with the option both on and off, what the call edge lands
|
|
// on — and that language built-ins / standard-library calls are
|
|
// filtered out as noise.
|
|
func TestSynthesizeExternalCalls(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
files map[string]string
|
|
// callerID identifies the function whose outbound call is
|
|
// under test.
|
|
callerID string
|
|
// wantSyntheticID, when set with the option ON, is the
|
|
// synthetic node ID the call edge must retarget onto.
|
|
wantSyntheticID string
|
|
// wantEcosystem / wantImportPath are asserted on the
|
|
// synthetic node's Meta when wantSyntheticID is set.
|
|
wantEcosystem string
|
|
wantImportPath string
|
|
// noiseOnly marks a fixture whose only external call is a
|
|
// language built-in / stdlib hop: the filter must synthesize
|
|
// nothing even with the option ON.
|
|
noiseOnly bool
|
|
}{
|
|
{
|
|
// An un-indexed npm package: `axios` is imported and
|
|
// called, but no axios source is in the graph. With the
|
|
// option on, the call must terminate on a synthetic node.
|
|
name: "un-indexed npm package call",
|
|
files: map[string]string{
|
|
"web/api.ts": `import axios from "axios";
|
|
export function fetchUser(): void {
|
|
axios.get("/user");
|
|
}`,
|
|
},
|
|
callerID: "web/api.ts::fetchUser",
|
|
wantSyntheticID: externalCallNodeID("stdlib", "axios"),
|
|
wantEcosystem: "stdlib",
|
|
wantImportPath: "axios",
|
|
},
|
|
{
|
|
// A sibling microservice's client SDK — a scoped package
|
|
// that is not part of this repo's index. The call into it
|
|
// must be preserved as an explicit external terminal.
|
|
name: "sibling-service client SDK call",
|
|
files: map[string]string{
|
|
"web/orders.ts": `import billing from "@acme/billing-service-client";
|
|
export function charge(): void {
|
|
billing.createInvoice();
|
|
}`,
|
|
},
|
|
callerID: "web/orders.ts::charge",
|
|
wantSyntheticID: externalCallNodeID("stdlib", "@acme/billing-service-client"),
|
|
wantEcosystem: "stdlib",
|
|
wantImportPath: "@acme/billing-service-client",
|
|
},
|
|
{
|
|
// An un-indexed Go third-party module — a domain-qualified
|
|
// import path, so the resolver lands it on a `dep::`
|
|
// terminal. The synthetic node must carry it through.
|
|
name: "un-indexed go module call",
|
|
files: map[string]string{
|
|
"svc/main.go": `package main
|
|
|
|
import "github.com/acme/stripe"
|
|
|
|
func Pay() {
|
|
stripe.New("key")
|
|
}`,
|
|
},
|
|
callerID: "svc/main.go::Pay",
|
|
wantSyntheticID: externalCallNodeID("dep", "github.com/acme/stripe"),
|
|
wantEcosystem: "dep",
|
|
wantImportPath: "github.com/acme/stripe",
|
|
},
|
|
{
|
|
// An un-indexed pip package: `requests` is imported and
|
|
// called. Not a stdlib module, so it must be synthesized.
|
|
name: "un-indexed pip package call",
|
|
files: map[string]string{
|
|
"app/client.py": `import requests
|
|
|
|
def fetch():
|
|
requests.get("/health")
|
|
`,
|
|
},
|
|
callerID: "app/client.py::fetch",
|
|
wantSyntheticID: externalCallNodeID("stdlib", "requests"),
|
|
wantEcosystem: "stdlib",
|
|
wantImportPath: "requests",
|
|
},
|
|
{
|
|
// Noise filter — Go standard library. Every Go file calls
|
|
// `fmt`; materialising a node for it would bury the real
|
|
// cross-system edges. Nothing must be synthesized.
|
|
name: "go stdlib call is filtered as noise",
|
|
files: map[string]string{
|
|
"svc/log.go": `package main
|
|
|
|
import "fmt"
|
|
|
|
func Log() {
|
|
fmt.Println("hello")
|
|
}`,
|
|
},
|
|
callerID: "svc/log.go::Log",
|
|
noiseOnly: true,
|
|
},
|
|
{
|
|
// Noise filter — Python standard library. `os.getenv` is
|
|
// an interpreter built-in, not a pip dependency.
|
|
name: "python stdlib call is filtered as noise",
|
|
files: map[string]string{
|
|
"app/env.py": `import os
|
|
|
|
def home():
|
|
os.getenv("HOME")
|
|
`,
|
|
},
|
|
callerID: "app/env.py::home",
|
|
noiseOnly: true,
|
|
},
|
|
{
|
|
// Noise filter — a Node.js core module. `node:fs` is part
|
|
// of the runtime, not an npm package.
|
|
name: "node core module call is filtered as noise",
|
|
files: map[string]string{
|
|
"web/disk.ts": `import fs from "node:fs";
|
|
export function read(): void {
|
|
fs.readFileSync("/etc/hosts");
|
|
}`,
|
|
},
|
|
callerID: "web/disk.ts::read",
|
|
noiseOnly: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Option OFF (the default): the synthesis pass is a pure
|
|
// no-op — the resolved graph is left exactly as resolution
|
|
// produced it, with no synthetic node and no retargeted
|
|
// edge. Snapshot the counts after ResolveAll (which itself
|
|
// mutates the graph) so the assertion isolates the pass.
|
|
gOff := buildMultiLangGraph(t, tc.files)
|
|
New(gOff).ResolveAll()
|
|
nodesAfterResolve := gOff.NodeCount()
|
|
edgesAfterResolve := gOff.EdgeCount()
|
|
synthesizedOff := SynthesizeExternalCalls(gOff, false)
|
|
assert.Zero(t, synthesizedOff, "option OFF must synthesize nothing")
|
|
assert.Equal(t, nodesAfterResolve, gOff.NodeCount(),
|
|
"option OFF must not add any node")
|
|
assert.Equal(t, edgesAfterResolve, gOff.EdgeCount(),
|
|
"option OFF must not add any edge")
|
|
for _, to := range callTargetsFrom(gOff, tc.callerID) {
|
|
assert.NotContains(t, to, externalCallPrefix,
|
|
"option OFF must leave call edges off synthetic nodes")
|
|
}
|
|
|
|
// Option ON: genuine externals get a synthetic terminal;
|
|
// noise (builtin / stdlib) is still filtered out.
|
|
gOn := buildMultiLangGraph(t, tc.files)
|
|
synthesizedOn := resolveAndSynthesize(gOn, true)
|
|
|
|
if tc.noiseOnly {
|
|
assert.Zero(t, synthesizedOn,
|
|
"noise filter must exclude builtin/stdlib calls")
|
|
for _, to := range callTargetsFrom(gOn, tc.callerID) {
|
|
assert.NotContains(t, to, externalCallPrefix,
|
|
"a builtin/stdlib call must not gain a synthetic node")
|
|
}
|
|
return
|
|
}
|
|
|
|
require.Positive(t, synthesizedOn,
|
|
"a genuine external call must be synthesized with the option ON")
|
|
|
|
// The synthetic node exists and is marked unmistakably as
|
|
// both synthetic and external so analyzers can filter it.
|
|
node := gOn.GetNode(tc.wantSyntheticID)
|
|
require.NotNil(t, node, "synthetic external node must be added")
|
|
assert.Equal(t, graph.KindModule, node.Kind)
|
|
assert.Equal(t, tc.wantImportPath, node.Name)
|
|
assert.Equal(t, true, node.Meta["synthetic"])
|
|
assert.Equal(t, true, node.Meta["external_call"])
|
|
assert.Equal(t, tc.wantEcosystem, node.Meta["ecosystem"])
|
|
assert.Equal(t, tc.wantImportPath, node.Meta["import_path"])
|
|
|
|
// The call edge was retargeted onto the synthetic node and
|
|
// the synthetic node sees the inbound call edge — so a
|
|
// call-chain walk reaches the external terminal.
|
|
targets := callTargetsFrom(gOn, tc.callerID)
|
|
assert.Contains(t, targets, tc.wantSyntheticID,
|
|
"the call edge must retarget onto the synthetic node")
|
|
require.NotEmpty(t, gOn.GetInEdges(tc.wantSyntheticID),
|
|
"synthetic node must see the inbound call edge")
|
|
|
|
// The retargeted edge is marked so a query can pick out the
|
|
// cross-system terminals this pass created.
|
|
edge := firstOutEdgeByKind(gOn, tc.callerID, graph.EdgeCalls)
|
|
require.NotNil(t, edge)
|
|
assert.Equal(t, tc.wantSyntheticID, edge.To)
|
|
assert.Equal(t, true, edge.Meta["external_call"])
|
|
assert.Equal(t, graph.OriginTextMatched, edge.Origin)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSynthesizeExternalCalls_Idempotent pins the full-recompute
|
|
// contract: re-running the pass rewrites every edge onto the same
|
|
// deterministic synthetic node and accretes no duplicate node or edge.
|
|
func TestSynthesizeExternalCalls_Idempotent(t *testing.T) {
|
|
files := map[string]string{
|
|
"web/api.ts": `import axios from "axios";
|
|
export function fetchUser(): void {
|
|
axios.get("/user");
|
|
}`,
|
|
}
|
|
g := buildMultiLangGraph(t, files)
|
|
New(g).ResolveAll()
|
|
|
|
first := SynthesizeExternalCalls(g, true)
|
|
nodesAfterFirst := g.NodeCount()
|
|
second := SynthesizeExternalCalls(g, true)
|
|
third := SynthesizeExternalCalls(g, true)
|
|
|
|
assert.Equal(t, 1, first)
|
|
assert.Equal(t, first, second, "re-run must report the same count")
|
|
assert.Equal(t, first, third)
|
|
assert.Equal(t, nodesAfterFirst, g.NodeCount(),
|
|
"re-run must not add a duplicate synthetic node")
|
|
|
|
syntheticID := externalCallNodeID("stdlib", "axios")
|
|
require.Len(t, g.GetInEdges(syntheticID), 1,
|
|
"re-run must not accrete a duplicate inbound edge")
|
|
}
|
|
|
|
// TestSynthesizeExternalCalls_DisabledByDefault guards the
|
|
// zero-config contract: a graph carrying an un-indexed external call
|
|
// is left completely untouched when the option is off — the default.
|
|
func TestSynthesizeExternalCalls_DisabledByDefault(t *testing.T) {
|
|
files := map[string]string{
|
|
"app/client.py": `import requests
|
|
|
|
def fetch():
|
|
requests.get("/health")
|
|
`,
|
|
}
|
|
g := buildMultiLangGraph(t, files)
|
|
New(g).ResolveAll()
|
|
|
|
nodesBefore := g.NodeCount()
|
|
edgesBefore := g.EdgeCount()
|
|
synthesized := SynthesizeExternalCalls(g, false)
|
|
|
|
assert.Zero(t, synthesized)
|
|
assert.Equal(t, nodesBefore, g.NodeCount(), "default-off must add no node")
|
|
assert.Equal(t, edgesBefore, g.EdgeCount(), "default-off must add no edge")
|
|
}
|
|
|
|
// TestParseExternalCallTarget unit-tests the terminal classifier: the
|
|
// three external bookkeeping-string shapes yield the right ecosystem +
|
|
// import path, while real node IDs, bare placeholders, `builtin::`
|
|
// terminals, and already-synthesised nodes are rejected.
|
|
func TestParseExternalCallTarget(t *testing.T) {
|
|
cases := []struct {
|
|
target string
|
|
wantOK bool
|
|
wantEcosystem string
|
|
wantPath string
|
|
}{
|
|
{"dep::github.com/foo/bar::Baz", true, "dep", "github.com/foo/bar"},
|
|
{"stdlib::axios::get", true, "stdlib", "axios"},
|
|
{"stdlib::@acme/svc-client::call", true, "stdlib", "@acme/svc-client"},
|
|
{"external::lodash", true, "external", "lodash"},
|
|
// Rejected: real node IDs and non-external placeholders.
|
|
{"pkg/foo.go::Bar", false, "", ""},
|
|
{"unresolved::Foo", false, "", ""},
|
|
{"unresolved::*.foo", false, "", ""},
|
|
{"builtin::js::array::push", false, "", ""},
|
|
{externalCallPrefix + "dep::x", false, "", ""},
|
|
// `dep::` / `stdlib::` with no `::<symbol>` separator is malformed.
|
|
{"dep::", false, "", ""},
|
|
{"stdlib::axios", false, "", ""},
|
|
{"external::", false, "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.target, func(t *testing.T) {
|
|
eco, path, ok := parseExternalCallTarget(tc.target)
|
|
assert.Equal(t, tc.wantOK, ok)
|
|
assert.Equal(t, tc.wantEcosystem, eco)
|
|
assert.Equal(t, tc.wantPath, path)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsLanguageStdlib unit-tests the language-aware noise filter: the
|
|
// same import-path shape is stdlib or third-party depending on the
|
|
// caller's language.
|
|
func TestIsLanguageStdlib(t *testing.T) {
|
|
cases := []struct {
|
|
lang string
|
|
path string
|
|
want bool
|
|
}{
|
|
// Go: un-dotted first segment is stdlib; domain-led is a module.
|
|
{"go", "fmt", true},
|
|
{"go", "net/http", true},
|
|
{"go", "encoding/json", true},
|
|
{"go", "github.com/stripe/stripe-go", false},
|
|
{"go", "gopkg.in/yaml.v3", false},
|
|
// Python: interpreter modules vs pip packages.
|
|
{"python", "os", true},
|
|
{"python", "os.path", true},
|
|
{"python", "collections", true},
|
|
{"python", "requests", false},
|
|
{"python", "numpy", false},
|
|
// Node: core modules (bare and node:-prefixed) vs npm packages.
|
|
{"javascript", "fs", true},
|
|
{"typescript", "node:crypto", true},
|
|
{"typescript", "stream/promises", true},
|
|
{"typescript", "axios", false},
|
|
{"typescript", "@acme/billing-client", false},
|
|
// Rust: the std distribution vs crates.
|
|
{"rust", "std", true},
|
|
{"rust", "core::mem", true},
|
|
{"rust", "tokio", false},
|
|
// Unknown language: treat the path as external so a real edge
|
|
// is never dropped by a missing rule.
|
|
{"", "anything", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.lang+":"+tc.path, func(t *testing.T) {
|
|
assert.Equal(t, tc.want, isLanguageStdlib(tc.lang, tc.path))
|
|
})
|
|
}
|
|
}
|