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

641 lines
20 KiB
Go

package goanalysis
import (
"go/types"
"strings"
"golang.org/x/tools/go/packages"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/semantic"
)
// stdlibModuleID is the synthetic KindModule node ID used to attribute Go
// standard-library symbols. The standard library has no go.mod entry, so
// we materialise a single shared module node so dependency queries
// ("which modules does pkg X reach into?") return a stdlib bucket
// alongside real module-cache modules.
const stdlibModuleID = "module::go:stdlib"
// modulePathStdlib is the canonical "path" for the stdlib module node. Kept
// in sync with the existing internal/modules naming so any consumer that
// groups KindModule nodes by Meta["path"] can find stdlib alongside real
// go.mod entries.
const modulePathStdlib = "stdlib"
// externalsAttribution holds bookkeeping for a single Enrich() pass. It
// owns:
//
// - pkgByPath: every transitively loaded *packages.Package, indexed by
// import path. Top-level packages.Load returns only the repo's own
// packages; we walk pkg.Imports recursively so external symbol
// resolution can find the owning module for stdlib / module-cache
// packages too.
// - moduleByPath: import path → KindModule node ID. Cached so the
// stdlib (and each dep module) materialises at most once per pass.
// - extByObj: types.Object → external node ID. Caches lookups across
// multiple Uses of the same external symbol.
//
// Statistics counters surface back through ExternalsResult so the caller
// can report nodes/edges added.
type externalsAttribution struct {
g graph.Store
pkgByPath map[string]*packages.Package
moduleByPath map[string]string
extByObj map[types.Object]string
provider string
// repoPrefix is the owning repo's prefix, used to namespace stub
// IDs (graph.StubID). Empty when the caller doesn't supply one
// — in that case stub IDs are emitted in the legacy un-prefixed
// form, which graph.IsStdlibStub / friends still recognise.
repoPrefix string
nodesAdded int
edgesAdded int
edgesUpgraded int
modulesLinked int
stdlibCreated bool
missingPkgInfo int
}
// newExternalsAttribution prepares externalsAttribution from the loaded
// roots. Walking pkg.Imports collects every dep — stdlib and module-cache
// alike — so resolveSymbol can find the owning *packages.Package for an
// arbitrary types.Object.
func newExternalsAttribution(g graph.Store, roots []*packages.Package, provider string) *externalsAttribution {
pkgByPath := make(map[string]*packages.Package)
var visit func(p *packages.Package)
visit = func(p *packages.Package) {
if p == nil || p.PkgPath == "" {
return
}
if _, seen := pkgByPath[p.PkgPath]; seen {
return
}
pkgByPath[p.PkgPath] = p
for _, imp := range p.Imports {
visit(imp)
}
}
for _, r := range roots {
visit(r)
}
return &externalsAttribution{
g: g,
pkgByPath: pkgByPath,
moduleByPath: make(map[string]string),
extByObj: make(map[types.Object]string),
provider: provider,
repoPrefix: deriveRepoPrefix(g, roots),
}
}
// deriveRepoPrefix peeks at the first source file across the
// enrichment roots and reads its RepoPrefix from the graph.
// All files belonging to a single semantic.Provider.Enrich call
// share one repo, so a single sample suffices. Returns "" when no
// matching file node is found — stubs then fall back to the
// legacy un-prefixed form, which graph.IsStdlibStub still accepts.
func deriveRepoPrefix(g graph.Store, roots []*packages.Package) string {
for _, r := range roots {
if r == nil {
continue
}
for _, f := range r.GoFiles {
if nodes := g.GetFileNodes(f); len(nodes) > 0 {
for _, n := range nodes {
if n != nil && n.RepoPrefix != "" {
return n.RepoPrefix
}
}
}
}
}
return ""
}
// resolveSymbol returns the graph node ID for an external go/types object,
// creating it (and the owning KindModule node, if not already present)
// on first sight. Returns "" when the object is unsuitable for
// externalisation: builtins / universe-scope (no Pkg), unsupported obj
// kinds, or a missing *packages.Package entry (rare — would mean a Use
// pointed at a package the loader didn't see).
//
// External symbols become first-class graph nodes so the call graph can
// reach into stdlib / module-cache without leaving stub-string targets
// behind. Each external symbol gains an EdgeDependsOnModule to its
// owning KindModule — i.e. stdlib/dep attribution.
func (e *externalsAttribution) resolveSymbol(obj types.Object) string {
if obj == nil || obj.Pkg() == nil {
return ""
}
if id, ok := e.extByObj[obj]; ok {
return id
}
importPath := obj.Pkg().Path()
if importPath == "" {
return ""
}
pkg, ok := e.pkgByPath[importPath]
if !ok {
// Loader didn't see this package — happens when packages.Load
// returns errors and the user-facing pkg list is partial. Bail
// rather than synthesize a half-formed node.
e.missingPkgInfo++
return ""
}
moduleID := e.ensureModuleNode(pkg)
if moduleID == "" {
// pkg belongs to the indexed repo (Main module). Caller should
// have routed obj through objToNode before falling back to
// externals — return "" so the caller drops the use.
return ""
}
kind := externalNodeKind(obj)
if kind == "" {
return ""
}
nodeID := externalNodeID(importPath, obj)
if nodeID == "" {
return ""
}
if existing := e.g.GetNode(nodeID); existing == nil {
e.g.AddNode(buildExternalNode(nodeID, kind, importPath, moduleID, pkg, obj, e.provider))
e.nodesAdded++
// Attribute the symbol to its module via EdgeDependsOnModule. The
// schema's existing convention is "file/package/import →
// KindModule"; an external symbol is morally equivalent — it's
// a first-class entity that depends on the module providing it.
e.g.AddEdge(&graph.Edge{
From: nodeID,
To: moduleID,
Kind: graph.EdgeDependsOnModule,
FilePath: externalFilePath(importPath),
Line: 0,
Confidence: 1.0,
ConfidenceLabel: "EXTRACTED",
Origin: graph.OriginLSPResolved,
Meta: map[string]any{
"semantic_source": e.provider,
},
})
e.edgesAdded++
e.modulesLinked++
}
e.extByObj[obj] = nodeID
return nodeID
}
// claimAndUpgradeStub looks for an existing edge from caller to one of the
// resolver's stub targets for this external symbol (stdlib::, dep::, or
// unresolved::extern::) and rewrites its To to point at the new external
// node. Returns the rewritten edge for the caller to confirm, or nil
// when no stub is found.
//
// Two passes:
//
// 1. Exact stub-string lookup. The parser+resolver shape for direct
// package calls like `fmt.Println(...)` lands as `stdlib::fmt::Println`
// after the resolver runs (or `unresolved::extern::fmt::Println` if
// resolution didn't happen yet). We replace the To with the real
// external node ID.
// 2. Fuzzy line-and-name match. Method calls on external types (e.g.
// `os.Stdout.Write(...)`) land as `unresolved::*.Write` because the
// parser doesn't know `os.Stdout` resolves to the os.File receiver.
// The fuzzy pass scans the caller's outgoing edges at the same line
// and matches by trailing-name, which is enough to correctly claim
// the stub without bringing in line-unrelated false positives.
//
// Why this matters: previously the resolver wrote stub-string targets like
// "stdlib::fmt::Println" that no node holds. Once goanalysis materialises
// the real ext::go:fmt::Println node, leaving the stub edge in place
// would double-count the call (one stub, one real). ReindexEdge migrates
// the byTo bucket so find_usages on the new node returns the correct
// caller and the stub bucket drains.
func (e *externalsAttribution) claimAndUpgradeStub(callerID string, importPath string, obj types.Object, newTarget string, line int) *graph.Edge {
if edge := e.claimByExactStub(callerID, importPath, obj, newTarget); edge != nil {
return edge
}
if edge := e.claimByLineAndName(callerID, obj, newTarget, line); edge != nil {
return edge
}
return nil
}
// claimByExactStub handles the canonical resolver-shaped targets. Pulled
// out so the fuzzy pass can layer on top.
func (e *externalsAttribution) claimByExactStub(callerID string, importPath string, obj types.Object, newTarget string) *graph.Edge {
candidates := stubEdgeTargets(e.repoPrefix, importPath, obj)
for _, target := range candidates {
edge := semantic.FindEdgeByTarget(e.g, callerID, target)
if edge == nil {
continue
}
oldTo := edge.To
edge.To = newTarget
e.g.ReindexEdge(edge, oldTo)
semantic.ConfirmEdge(edge, e.provider)
e.edgesUpgraded++
return edge
}
return nil
}
// claimByLineAndName scans the caller's outgoing edges at line `line` for
// any edge whose target is still a stub-string (`unresolved::`, `external::`,
// `stdlib::`, `dep::`) and whose trailing-name matches obj.Name(). Used
// for method calls on external types where the parser's `unresolved::*.M`
// shape doesn't carry the import path.
//
// Conservative — only matches stub targets so we never overwrite a
// resolver-confirmed real edge — and only when both line and trailing
// name match, which together pin the use-site uniquely.
func (e *externalsAttribution) claimByLineAndName(callerID string, obj types.Object, newTarget string, line int) *graph.Edge {
if line <= 0 {
return nil
}
name := obj.Name()
if name == "" {
return nil
}
expected := wantedEdgeKind(obj)
for _, edge := range e.g.GetOutEdges(callerID) {
if edge.Line != line {
continue
}
if expected != "" && edge.Kind != expected {
continue
}
if !isStubTarget(edge.To) {
continue
}
if !stubTargetTrailingNameMatches(edge.To, name) {
continue
}
oldTo := edge.To
edge.To = newTarget
e.g.ReindexEdge(edge, oldTo)
semantic.ConfirmEdge(edge, e.provider)
e.edgesUpgraded++
return edge
}
return nil
}
// wantedEdgeKind returns the EdgeKind goanalysis would emit for obj, used
// to scope the fuzzy claim so we don't accidentally rewrite an unrelated
// edge with a different semantic.
func wantedEdgeKind(obj types.Object) graph.EdgeKind {
if obj == nil {
return ""
}
switch obj.(type) {
case *types.Func:
return graph.EdgeCalls
case *types.TypeName, *types.Var, *types.Const:
return graph.EdgeReferences
}
return ""
}
// isStubTarget reports whether a target ID is one of the bookkeeping
// strings the resolver writes for unresolved or external lookups.
func isStubTarget(to string) bool {
switch {
case graph.IsUnresolvedTarget(to),
strings.HasPrefix(to, "external::"),
graph.IsStdlibStub(to),
strings.HasPrefix(to, "dep::"):
return true
}
return false
}
// stubTargetTrailingNameMatches reports whether the trailing name of a
// stub target (everything after the final `::` or after `*.`) equals
// `name`. The encodings include:
//
// unresolved::FooBar → trailing FooBar
// unresolved::*.Method → trailing Method
// unresolved::extern::pkg::Sym → trailing Sym
// stdlib::pkg::Sym → trailing Sym
// dep::pkg::Sym → trailing Sym
// external::pkg → trailing pkg (file-level imports)
func stubTargetTrailingNameMatches(to, name string) bool {
trailing := to
if idx := strings.LastIndex(trailing, "::"); idx >= 0 {
trailing = trailing[idx+2:]
}
trailing = strings.TrimPrefix(trailing, "*.")
return trailing == name
}
// ensureModuleNode finds (or creates) the KindModule node for pkg's owning
// module. Returns the module node ID, or "" when pkg belongs to the
// indexed repo's main module (no externalisation needed).
//
// The stdlib (pkg.Module == nil for stdlib) shares one synthetic
// "module::go:stdlib" node so callers can group stdlib references under
// a single edge target. Module-cache packages reuse any existing
// KindModule node materialised from go.mod by internal/modules and
// otherwise fall back to creating one — go/types is the source of
// truth when go.mod parsing missed the dep (e.g. tooling/test fixtures).
func (e *externalsAttribution) ensureModuleNode(pkg *packages.Package) string {
if pkg == nil {
return ""
}
importPath := pkg.PkgPath
if id, ok := e.moduleByPath[importPath]; ok {
return id
}
if pkg.Module == nil {
// No module info → assume Go stdlib. The stdlib has no go.mod
// entry; one shared module node covers every stdlib package.
if !e.stdlibCreated {
if existing := e.g.GetNode(stdlibModuleID); existing == nil {
e.g.AddNode(&graph.Node{
ID: stdlibModuleID,
Kind: graph.KindModule,
Name: "stdlib",
FilePath: externalFilePath("std"),
Language: "go",
Meta: map[string]any{
"ecosystem": "go",
"path": modulePathStdlib,
"version": "",
"module_kind": "stdlib",
"semantic_source": e.provider,
},
})
e.nodesAdded++
}
e.stdlibCreated = true
}
e.moduleByPath[importPath] = stdlibModuleID
return stdlibModuleID
}
if pkg.Module.Main {
// The indexed repo itself — caller should treat this obj as
// internal and look it up in objToNode instead.
e.moduleByPath[importPath] = ""
return ""
}
mod := pkg.Module
if mod.Replace != nil {
mod = mod.Replace
}
moduleID := goModuleNodeID(mod.Path, mod.Version)
if existing := e.g.GetNode(moduleID); existing == nil {
meta := map[string]any{
"ecosystem": "go",
"path": mod.Path,
"version": mod.Version,
"module_kind": "module_cache",
"indirect": mod.Indirect,
"semantic_source": e.provider,
}
if pkg.Module.Replace != nil {
meta["replace"] = mod.Path + "@" + mod.Version
meta["replaced_path"] = pkg.Module.Path
}
e.g.AddNode(&graph.Node{
ID: moduleID,
Kind: graph.KindModule,
Name: shortModulePath(mod.Path),
FilePath: externalFilePath(mod.Path),
Language: "go",
Meta: meta,
})
e.nodesAdded++
}
e.moduleByPath[importPath] = moduleID
return moduleID
}
// stubEdgeTargets enumerates every stub-string the resolver might have
// written for an external obj. Order matches resolver precedence:
// stdlib::/dep:: are produced post-resolve, unresolved::extern:: is the
// raw form when resolveExtern wasn't run.
//
// repoPrefix namespaces the stdlib stub form per-repo so two repos
// pinned to different Go SDK versions don't collide on a single
// `stdlib::fmt::Errorf` node. An empty repoPrefix yields the legacy
// un-prefixed form, which the resolver still emits today.
func stubEdgeTargets(repoPrefix, importPath string, obj types.Object) []string {
if obj == nil {
return nil
}
name := obj.Name()
if name == "" {
return nil
}
return []string{
graph.StubID(repoPrefix, graph.StubKindStdlib, importPath, name),
"dep::" + importPath + "::" + name,
"unresolved::extern::" + importPath + "::" + name,
}
}
// buildExternalNode constructs a graph node for an external symbol. The
// FilePath is a synthetic "external::go:<importPath>" string so byFile
// lookups don't pollute real source-file buckets.
func buildExternalNode(nodeID string, kind graph.NodeKind, importPath, moduleID string, pkg *packages.Package, obj types.Object, provider string) *graph.Node {
moduleKind, version := classifyPackage(pkg)
meta := map[string]any{
"external": true,
"import_path": importPath,
"module_path": modulePathOf(pkg),
"module_kind": moduleKind,
"module_id": moduleID,
"semantic_source": provider,
}
if version != "" {
meta["version"] = version
}
if sig := types.ObjectString(obj, nil); sig != "" {
meta["signature"] = sig
}
if typeStr := types.TypeString(obj.Type(), nil); typeStr != "" && typeStr != "invalid type" {
meta["semantic_type"] = typeStr
}
if recv := receiverTypeName(obj); recv != "" {
meta["receiver"] = recv
}
qualName := importPath + "." + obj.Name()
if recv := receiverTypeName(obj); recv != "" {
qualName = importPath + "." + recv + "." + obj.Name()
}
return &graph.Node{
ID: nodeID,
Kind: kind,
Name: obj.Name(),
QualName: qualName,
FilePath: externalFilePath(importPath),
Language: "go",
Meta: meta,
}
}
// classifyPackage labels a loaded package as stdlib / module_cache / main.
// Returns (kind, version) — version is empty for stdlib and main.
func classifyPackage(pkg *packages.Package) (string, string) {
if pkg == nil {
return "", ""
}
if pkg.Module == nil {
return "stdlib", ""
}
if pkg.Module.Main {
return "main", ""
}
mod := pkg.Module
if mod.Replace != nil {
mod = mod.Replace
}
return "module_cache", mod.Version
}
// modulePathOf returns the owning module's path for pkg. Stdlib packages
// fall back to the package import path so callers can group by package
// (the stdlib has no module path proper); module-cache packages return
// the module path which may be a prefix of the import path.
func modulePathOf(pkg *packages.Package) string {
if pkg == nil {
return ""
}
if pkg.Module == nil {
return pkg.PkgPath
}
mod := pkg.Module
if mod.Replace != nil {
mod = mod.Replace
}
return mod.Path
}
// receiverTypeName returns the bare type name of obj's receiver when obj
// is a method. Strips pointer wrappers — Go forbids both `func (T) M()`
// and `func (*T) M()` on the same type, so the bare name uniquely
// identifies the method's owner. Returns "" for non-methods.
func receiverTypeName(obj types.Object) string {
fn, ok := obj.(*types.Func)
if !ok {
return ""
}
sig, ok := fn.Type().(*types.Signature)
if !ok || sig.Recv() == nil {
return ""
}
t := sig.Recv().Type()
if ptr, ok := t.(*types.Pointer); ok {
t = ptr.Elem()
}
if named, ok := t.(*types.Named); ok {
return named.Obj().Name()
}
return ""
}
// externalNodeID returns the canonical ID for an external symbol node.
// Methods are disambiguated by receiver type so `os.File.Write` and a
// hypothetical `os.Foo.Write` would land on distinct nodes; pure
// functions / vars / consts / types use the package-qualified short
// name. Returns "" when name is empty.
func externalNodeID(importPath string, obj types.Object) string {
name := obj.Name()
if name == "" {
return ""
}
if recv := receiverTypeName(obj); recv != "" {
return "ext::go:" + importPath + "::" + recv + "." + name
}
return "ext::go:" + importPath + "::" + name
}
// externalNodeKind classifies a types.Object into the matching graph
// NodeKind. Funcs with a receiver become methods; type names with an
// interface underlying type become interfaces. Unsupported obj kinds
// (PkgName, Label, Builtin, Nil) return "" so resolveSymbol drops them.
func externalNodeKind(obj types.Object) graph.NodeKind {
switch t := obj.(type) {
case *types.Func:
if sig, ok := t.Type().(*types.Signature); ok && sig.Recv() != nil {
return graph.KindMethod
}
return graph.KindFunction
case *types.TypeName:
if t.Type() == nil {
return graph.KindType
}
if _, isIface := t.Type().Underlying().(*types.Interface); isIface {
return graph.KindInterface
}
return graph.KindType
case *types.Var:
return graph.KindVariable
case *types.Const:
return graph.KindConstant
default:
return ""
}
}
// externalFilePath returns the synthetic file-path used for external
// nodes. Kept consistent so byFile lookups don't pollute real file
// buckets.
func externalFilePath(importPath string) string {
if importPath == "" {
return "external::go"
}
return "external::go:" + importPath
}
// goModuleNodeID returns the canonical KindModule node ID for a Go
// module. Mirrors internal/modules/scanner.go::ModuleNodeID without
// importing scanner — that import would create graph→modules→graph
// cycle when scanner already imports graph.
func goModuleNodeID(path, version string) string {
id := "module::go:" + path
if version != "" {
id += "@" + version
}
return id
}
// shortModulePath returns the last meaningful segment of a module path,
// stripping the /vN major-version suffix so `github.com/foo/bar/v2`
// surfaces as `bar` not `v2`. Mirrors internal/modules/scanner.go::shortName.
func shortModulePath(path string) string {
if path == "" {
return ""
}
parts := strings.Split(path, "/")
last := parts[len(parts)-1]
if len(parts) >= 2 && isMajorVersion(last) {
last = parts[len(parts)-2]
}
return last
}
// isMajorVersion matches "v2", "v3", "v10" etc. — the Go-modules SemVer
// major-version suffix that lives at the end of a module path.
func isMajorVersion(s string) bool {
if len(s) < 2 || s[0] != 'v' {
return false
}
for i := 1; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}