package resolver import ( "path" "strings" "github.com/zzet/gortex/internal/graph" ) // attributeGoExternalCalls materialises a KindFunction node for every // unique `stdlib::::` / `dep::::` // / `external::::` edge target, plus a KindModule // parent for each owning import path. Without this pass the targets // are stubs in storage backends that enforce rel-table FK (the on-disk backend) // and invisible nodes in the in-memory backend, so a query like // `find_usages(stdlib::encoding/json::Marshal)` // can't surface "every function in this codebase that calls // json.Marshal" — the destination doesn't exist as a graph node. // // Mirrors the Python / Dart attributeNonGoModuleImports pass for Go. // Runs after resolveExtern (which classifies extern targets into the // three prefix buckets) so we materialise the post-classification // state rather than the pre-classification `unresolved::extern::*` // shape. // // ID conventions: // - Module node: `module::go:` — shared across every // repo that imports the same path. Carries // Meta["ecosystem"]="go" and Meta["import_path"]=. // Meta["role"]="stdlib" for stdlib paths. // - Symbol node: the original `stdlib::*` / `dep::*` / // `external::*` ID stays the symbol's ID so existing edges land // on it without rewriting. Carries Meta["external"]=true and // Meta["module_path"]=. // - EdgeMemberOf: symbol → module so `get_callers` on the module // surfaces every symbol used from that package. // // All AddNode / AddEdge calls are idempotent on ID, so a second run // of this pass (incremental ResolveFile re-invocation) is a no-op. // extKey identifies a unique external target across the attribution // passes; modKey identifies its owning module. Package-level so the // whole-graph and single-file collectors feed one materialiser. // // repoPrefix is part of the key because stdlib stubs are per-repo (see // internal/graph/stub.go) — two repos on different Go SDK versions emit // semantically distinct `::stdlib::fmt::Errorf` and // `::stdlib::fmt::Errorf` stubs that MUST round-trip through this // attribution as distinct nodes, not collide into one. type extKey struct { repoPrefix, prefix, importPath, symbol string } type modKey struct{ repoPrefix, importPath string } // goExternalAttribKinds is every edge kind an extern-prefixed target can // show up on — the same set attributeGoBuiltins scans. var goExternalAttribKinds = []graph.EdgeKind{ graph.EdgeCalls, graph.EdgeReferences, graph.EdgeReads, graph.EdgeArgOf, graph.EdgeValueFlow, graph.EdgeReturnsTo, graph.EdgeTypedAs, graph.EdgeReturns, graph.EdgeInstantiates, graph.EdgeCaptures, graph.EdgeThrows, } func (r *Resolver) attributeGoExternalCalls() { // Go-only pass: skip the external-prefix edge scan when the graph has // no Go nodes. if !r.graphHasLanguage("go") { return } seen := map[extKey]struct{}{} for _, k := range goExternalAttribKinds { for e := range r.graph.EdgesByKind(k) { collectGoExternalTarget(e, seen) } } r.materializeGoExternalSeen(seen) } // attributeGoExternalCallsForFile is the single-file scope of // attributeGoExternalCalls: an extern-prefixed target is referenced from // inside the edited file, so only that file's outgoing edges can // introduce a new one. Produces the same materialisation as the // whole-graph sweep for a per-save resolve. func (r *Resolver) attributeGoExternalCallsForFile(filePath string) { if !r.graphHasLanguage("go") { return } seen := map[extKey]struct{}{} for _, e := range r.fileOutEdges(filePath) { collectGoExternalTarget(e, seen) } r.materializeGoExternalSeen(seen) } // collectGoExternalTarget records e's external target (if any) into seen, // deduping by the per-repo (prefix, path, symbol) tuple. func collectGoExternalTarget(e *graph.Edge, seen map[extKey]struct{}) { if e == nil || e.To == "" { return } prefix, importPath, symbol := splitGoExternalTarget(e.To) if prefix == "" { return } seen[extKey{graph.StubRepoPrefix(e.To), prefix, importPath, symbol}] = struct{}{} } // materializeGoExternalSeen turns the collected external targets into // KindModule + KindFunction nodes and their EdgeMemberOf links. All // AddNode / AddEdge calls are idempotent on ID, so a second run (e.g. a // re-resolve of the same file) is a no-op. func (r *Resolver) materializeGoExternalSeen(seen map[extKey]struct{}) { if len(seen) == 0 { return } // Materialise the parent KindModule for each unique import path, // then the per-symbol KindFunction. Module-side dedupe is via // the `modules` map; the per-symbol nodes are unique by (prefix, // path, symbol) by construction. // Module IDs are also per-repo now — a module node carries the // same SDK-version sensitivity its symbols do. Key includes the // repo prefix so two repos importing the same path get distinct // module nodes. modules := map[modKey]string{} for k := range seen { modKey := modKey{repoPrefix: k.repoPrefix, importPath: k.importPath} moduleID, ok := modules[modKey] if !ok { // Ecosystem + path are ONE stub segment joined by a single // colon (`go:`), matching the npm convention // (`module::npm:`) and every module-id consumer // (tools_analyze_external_calls). Passing them as two // StubID parts would emit `module::go::` (double // colon) — the form that broke the attribution tests. moduleID = graph.StubID(k.repoPrefix, graph.StubKindModule, "go:"+k.importPath) modules[modKey] = moduleID role := "external" switch k.prefix { case "stdlib::": role = "stdlib" case "dep::": role = "dep" } r.graph.AddNode(&graph.Node{ ID: moduleID, Kind: graph.KindModule, Name: lastImportSegment(k.importPath), Language: "go", Meta: map[string]any{ "ecosystem": "go", "role": role, "import_path": k.importPath, }, }) } var symbolID string switch k.prefix { case "stdlib::": symbolID = graph.StubID(k.repoPrefix, graph.StubKindStdlib, k.importPath, k.symbol) default: // dep:: / external:: keep their legacy unprefixed form for // now — they aren't covered by the stub-prefix migration // (different module paths already provide repo-level // distinction; same version pinning is enforced by go.mod // per-repo). symbolID = k.prefix + k.importPath + "::" + k.symbol } r.graph.AddNode(&graph.Node{ ID: symbolID, Kind: graph.KindFunction, Name: k.symbol, Language: "go", Meta: map[string]any{ "external": true, "module_path": k.importPath, "module_role": map[string]string{ "stdlib::": "stdlib", "dep::": "dep", "external::": "external", }[k.prefix], }, }) // EdgeMemberOf: symbol → module. AddEdge is idempotent on the // edge-key tuple so a re-run doesn't duplicate. r.graph.AddEdge(&graph.Edge{ From: symbolID, To: moduleID, Kind: graph.EdgeMemberOf, Origin: graph.OriginASTResolved, }) } } // splitGoExternalTarget recognises the three external-target prefixes // the resolver emits after resolveExtern. Returns the prefix // (`stdlib::` / `dep::` / `external::`), the import path, and the // symbol name. Returns ("", "", "") for any other shape so the pass // can skip it cleanly. // // The stdlib case is matched via graph.IsStdlibStub so both the // legacy `stdlib::fmt::Errorf` shape and the per-repo-prefixed // `::stdlib::fmt::Errorf` shape (see internal/graph/stub.go) // route the same way. The returned bucket label stays `stdlib::` for // downstream `k.prefix == "stdlib::"` comparisons. func splitGoExternalTarget(target string) (prefix, importPath, symbol string) { var body string switch { case graph.IsStdlibStub(target): prefix = "stdlib::" body = graph.StubRest(target) case strings.HasPrefix(target, "dep::"): prefix = "dep::" body = strings.TrimPrefix(target, prefix) case strings.HasPrefix(target, "external::"): prefix = "external::" body = strings.TrimPrefix(target, prefix) default: return "", "", "" } // The body shape produced by resolveExtern is // `::`. Split on the LAST `::` because import // paths can include slashes but not `::`, so the rightmost // separator is always between path and symbol. sep := strings.LastIndex(body, "::") if sep < 0 { // `external::os` style (just the package, no symbol — // the resolveImport path). Treat the whole body as the path // and leave symbol empty so we still materialise the module // node but skip the symbol. return prefix, body, "" } return prefix, body[:sep], body[sep+2:] } // lastImportSegment returns the rightmost path component, used as // the human-readable Name on the KindModule node. For // `github.com/stretchr/testify/assert` the segment is `assert`; for // `encoding/json` it's `json`; for `fmt` it's `fmt`. func lastImportSegment(importPath string) string { if importPath == "" { return "" } return path.Base(importPath) }