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
467 lines
14 KiB
Go
467 lines
14 KiB
Go
package goanalysis
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
)
|
|
|
|
// stdlibCallerFixture writes a one-package module that calls fmt.Println
|
|
// (a top-level stdlib func) and uses fmt.Stringer (a stdlib interface).
|
|
// Drives the externals attribution tests.
|
|
func stdlibCallerFixture(t *testing.T, root string) {
|
|
t.Helper()
|
|
writeGoMod(t, root, "example.com/stdlib")
|
|
writeFile(t, root, "main.go", `package main
|
|
|
|
import "fmt"
|
|
|
|
type Banner struct{}
|
|
|
|
func (b Banner) String() string { return "hi" }
|
|
|
|
func Greet() {
|
|
fmt.Println("hello")
|
|
var _ fmt.Stringer = Banner{}
|
|
}
|
|
`)
|
|
}
|
|
|
|
func TestExternals_AttributesStdlibCallToModuleNode(t *testing.T) {
|
|
root := resolvedTempDir(t)
|
|
stdlibCallerFixture(t, root)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
|
|
FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
|
|
FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
|
|
})
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// fmt.Println landed as ext::go:fmt::Println.
|
|
println := g.GetNode("ext::go:fmt::Println")
|
|
require.NotNil(t, println, "expected synthetic node for fmt.Println")
|
|
assert.Equal(t, graph.KindFunction, println.Kind)
|
|
assert.Equal(t, "Println", println.Name)
|
|
assert.Equal(t, "go", println.Language)
|
|
require.NotNil(t, println.Meta)
|
|
assert.True(t, println.Meta["external"].(bool))
|
|
assert.Equal(t, "stdlib", println.Meta["module_kind"])
|
|
assert.Equal(t, "fmt", println.Meta["import_path"])
|
|
assert.Equal(t, "go-types", println.Meta["semantic_source"])
|
|
sig, _ := println.Meta["signature"].(string)
|
|
assert.Contains(t, sig, "Println")
|
|
|
|
// Stdlib module node materialised exactly once.
|
|
stdlib := g.GetNode("module::go:stdlib")
|
|
require.NotNil(t, stdlib, "expected synthetic stdlib module node")
|
|
assert.Equal(t, graph.KindModule, stdlib.Kind)
|
|
assert.Equal(t, "stdlib", stdlib.Meta["module_kind"])
|
|
|
|
// Greet → fmt.Println via EdgeCalls at lsp_resolved.
|
|
var callEdge *graph.Edge
|
|
for _, e := range g.GetOutEdges("main.go::Greet") {
|
|
if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
|
|
callEdge = e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, callEdge, "expected EdgeCalls Greet → ext::go:fmt::Println")
|
|
assert.Equal(t, graph.OriginLSPResolved, callEdge.Origin)
|
|
assert.Equal(t, "go-types", callEdge.Meta["semantic_source"])
|
|
|
|
// External symbol → module via EdgeDependsOnModule.
|
|
var modEdge *graph.Edge
|
|
for _, e := range g.GetOutEdges("ext::go:fmt::Println") {
|
|
if e.To == "module::go:stdlib" && e.Kind == graph.EdgeDependsOnModule {
|
|
modEdge = e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, modEdge, "external symbol must link to its module via EdgeDependsOnModule")
|
|
assert.Equal(t, graph.OriginLSPResolved, modEdge.Origin)
|
|
|
|
// fmt.Stringer landed as a KindInterface external node.
|
|
stringer := g.GetNode("ext::go:fmt::Stringer")
|
|
require.NotNil(t, stringer, "expected synthetic node for fmt.Stringer")
|
|
assert.Equal(t, graph.KindInterface, stringer.Kind)
|
|
}
|
|
|
|
func TestExternals_DepInModuleCacheGetsModuleNode(t *testing.T) {
|
|
root := resolvedTempDir(t)
|
|
writeGoMod(t, root, "example.com/depcaller")
|
|
// We rely on x/tools being in the module cache for the host machine —
|
|
// it's an indirect dep of the gortex module, so go/packages will find
|
|
// it. The fixture imports a tiny well-known type to keep the test
|
|
// resilient to API shifts.
|
|
writeFile(t, root, "go.mod", `module example.com/depcaller
|
|
|
|
go 1.21
|
|
|
|
require golang.org/x/tools v0.20.0
|
|
`)
|
|
writeFile(t, root, "main.go", `package main
|
|
|
|
import "golang.org/x/tools/go/packages"
|
|
|
|
func Use() *packages.Config {
|
|
return &packages.Config{}
|
|
}
|
|
`)
|
|
// Skip cleanly when module cache isn't populated for this dep.
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Use", Kind: graph.KindFunction, Name: "Use",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 7, Language: "go",
|
|
})
|
|
|
|
p := newTestProvider(t)
|
|
res, err := p.Enrich(g, root)
|
|
if err != nil {
|
|
t.Skipf("dep not in module cache (CI without GOFLAGS=-mod=mod cache): %v", err)
|
|
}
|
|
require.NotNil(t, res)
|
|
|
|
var modulePathPrefix = "module::go:golang.org/x/tools"
|
|
var foundModule *graph.Node
|
|
for _, n := range g.AllNodes() {
|
|
if n.Kind != graph.KindModule {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(n.ID, modulePathPrefix) {
|
|
foundModule = n
|
|
break
|
|
}
|
|
}
|
|
if foundModule == nil {
|
|
t.Skipf("x/tools not loaded with module info — host environment probably uses GOFLAGS=-mod=vendor")
|
|
}
|
|
assert.Equal(t, "module_cache", foundModule.Meta["module_kind"])
|
|
assert.Equal(t, "go", foundModule.Language)
|
|
|
|
// Use → some ext::go:golang.org/x/tools/go/packages::Config edge.
|
|
var edge *graph.Edge
|
|
for _, e := range g.GetOutEdges("main.go::Use") {
|
|
if strings.HasPrefix(e.To, "ext::go:golang.org/x/tools/go/packages::") {
|
|
edge = e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, edge, "expected an edge from Use to a packages.* external node")
|
|
assert.Equal(t, graph.OriginLSPResolved, edge.Origin)
|
|
}
|
|
|
|
func TestExternals_ClaimsResolverStubEdge(t *testing.T) {
|
|
root := resolvedTempDir(t)
|
|
stdlibCallerFixture(t, root)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
|
|
FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
|
|
FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
|
|
})
|
|
|
|
// Pre-seed the resolver-shaped stub edge that the parser+resolver
|
|
// would have written before externals attribution landed.
|
|
// Goanalysis must rewrite this edge to point at the real ext::
|
|
// node rather than leaving a duplicate.
|
|
stubEdge := &graph.Edge{
|
|
From: "main.go::Greet", To: "stdlib::fmt::Println", Kind: graph.EdgeCalls,
|
|
FilePath: "main.go", Line: 10,
|
|
Confidence: 0.0, ConfidenceLabel: "INFERRED",
|
|
Origin: graph.OriginTextMatched,
|
|
}
|
|
g.AddEdge(stubEdge)
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// The original stub bucket must drain; the new bucket must hold the edge.
|
|
stubInBucket := false
|
|
for _, e := range g.GetInEdges("stdlib::fmt::Println") {
|
|
_ = e
|
|
stubInBucket = true
|
|
}
|
|
assert.False(t, stubInBucket, "stub bucket stdlib::fmt::Println must be empty after externals upgrade")
|
|
|
|
var realEdge *graph.Edge
|
|
for _, e := range g.GetOutEdges("main.go::Greet") {
|
|
if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
|
|
realEdge = e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, realEdge)
|
|
assert.Equal(t, graph.OriginLSPResolved, realEdge.Origin, "claimed stub edges must be promoted to lsp_resolved")
|
|
assert.Equal(t, 1.0, realEdge.Confidence)
|
|
require.NotNil(t, realEdge.Meta)
|
|
assert.Equal(t, "go-types", realEdge.Meta["semantic_source"])
|
|
|
|
// And the stub edge identity (same pointer) must now point at the real node.
|
|
assert.Same(t, stubEdge, realEdge, "claim should mutate the existing edge struct, not duplicate it")
|
|
}
|
|
|
|
func TestExternals_ClaimsFuzzyStubByLineAndName(t *testing.T) {
|
|
// The parser emits `unresolved::*.Method` for method calls on
|
|
// external receivers it can't classify (e.g. `os.Stdout.Write`).
|
|
// The fuzzy claim path matches by (line, trailing-name) so the
|
|
// stub doesn't survive alongside the real ext::go: node.
|
|
root := resolvedTempDir(t)
|
|
writeGoMod(t, root, "example.com/methodcall")
|
|
writeFile(t, root, "main.go", `package main
|
|
|
|
import "os"
|
|
|
|
func Write() {
|
|
os.Stdout.Write([]byte("x"))
|
|
}
|
|
`)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Write", Kind: graph.KindFunction, Name: "Write",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 7, Language: "go",
|
|
})
|
|
stubEdge := &graph.Edge{
|
|
From: "main.go::Write", To: "unresolved::*.Write", Kind: graph.EdgeCalls,
|
|
FilePath: "main.go", Line: 6,
|
|
Confidence: 0.0, ConfidenceLabel: "INFERRED",
|
|
Origin: graph.OriginTextMatched,
|
|
}
|
|
g.AddEdge(stubEdge)
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// stub bucket drained — fuzzy claim should have rewritten the edge.
|
|
stubInBucket := false
|
|
for _, e := range g.GetInEdges("unresolved::*.Write") {
|
|
_ = e
|
|
stubInBucket = true
|
|
}
|
|
assert.False(t, stubInBucket, "fuzzy claim must drain the unresolved::*.Write bucket")
|
|
|
|
// And the real ext::go:os::File.Write node holds the call.
|
|
realNode := g.GetNode("ext::go:os::File.Write")
|
|
require.NotNil(t, realNode, "expected ext::go:os::File.Write to be created")
|
|
assert.Equal(t, graph.KindMethod, realNode.Kind)
|
|
assert.Equal(t, "File", realNode.Meta["receiver"])
|
|
|
|
var realEdge *graph.Edge
|
|
for _, e := range g.GetOutEdges("main.go::Write") {
|
|
if e.To == "ext::go:os::File.Write" && e.Kind == graph.EdgeCalls {
|
|
realEdge = e
|
|
break
|
|
}
|
|
}
|
|
require.NotNil(t, realEdge, "expected EdgeCalls Write → ext::go:os::File.Write")
|
|
assert.Equal(t, graph.OriginLSPResolved, realEdge.Origin)
|
|
assert.Same(t, stubEdge, realEdge, "fuzzy claim should mutate the existing edge struct")
|
|
}
|
|
|
|
func TestExternals_FuzzyClaimDoesNotMatchAcrossLines(t *testing.T) {
|
|
// Conservatism check: if a stub edge is at a different line, the
|
|
// fuzzy pass must NOT claim it — that would clobber unrelated
|
|
// outbound calls that happen to share a name.
|
|
root := resolvedTempDir(t)
|
|
stdlibCallerFixture(t, root)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
|
|
FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
|
|
FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
|
|
})
|
|
// Stub on a wildly-wrong line (line 99) — must survive untouched.
|
|
wrongLineStub := &graph.Edge{
|
|
From: "main.go::Greet", To: "unresolved::*.Println", Kind: graph.EdgeCalls,
|
|
FilePath: "main.go", Line: 99,
|
|
}
|
|
g.AddEdge(wrongLineStub)
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// The off-line stub must still be in its bucket — we didn't claim it.
|
|
stillThere := false
|
|
for _, e := range g.GetInEdges("unresolved::*.Println") {
|
|
_ = e
|
|
stillThere = true
|
|
}
|
|
assert.True(t, stillThere, "fuzzy claim must not match across lines")
|
|
}
|
|
|
|
func TestExternals_SkipsBuiltinsAndUniverseScope(t *testing.T) {
|
|
root := resolvedTempDir(t)
|
|
writeGoMod(t, root, "example.com/builtins")
|
|
writeFile(t, root, "main.go", `package main
|
|
|
|
func Loop(xs []int) int {
|
|
n := 0
|
|
for _, x := range xs {
|
|
n += x
|
|
}
|
|
return len(xs) + n
|
|
}
|
|
`)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Loop", Kind: graph.KindFunction, Name: "Loop",
|
|
FilePath: "main.go", StartLine: 3, EndLine: 9, Language: "go",
|
|
})
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// `int` and `len` are universe-scope objects (Pkg() == nil). They must
|
|
// not become external nodes — that would be noise.
|
|
for _, n := range g.AllNodes() {
|
|
if n.Kind == graph.KindModule {
|
|
continue
|
|
}
|
|
if !strings.HasPrefix(n.ID, "ext::") {
|
|
continue
|
|
}
|
|
t.Fatalf("unexpected external node from universe-scope obj: %s", n.ID)
|
|
}
|
|
}
|
|
|
|
func TestExternals_IdempotentOnReEnrich(t *testing.T) {
|
|
root := resolvedTempDir(t)
|
|
stdlibCallerFixture(t, root)
|
|
|
|
g := graph.New()
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Greet", Kind: graph.KindFunction, Name: "Greet",
|
|
FilePath: "main.go", StartLine: 9, EndLine: 12, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner", Kind: graph.KindType, Name: "Banner",
|
|
FilePath: "main.go", StartLine: 5, EndLine: 5, Language: "go",
|
|
})
|
|
g.AddNode(&graph.Node{
|
|
ID: "main.go::Banner.String", Kind: graph.KindMethod, Name: "String",
|
|
FilePath: "main.go", StartLine: 7, EndLine: 7, Language: "go",
|
|
})
|
|
|
|
p := newTestProvider(t)
|
|
_, err := p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
_, err = p.Enrich(g, root)
|
|
require.NoError(t, err)
|
|
|
|
// Count Greet → fmt.Println edges. Re-running Enrich must not
|
|
// duplicate them; the second pass should confirm the existing edge
|
|
// in place.
|
|
matchingEdges := 0
|
|
for _, e := range g.GetOutEdges("main.go::Greet") {
|
|
if e.To == "ext::go:fmt::Println" && e.Kind == graph.EdgeCalls {
|
|
matchingEdges++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, matchingEdges, "re-enrichment must not duplicate the external call edge")
|
|
|
|
// One stdlib module node only.
|
|
count := 0
|
|
for _, n := range g.AllNodes() {
|
|
if n.ID == "module::go:stdlib" {
|
|
count++
|
|
}
|
|
}
|
|
assert.Equal(t, 1, count)
|
|
}
|
|
|
|
func TestExternals_GoModuleNodeID(t *testing.T) {
|
|
tests := []struct {
|
|
path string
|
|
version string
|
|
want string
|
|
}{
|
|
{"github.com/foo/bar", "v1.2.3", "module::go:github.com/foo/bar@v1.2.3"},
|
|
{"github.com/foo/bar", "", "module::go:github.com/foo/bar"},
|
|
{"stdlib", "", "module::go:stdlib"},
|
|
}
|
|
for _, tt := range tests {
|
|
assert.Equal(t, tt.want, goModuleNodeID(tt.path, tt.version))
|
|
}
|
|
}
|
|
|
|
func TestExternals_ShortModulePath(t *testing.T) {
|
|
tests := map[string]string{
|
|
"": "",
|
|
"fmt": "fmt",
|
|
"github.com/foo/bar": "bar",
|
|
"github.com/foo/bar/v2": "bar",
|
|
"github.com/foo/bar/v10": "bar",
|
|
"github.com/foo/bar/sub": "sub",
|
|
"single": "single",
|
|
"vibrant": "vibrant", // not a major-version segment
|
|
}
|
|
keys := make([]string, 0, len(tests))
|
|
for k := range tests {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
assert.Equal(t, tests[k], shortModulePath(k), "input=%q", k)
|
|
}
|
|
}
|
|
|
|
func TestExternals_IsMajorVersion(t *testing.T) {
|
|
cases := map[string]bool{
|
|
"": false,
|
|
"v": false,
|
|
"v0": true,
|
|
"v1": true,
|
|
"v999": true,
|
|
"v1.0": false, // dots are not part of the suffix
|
|
"v1a": false,
|
|
"vibrant": false, // first letter v but rest non-digit
|
|
"version1": false,
|
|
}
|
|
keys := make([]string, 0, len(cases))
|
|
for k := range cases {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
assert.Equal(t, cases[k], isMajorVersion(k), "input=%q", k)
|
|
}
|
|
}
|