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:" 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 }