package languages import ( "bytes" "fmt" "strings" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/parser" sitter "github.com/zzet/gortex/internal/parser/tsitter" "github.com/zzet/gortex/internal/parser/tsitter/golang" ) // qGoAll is a single tree-sitter query that alternates over every pattern // the Go extractor needs. One tree walk per file replaces the 20+ // `parser.RunQuery` calls the previous design made per Extract call // (each of which recompiled its query and ran an independent cursor // over the whole tree). Capture names are disjoint across patterns so // the dispatch in Extract can branch on which name is set. const qGoAll = ` [ (package_clause (package_identifier) @pkg.name) (function_declaration name: (identifier) @func.name parameters: (parameter_list) @func.params result: (_)? @func.result) @func.def (method_declaration receiver: (parameter_list) @method.receiver name: (field_identifier) @method.name parameters: (parameter_list) @method.params result: (_)? @method.result) @method.def (type_declaration (type_spec name: (type_identifier) @typedef.name type: (_) @typedef.body)) @typedef.def (type_declaration (type_alias name: (type_identifier) @alias.name type: (_) @alias.type)) @alias.def (import_spec name: (package_identifier)? @import.alias path: (interpreted_string_literal) @import.path) @import.spec (call_expression function: (identifier) @call.name) @call.expr (call_expression function: (selector_expression operand: (_) @callm.receiver field: (field_identifier) @callm.method)) @callm.expr (var_declaration (var_spec name: (identifier) @var.name type: (_)? @var.type)) @var.def (const_declaration (const_spec name: (identifier) @const.name)) @const.def (short_var_declaration left: (expression_list (identifier) @svar.name) right: (expression_list (_) @svar.value)) @svar.def (composite_literal type: (type_identifier) @comp.type) @comp.expr (composite_literal type: (qualified_type package: (package_identifier) @compq.pkg name: (type_identifier) @compq.type)) @compq.expr (field_declaration type: (type_identifier) @ftype.name) @ftype.decl (const_spec type: (type_identifier) @ctype.name) @ctype.decl (var_spec type: (type_identifier) @vtype.name) @vtype.decl (parameter_declaration type: (type_identifier) @ptype.name) @ptype.decl ; Type assertions: x.(T) / x.(pkg.T). The asserted type is a ; reference to T. Without this an interface used only via a type ; assertion has no incoming edge and looks like dead code. (type_assertion_expression type: (type_identifier) @assert.name) @assert.decl (type_assertion_expression type: (qualified_type package: (package_identifier) @assertq.pkg name: (type_identifier) @assertq.name)) @assertq.decl (argument_list (selector_expression operand: (_) @selarg.receiver field: (field_identifier) @selarg.field)) @selarg.list (argument_list (identifier) @identarg.name) @identarg.list (keyed_element (literal_element (identifier) @fieldval.key) (literal_element (identifier) @fieldval.value)) @fieldval.elem ; Struct field set to a string literal, e.g. ActivityName: "Charge". ; The Temporal step/executor resolver joins these to a struct whose ; method dispatches via that field. (keyed_element (literal_element (identifier) @fieldstr.key) (literal_element (interpreted_string_literal) @fieldstr.val)) @fieldstr.elem (keyed_element (literal_element (identifier) @fieldsel.key) (literal_element (selector_expression operand: (_) @fieldsel.receiver field: (field_identifier) @fieldsel.method))) @fieldsel.elem ; Assignment LHS selector. EdgeWrites from the enclosing function ; to the field. Covers plain "s.field = x" and "s.field += x". (assignment_statement left: (expression_list (selector_expression operand: (_) @assign.receiver field: (field_identifier) @assign.field))) @assign.def ; Inc/dec selector statements: s.field++ / s.field-- both write. (inc_statement (selector_expression operand: (_) @incsel.receiver field: (field_identifier) @incsel.field)) @incsel.def (dec_statement (selector_expression operand: (_) @decsel.receiver field: (field_identifier) @decsel.field)) @decsel.def ; Return-statement value-side reads. "return s.port, s.addr" reads ; both fields. We capture each selector in the return expression ; list so the schema's "every value use is a read" rule covers ; functions whose only output is the read. (return_statement (expression_list (selector_expression operand: (_) @retsel.receiver field: (field_identifier) @retsel.field)) @retsel.list) (return_statement (expression_list (identifier) @retident.name) @retident.list) ] ` // GoExtractor extracts Go source files into graph nodes and edges. type GoExtractor struct { lang *sitter.Language qAll *parser.PreparedQuery // envHelperExtra is the per-repo corporate env-helper allow-list (lower- // cased names) loaded from the git-ignored `.gortex/temporal-allowlist.yaml`. // Names here are merged with the built-in goEnvHelperNames when recognising // a Temporal env-or-default dispatch helper, promoting the resolved edge to // the inferred (visible) tier. Empty/nil when no local allow-list is loaded. envHelperExtra map[string]bool } // SetEnvHelperNames installs the per-repo corporate env-helper allow-list on // the extractor. Names are stored lower-cased for case-insensitive matching. // Called once during extractor registration (config is not available at parse // time); a nil / empty slice clears it. Safe to call before indexing begins; // must not be called concurrently with Extract. func (e *GoExtractor) SetEnvHelperNames(names []string) { if len(names) == 0 { e.envHelperExtra = nil return } m := make(map[string]bool, len(names)) for _, n := range names { if n = strings.TrimSpace(n); n != "" { m[strings.ToLower(n)] = true } } e.envHelperExtra = m } func NewGoExtractor() *GoExtractor { lang := golang.GetLanguage() return &GoExtractor{ lang: lang, qAll: parser.MustPreparedQuery(qGoAll, lang), } } func (e *GoExtractor) Language() string { return "go" } func (e *GoExtractor) Extensions() []string { return []string{".go"} } // --- Deferred match buffers ---------------------------------------- type goDeferredCall struct { callName string // plain call method string // selector call method name receiver string // selector call receiver text line int // 1-based line of call_expression isSelector bool spawn bool // call is launched via `go` — emit EdgeSpawns alongside EdgeCalls // returnUsage is how the call site consumes the return value // (graph.ReturnUsage* label), classified from the call node's // parent chain at capture time and stamped as edge Meta on every // EdgeCalls emitted for this site. Empty when unclassifiable. returnUsage string // gRPC server registration. Set when this call is the generated // `RegisterServer(registrar, impl)` helper: grpcRegService // is the service name and grpcRegArgNode is the second-argument AST // node naming the impl. The impl type itself is resolved in the // post-pass below — after the file-wide type environment is fully // populated, since the impl may be a variable declared anywhere in // the file. Stamped as edge Meta so the resolver's // ResolveGRPCStubCalls pass can join client-stub calls to the // server-side handler method. grpcRegService string grpcRegArgNode *sitter.Node // Temporal SDK call attribution. tempKind is "" for non-Temporal // calls; "activity" / "workflow" / "register_activity" / // "register_workflow" identifies which Temporal pattern this call // is. tempName is the activity / workflow name extracted from the // call (function reference or string literal). tempLocal flags // `workflow.ExecuteLocalActivity`. The actual edge target + // `via=temporal.stub` / `via=temporal.register` meta is stamped // in the call post-pass below; the resolver's ResolveTemporalCalls // pass joins stub calls to the registered handler. tempKind string tempName string tempLocal bool // tempEnvDefault is set when tempName was resolved from a bare // variable read from an env var with a literal default (e.g. // `cmp.Or(os.Getenv("K"), "Default")`). The stub edge is then tagged // `temporal_name_origin=env_default` so the resolver lands it at the // speculative tier — the runtime env value may differ from the default. tempEnvDefault bool // tempVarTrace is set when tempName was recovered by tracing a bare // local dispatch variable to its last intra-procedural assignment — a // string literal, a const reference, or a no-arg const-returning func // (the "meta_vars" broken-dispatch category). The stub edge is tagged // `temporal_name_origin=var_trace` so the resolver lands it at the // speculative tier — last-assignment-wins is a best-effort static guess. tempVarTrace bool // tempHandlerKind is "query" / "signal" / "update" when this call // is a `workflow.SetQueryHandler` / `GetSignalChannel` / // `SetUpdateHandler` in-workflow handler declaration; tempName then // carries the handler's string name. `via=temporal.handler` meta is // stamped on the emitted edge in the call post-pass below. tempHandlerKind string // tempDefaultConst is set when the env-default's default argument was a // constant REFERENCE (`config.ACTIVITY_NAME_DEFAULT` / a bare const name) // rather than a string literal. temporal_name then stays the dispatch // variable name; the resolver substitutes the constant's literal value // from its const-deref index. Emitted as `temporal_default_const`. tempDefaultConst string // tempEnvSource records HOW the env-default name was recognised: // "os_getenv" (provable os.Getenv / os.LookupEnv read), "allowlist" (a // helper name in the configured env-helper allow-list), or "heuristic" // (a generic env-named helper matched structurally). Emitted as // `temporal_env_source` so the resolver can tier the edge: allow-list / // os.Getenv land at the inferred tier, the heuristic stays speculative. tempEnvSource string // tempOutKind is "signal" / "query" when this call is an outbound // signal-send / query-call against a running workflow // (SignalExternalWorkflow / SignalWorkflow / QueryWorkflow); tempName // then carries the signal/query name. `via=temporal.signal-send` / // `temporal.query-call` meta is stamped on the emitted edge below. tempOutKind string // tempRegisteredName is the canonical registered name when a // RegisterActivityWithOptions / RegisterWorkflowWithOptions call // overrides it via RegisterOptions{Name: "..."}. tempName still holds // the function-reference identifier (used to locate the node); this // is the name the activity/workflow is dispatched under and becomes // the resolver's index key. Empty when no Name override is present. tempRegisteredName string // tempRegisterPlural marks a `w.RegisterActivities(&MyActivities{})` // struct registration: tempName then holds the struct TYPE name and // the resolver promotes every exported method of that struct to a // temporal activity keyed by the method name. tempRegisterPlural bool // tempStartName is the workflow name when this call STARTS a workflow // (client.ExecuteWorkflow / SignalWithStartWorkflow). `via=temporal.start` // meta is stamped on the emitted edge and the resolver rewrites it to // the registered workflow — the "who starts this workflow" edge. tempStartName string // callNode is the call_expression AST node, retained so the general // call-edge emission can extract positional arg names // (attachGoTemporalCallArgNames) for the resolver's wrapper-following // pass; the executor-field resolver also uses it to inspect the // dispatch argument shape (e.g. `e.ActivityName` as a selector). // Nil for synthetic / non-call entries. callNode *sitter.Node } // PURPOSE — captures a struct-literal field assignment to a string literal // so the Temporal executor-field resolver can join the construction site // to a dispatch method that reads the same field. // RATIONALE — separate from goDeferredCall because it fires on composite // literals, not call expressions; the two passes share a single tree walk. // KEYWORDS — temporal, executor, struct-field type goExecutorField struct { typeName string field string value string line int } type goDeferredTypeRef struct { typeName string pkg string // optional qualifier line int kind graph.EdgeKind } type goDeferredValueSel struct { field string receiver string line int kind graph.EdgeKind } type goDeferredValueIdent struct { name string line int } func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionResult, error) { tree, err := parser.ParseFile(src, e.lang) if err != nil { return nil, err } // Wrap in a ref-counted handle and stamp it on the result so the // indexer can hand it to contract enrichers. The wrapper owns // the tree from here; callers Release() once they're done. We // don't defer Close — closing happens in the indexer after the // contract pass releases its ref. pt := parser.NewParseTree(tree, src, "go") result := &parser.ExtractionResult{Tree: pt} root := tree.RootNode() fileNode := &graph.Node{ ID: filePath, Kind: graph.KindFile, Name: filePath, FilePath: filePath, StartLine: 1, EndLine: int(root.EndPoint().Row) + 1, Language: "go", } fileID := fileNode.ID result.Nodes = append(result.Nodes, fileNode) imports := map[string]string{} // alias → importPath // Local alias the Temporal workflow package is imported under in this // file (default "workflow"; "" when not imported). The receiver-gated // temporal detectors canonicalise a matching receiver to "workflow" so // an aliased `import wf "go.temporal.io/sdk/workflow"` is recognised. wfAlias := goWorkflowReceiverAlias(root, src) tenv := make(typeEnv) // paramsByFunc: enclosing-function ID → (param/receiver name → type). // Function parameters and method receivers shadow file-wide tenv at // call resolution time so each function's locals stay sandboxed. paramsByFunc := map[string]typeEnv{} // paramNamesByFunc: function/method ID → set of ALL its parameter // names (paramsByFunc only keeps params with non-builtin types). Used // by the Temporal wrapper detector to recognise a dispatch whose name // is a forwarded parameter. paramNamesByFunc := map[string]map[string]bool{} // seenIDs: per-file node-ID collision set. Two declarations that build // the same base ID (e.g. two func init()) both survive as distinct nodes // via a line suffix instead of one silently overwriting the other. seenIDs := map[string]bool{} seenTypeName := map[string]bool{} // dedup when alias + typedef match same name var calls []goDeferredCall // grpcStubVars maps a local variable name to the gRPC service it is // a client stub for, seeded from `v := pkg.NewClient(conn)` // short-var declarations. Lets `v.Method(...)` call sites resolve as // gRPC stub calls in the post-pass below. grpcStubVars := map[string]string{} var typeRefs []goDeferredTypeRef var instantiates []goDeferredTypeRef var valueSels []goDeferredValueSel var valueIdents []goDeferredValueIdent var fieldValSels []goDeferredValueSel var fieldValIdents []goDeferredValueIdent var observabilityEvents []goObservabilityEvent var pubsubCandidates []goPubsubCandidate var stringEvents []goStringEvent var flagEvents []goFlagEvent var configEvents []goConfigEvent var sqlEvents []goSQLEvent // writes buffers selector LHS of assignment / inc / dec // statements. Emitted in the post-pass once funcRanges and tenv // are settled so each EdgeWrites is attributed to its enclosing // function and (when known) carries the receiver type for the // resolver to land on the right field node. var writes []goDeferredValueSel var executorFields []goExecutorField parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) { switch { // Package: just a marker; not emitted as a graph node. case m.Captures["pkg.name"] != nil: // No-op (the package name is not currently surfaced as a node). case m.Captures["func.def"] != nil: e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) case m.Captures["method.def"] != nil: e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports, seenIDs) case m.Captures["typedef.def"] != nil: e.emitTypeDecl(m, filePath, fileID, src, result, seenTypeName) case m.Captures["alias.def"] != nil: e.emitTypeAlias(m, filePath, fileID, src, result, seenTypeName) case m.Captures["import.spec"] != nil: e.emitImport(m, filePath, fileID, result, imports, fileNode) case m.Captures["call.expr"] != nil: expr := m.Captures["call.expr"] callName := m.Captures["call.name"].Text dc := goDeferredCall{ callName: callName, line: expr.StartLine + 1, spawn: isGoroutineSpawn(expr.Node), returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec), callNode: expr.Node, } if svc, argNode, ok := grpcRegisterArgNode(expr.Node, callName); ok { dc.grpcRegService, dc.grpcRegArgNode = svc, argNode } calls = append(calls, dc) case m.Captures["callm.expr"] != nil: expr := m.Captures["callm.expr"] method := m.Captures["callm.method"].Text receiver := m.Captures["callm.receiver"].Text dc := goDeferredCall{ method: method, receiver: receiver, line: expr.StartLine + 1, isSelector: true, spawn: isGoroutineSpawn(expr.Node), returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec), callNode: expr.Node, } dc.callNode = expr.Node if svc, argNode, ok := grpcRegisterArgNode(expr.Node, method); ok { dc.grpcRegService, dc.grpcRegArgNode = svc, argNode } // Temporal workflow → activity dispatch: // `workflow.ExecuteActivity(ctx, X, ...)` etc. Canonicalise an // aliased workflow-package receiver (e.g. `wf`) to "workflow" // so the receiver-gated detectors recognise it. tempRecv := goCanonicalWorkflowReceiver(receiver, wfAlias) if kind, local, ok := goTemporalDispatchKind(tempRecv, method); ok { argNode := goTemporalDispatchArg(expr.Node) if name := goTemporalNameFromExpr(argNode, src); name != "" { dc.tempKind = kind dc.tempName = name dc.tempLocal = local // Env-default refinement: when the name is a bare local // variable, try to resolve it to an env-var-with-literal // -default so the dispatch lands on the default activity. if argNode != nil && argNode.Type() == "identifier" { if litDef, constName, source, ok := goTemporalEnvDefaultName(expr.Node, name, src, e.envHelperExtra); ok { dc.tempEnvDefault = true dc.tempEnvSource = source if constName != "" { // Const-reference default: keep temporal_name as the // dispatch variable; the resolver substitutes the // constant's literal value via temporal_default_const. dc.tempDefaultConst = constName } else { dc.tempName = litDef } } else if lit, cn, ok := goTemporalVarTrace(expr.Node, name, src); ok { // Variable tracing (Cat 2): the dispatch name is a bare // local var assigned a string literal, a const // reference, or a no-arg const-returning func. Emit the // traced value as the dispatch name — a literal resolves // directly; a const NAME is dereferenced by the // resolver's const-deref map (a non-const name simply // fails to deref and stays a broken_dispatch). switch { case lit != "": dc.tempName = lit case cn != "": dc.tempName = cn } dc.tempVarTrace = true } } } } else if kind, plural, ok := goTemporalRegisterKind(method); ok { // Temporal worker registration: // `w.RegisterActivity(F)` etc. if plural { // `w.RegisterActivities(&MyActivities{})` — every // exported method of the struct becomes an activity. // tempName carries the struct TYPE name; the resolver // promotes the methods. if st := goTemporalRegisterStructType(expr.Node, src); st != "" { dc.tempKind = "register_" + kind dc.tempName = st dc.tempRegisterPlural = true } } else if name := goTemporalRegisterName(expr.Node, src); name != "" { dc.tempKind = "register_" + kind dc.tempName = name // RegisterActivityWithOptions / RegisterWorkflowWithOptions // may override the registered name via // RegisterOptions{Name: "..."} — that is the name a // dispatch matches against, so capture it as the index key. if override := goTemporalRegisterNameOverride(expr.Node, src); override != "" { dc.tempRegisteredName = override } } } else if hkind, ok := goTemporalHandlerKind(tempRecv, method); ok { // Temporal in-workflow handler declaration: // `workflow.SetQueryHandler(ctx, "name", fn)` etc. if name := goTemporalHandlerName(expr.Node, src); name != "" { dc.tempHandlerKind = hkind dc.tempName = name } } else if okind, namePos, ok := goTemporalSignalQueryOutKind(tempRecv, method); ok { // Outbound signal-send / query-call against a running // workflow: SignalExternalWorkflow / SignalWorkflow / // QueryWorkflow. The name is the 4th positional literal. if name := goTemporalNthStringLiteralArg(expr.Node, namePos, src); name != "" { dc.tempOutKind = okind dc.tempName = name } } else if wfPos, ok := goTemporalStartKind(method); ok { // Service-side workflow START: client.ExecuteWorkflow / // SignalWithStartWorkflow. The workflow is the wfPos-th // positional arg (a func ref, selector, or string type name). if name := goTemporalNthArgName(expr.Node, wfPos, src); name != "" { dc.tempStartName = name } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { observabilityEvents = append(observabilityEvents, goObservabilityEvent{ method: method, name: name, line: expr.StartLine + 1, }) } if cand, ok := detectGoPubsubCall(expr.Node, method, src); ok { pubsubCandidates = append(pubsubCandidates, cand) } receiverText := m.Captures["callm.receiver"].Text if name, ok := detectGoMetric(expr.Node, method, src); ok { stringEvents = append(stringEvents, goStringEvent{ context: stringCtxMetric, method: method, value: name, line: expr.StartLine + 1, }) } if msg, ok := detectGoErrorMessage(expr.Node, receiverText, method, src); ok { stringEvents = append(stringEvents, goStringEvent{ context: stringCtxErrorMsg, method: receiverText + "." + method, value: msg, line: expr.StartLine + 1, }) } if route, ok := detectGoRoute(expr.Node, method, src); ok { stringEvents = append(stringEvents, goStringEvent{ context: stringCtxRoute, method: method, value: route, line: expr.StartLine + 1, }) } if provider, flagName, ok := detectGoFlagCheck(expr.Node, method, src); ok { flagEvents = append(flagEvents, goFlagEvent{ provider: provider, method: method, name: flagName, line: expr.StartLine + 1, }) } if source, op, key, ok := detectGoConfigKey(expr.Node, m.Captures["callm.receiver"].Text, method, src); ok { // Gate provider-specific classifications on the import // set so a `req.GetString("query")` against an mcp // request type doesn't get mistaken for a viper read in // a file that never imports viper. if goConfigSourceImported(source, imports) { configEvents = append(configEvents, goConfigEvent{ source: source, op: op, method: method, key: key, line: expr.StartLine + 1, }) } } if tables, cols, query, ok := detectGoSQLCall(expr.Node, method, src); ok { sqlEvents = append(sqlEvents, goSQLEvent{ method: method, tables: tables, columns: cols, query: query, line: expr.StartLine + 1, }) } case m.Captures["var.def"] != nil: e.emitVar(m, filePath, fileID, result, tenv) case m.Captures["const.def"] != nil: e.emitConst(m, filePath, fileID, src, result) case m.Captures["svar.def"] != nil: e.recordShortVarType(m, src, tenv) recordGoGRPCStubVar(m, src, grpcStubVars) case m.Captures["comp.expr"] != nil: expr := m.Captures["comp.expr"] instantiates = append(instantiates, goDeferredTypeRef{ typeName: m.Captures["comp.type"].Text, line: expr.StartLine + 1, kind: graph.EdgeInstantiates, }) case m.Captures["compq.expr"] != nil: expr := m.Captures["compq.expr"] instantiates = append(instantiates, goDeferredTypeRef{ typeName: m.Captures["compq.type"].Text, pkg: m.Captures["compq.pkg"].Text, line: expr.StartLine + 1, kind: graph.EdgeInstantiates, }) case m.Captures["ftype.decl"] != nil: decl := m.Captures["ftype.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["ftype.name"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["ctype.decl"] != nil: decl := m.Captures["ctype.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["ctype.name"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["vtype.decl"] != nil: decl := m.Captures["vtype.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["vtype.name"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["ptype.decl"] != nil: decl := m.Captures["ptype.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["ptype.name"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["assert.decl"] != nil: decl := m.Captures["assert.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["assert.name"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["assertq.decl"] != nil: decl := m.Captures["assertq.decl"] typeRefs = append(typeRefs, goDeferredTypeRef{ typeName: m.Captures["assertq.name"].Text, pkg: m.Captures["assertq.pkg"].Text, line: decl.StartLine + 1, kind: graph.EdgeReferences, }) case m.Captures["selarg.list"] != nil: list := m.Captures["selarg.list"] valueSels = append(valueSels, goDeferredValueSel{ field: m.Captures["selarg.field"].Text, receiver: m.Captures["selarg.receiver"].Text, line: list.StartLine + 1, kind: graph.EdgeReads, }) case m.Captures["identarg.list"] != nil: list := m.Captures["identarg.list"] valueIdents = append(valueIdents, goDeferredValueIdent{ name: m.Captures["identarg.name"].Text, line: list.StartLine + 1, }) case m.Captures["fieldval.elem"] != nil: elem := m.Captures["fieldval.elem"] fieldValIdents = append(fieldValIdents, goDeferredValueIdent{ name: m.Captures["fieldval.value"].Text, line: elem.StartLine + 1, }) case m.Captures["fieldstr.elem"] != nil: elem := m.Captures["fieldstr.elem"] if typeName := goCompositeLiteralType(elem.Node, src); typeName != "" { val := m.Captures["fieldstr.val"].Text if len(val) >= 2 && (val[0] == '"' || val[0] == '`') { val = val[1 : len(val)-1] } executorFields = append(executorFields, goExecutorField{ typeName: typeName, field: m.Captures["fieldstr.key"].Text, value: val, line: elem.StartLine + 1, }) } case m.Captures["assign.def"] != nil: def := m.Captures["assign.def"] writes = append(writes, goDeferredValueSel{ field: m.Captures["assign.field"].Text, receiver: m.Captures["assign.receiver"].Text, line: def.StartLine + 1, kind: graph.EdgeWrites, }) case m.Captures["incsel.def"] != nil: def := m.Captures["incsel.def"] writes = append(writes, goDeferredValueSel{ field: m.Captures["incsel.field"].Text, receiver: m.Captures["incsel.receiver"].Text, line: def.StartLine + 1, kind: graph.EdgeWrites, }) case m.Captures["decsel.def"] != nil: def := m.Captures["decsel.def"] writes = append(writes, goDeferredValueSel{ field: m.Captures["decsel.field"].Text, receiver: m.Captures["decsel.receiver"].Text, line: def.StartLine + 1, kind: graph.EdgeWrites, }) case m.Captures["fieldsel.elem"] != nil: elem := m.Captures["fieldsel.elem"] fieldValSels = append(fieldValSels, goDeferredValueSel{ field: m.Captures["fieldsel.method"].Text, receiver: m.Captures["fieldsel.receiver"].Text, line: elem.StartLine + 1, kind: graph.EdgeReads, }) case m.Captures["retsel.list"] != nil: list := m.Captures["retsel.list"] valueSels = append(valueSels, goDeferredValueSel{ field: m.Captures["retsel.field"].Text, receiver: m.Captures["retsel.receiver"].Text, line: list.StartLine + 1, kind: graph.EdgeReads, }) case m.Captures["retident.list"] != nil: list := m.Captures["retident.list"] valueIdents = append(valueIdents, goDeferredValueIdent{ name: m.Captures["retident.name"].Text, line: list.StartLine + 1, }) } }) // All function/method nodes have been emitted; now map call sites to // their enclosing definition. funcRanges := buildFuncRanges(result) // lookupRecvType resolves a receiver-expression name first against // the enclosing function's parameter scope, then the file-wide tenv. // Per-function scope is needed so calls like `func F(reg *Registry) // { reg.Register(...) }` get the right `receiver_type` even when // another function in the same file has a parameter or local named // `reg` with a different type. lookupRecvType := func(callerID, name string) (string, bool) { if callerID != "" { if scope, ok := paramsByFunc[callerID]; ok { if t, ok := scope[name]; ok { return t, true } } } t, ok := tenv[name] return t, ok } // recvNameByID maps a method node ID to its receiver variable name, so a // selector call can be classified as invoked on the method's own receiver // (s.counter.Increment() / s.helper()) — the basis for indirect // receiver-field-mutation attribution. recvNameByID := map[string]string{} recvTypeByID := map[string]string{} for _, n := range result.Nodes { if n.Kind == graph.KindMethod && n.Meta != nil { if rn, _ := n.Meta["recv_name"].(string); rn != "" { recvNameByID[n.ID] = rn } if rt, _ := n.Meta["receiver"].(string); rt != "" { recvTypeByID[n.ID] = rt } } } // --- Calls --- for _, c := range calls { callerID := findEnclosingFunc(funcRanges, c.line) if callerID == "" { continue } // gRPC client-stub call: `pkg.NewClient(conn).Method(...)` // (inline chain) or `stub.Method(...)` where `stub` came from a // `NewClient` constructor. Emitted to a dedicated // placeholder that the resolver's ResolveGRPCStubCalls pass lands // on the server-side handler method. Intercepted ahead of normal // selector handling so the call doesn't resolve onto the generated // client-interface method instead of the actual handler. if c.isSelector { if svc, ok := goGRPCStubService(c, grpcStubVars); ok { target := "unresolved::grpc::" + svc + "::" + c.method edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, Meta: map[string]any{ "via": "grpc.stub", "grpc_service": svc, "grpc_method": c.method, }, } stampReturnUsage(edge, c.returnUsage) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue } // Temporal workflow → activity / child-workflow dispatch: // `workflow.ExecuteActivity(ctx, X, ...)` etc. Emitted to a // dedicated placeholder that the resolver's // ResolveTemporalCalls pass lands on the activity / workflow // function the worker has registered under that name. // Intercepted ahead of normal selector handling so the call // doesn't resolve onto the SDK's generic `ExecuteActivity` // helper instead of the actual activity body. if c.tempKind == "activity" || c.tempKind == "workflow" { // Wrapper detection: when the dispatch name is a PARAMETER of // the enclosing function, this function is a dispatch wrapper // (e.g. executeActivity(ctx, ao, name, …) { // workflow.ExecuteActivity(ctx, name, …) }). The parameter is // not a real activity name, so the stub could never resolve — // suppress the noise and mark the wrapper instead. Propagating // a caller's literal/const argument into the dispatch // (cross-file wrapper-FOLLOWING) needs call-argument flow and // is a deliberate, documented blind spot for now. if !c.tempEnvDefault { if names, ok := paramNamesByFunc[callerID]; ok { if names[c.tempName] { markGoTemporalWrapper(result, callerID, c.tempKind, c.tempName) // Emit a wrapper-stub edge that the resolver's // wrapper-following pass can use as an anchor: // from=callerID, target=placeholder, // temporal_name_param=paramName. No literal // temporal_name resolution here (it's a param); // the resolver synthesises a proper stub for each // caller that passes a literal at this position. target := "unresolved::temporal::" + c.tempKind + "::" + c.tempName meta := map[string]any{ "via": "temporal.stub", "temporal_kind": c.tempKind, "temporal_name": c.tempName, "temporal_name_param": c.tempName, } if c.tempLocal { meta["temporal_local"] = true } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, Meta: meta, }) continue } } } target := "unresolved::temporal::" + c.tempKind + "::" + c.tempName meta := map[string]any{ "via": "temporal.stub", "temporal_kind": c.tempKind, "temporal_name": c.tempName, } if c.tempLocal { meta["temporal_local"] = true } if c.tempEnvDefault { meta["temporal_name_origin"] = "env_default" if c.tempEnvSource != "" { meta["temporal_env_source"] = c.tempEnvSource } if c.tempDefaultConst != "" { meta["temporal_default_const"] = c.tempDefaultConst } } if c.tempVarTrace { // Variable-traced dispatch name (Cat 2): the name was the // last intra-procedural assignment to a bare local — a // best-effort static guess (a later conditional reassign // may not execute). The resolver lands it at the // speculative tier, and (when the traced value is a const // NAME) dereferences it to the literal via the const map. meta["temporal_name_origin"] = "var_trace" } if recvName := recvNameByID[callerID]; recvName != "" { if arg := goTemporalDispatchArg(c.callNode); arg != nil && arg.Type() == "selector_expression" { op := arg.ChildByFieldName("operand") fld := arg.ChildByFieldName("field") if op != nil && fld != nil && op.Content(src) == recvName { meta["temporal_name_field"] = fld.Content(src) if rt := recvTypeByID[callerID]; rt != "" { meta["temporal_recv_type"] = rt } } } } edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, Meta: meta, } stampReturnUsage(edge, c.returnUsage) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue } } var target string if !c.isSelector { target = "unresolved::" + c.callName edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) applyGoTemporalHandlerMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) applyGoTemporalStartMeta(edge, c) stampReturnUsage(edge, c.returnUsage) attachGoTemporalCallArgNames(edge, c, c.callNode, src, paramNamesByFunc[callerID]) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue } if importPath, ok := imports[c.receiver]; ok { target = "unresolved::extern::" + importPath + "::" + c.method edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) applyGoTemporalHandlerMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) applyGoTemporalStartMeta(edge, c) stampReturnUsage(edge, c.returnUsage) attachGoTemporalCallArgNames(edge, c, c.callNode, src, paramNamesByFunc[callerID]) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue } target = "unresolved::*." + c.method edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, } // Classify the call against the enclosing method's own receiver so the // capability synthesizer can attribute indirect field mutations: // s.counter.Increment() → recv_field=counter (mutates s.counter) // s.helper() → recv_self=true (mutates s's fields // transitively via helper) if recvName := recvNameByID[callerID]; recvName != "" { if c.receiver == recvName { edge.Meta = map[string]any{"recv_self": true} } else if f, ok := ownReceiverField(c.receiver, recvName); ok { edge.Meta = map[string]any{"recv_field": f} } } if recvType, ok := lookupRecvType(callerID, c.receiver); ok { if edge.Meta == nil { edge.Meta = map[string]any{} } edge.Meta["receiver_type"] = recvType } else if strings.Contains(c.receiver, ".") || strings.Contains(c.receiver, "(") { // Chain walk needs both the file-wide tenv and the // enclosing function's parameter scope as one map. Compose // without mutating either. composed := tenv if scope, ok := paramsByFunc[callerID]; ok && len(scope) > 0 { composed = make(typeEnv, len(tenv)+len(scope)) for k, v := range tenv { composed[k] = v } for k, v := range scope { composed[k] = v } } stampFactoryChainReceiver(edge, c.receiver, resolveChainType(c.receiver, composed, result)) } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) applyGoTemporalStartMeta(edge, c) stampReturnUsage(edge, c.returnUsage) attachGoTemporalCallArgNames(edge, c, c.callNode, src, paramNamesByFunc[callerID]) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) } // --- Observability events --- emitGoObservabilityEvents(observabilityEvents, func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- Event pub/sub edges --- // Transport resolution needs the file's full import set, which the // tree walk above finished collecting, so it runs here in the // post-pass alongside the other deferred coverage emitters. emitGoPubsubEvents(pubsubCandidates, importPathValues(imports), func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- String observations (metrics, error messages, routes) --- emitGoStringEvents(stringEvents, func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- Feature flag checks --- emitGoFlagChecks(flagEvents, func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- Config keys (viper) --- emitGoConfigKeys(configEvents, func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- SQL queries --- // Dialect inference uses the same imports map the call walker // already builds. When the file imports a recognised SQL // driver, KindTable nodes get db::::... IDs; otherwise // they fall back to db::generic::... emitGoSQLEvents(sqlEvents, inferGoSQLDialect(imports), func(line int) string { return findEnclosingFunc(funcRanges, line) }, filePath, result) // --- Composite literals (instantiations) --- for _, r := range instantiates { callerID := findEnclosingFunc(funcRanges, r.line) if callerID == "" { callerID = filePath } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: "unresolved::" + r.typeName, Kind: r.kind, FilePath: filePath, Line: r.line, }) } // --- Type assertions + declaration type references --- for _, r := range typeRefs { callerID := findEnclosingFunc(funcRanges, r.line) if callerID == "" { callerID = filePath } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: "unresolved::" + r.typeName, Kind: r.kind, FilePath: filePath, Line: r.line, }) } // --- Function/method values passed as arguments --- // Selector expression in arg position: h.handleHealth for _, v := range valueSels { callerID := findEnclosingFunc(funcRanges, v.line) if callerID == "" { callerID = filePath } edge := &graph.Edge{ From: callerID, To: "unresolved::*." + v.field, Kind: v.kind, FilePath: filePath, Line: v.line, } if recvType, ok := lookupRecvType(callerID, v.receiver); ok { edge.Meta = map[string]any{"receiver_type": recvType} } result.Edges = append(result.Edges, edge) } // Bare identifier in arg position: funcName as a value. for _, v := range valueIdents { if isGoBuiltinOrKeyword(v.name) { continue } callerID := findEnclosingFunc(funcRanges, v.line) if callerID == "" { callerID = filePath } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: "unresolved::" + v.name, Kind: graph.EdgeReads, FilePath: filePath, Line: v.line, }) } // Bare identifier as struct field value: &X{RunE: runClean} for _, v := range fieldValIdents { if isGoBuiltinOrKeyword(v.name) { continue } callerID := findEnclosingFunc(funcRanges, v.line) if callerID == "" { callerID = filePath } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: "unresolved::" + v.name, Kind: graph.EdgeReads, FilePath: filePath, Line: v.line, }) } // Selector as struct field value: {Handler: h.handleHealth} for _, v := range fieldValSels { callerID := findEnclosingFunc(funcRanges, v.line) if callerID == "" { callerID = filePath } edge := &graph.Edge{ From: callerID, To: "unresolved::*." + v.field, Kind: v.kind, FilePath: filePath, Line: v.line, } if recvType, ok := lookupRecvType(callerID, v.receiver); ok { edge.Meta = map[string]any{"receiver_type": recvType} } result.Edges = append(result.Edges, edge) } // Temporal step/executor field: `ActivityExecutor{ActivityName: "X"}`. // PURPOSE — emits a marker edge so the resolver can join the string // literal at the construction site to the dispatch that reads this field. // RATIONALE — separate pass because it fires on keyed_element nodes, not // call expressions; the construction site and dispatch site may be in // different functions or even files. // KEYWORDS — temporal, executor-field, marker for _, ef := range executorFields { callerID := findEnclosingFunc(funcRanges, ef.line) if callerID == "" { callerID = filePath } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: "unresolved::temporal-executor::" + ef.typeName + "::" + ef.field, Kind: graph.EdgeCalls, FilePath: filePath, Line: ef.line, Meta: map[string]any{ "via": "temporal.executor-field", "executor_type": ef.typeName, "executor_field": ef.field, "executor_value": ef.value, }, }) } // Assignment / inc / dec selector LHS — EdgeWrites from the // enclosing function to the assigned field. Same resolution path // as the value-side selectors: the resolver lands on the field // node when we know the receiver's type, otherwise the edge stays // unresolved::*.field for downstream cleanup. for _, v := range writes { callerID := findEnclosingFunc(funcRanges, v.line) if callerID == "" { callerID = filePath } edge := &graph.Edge{ From: callerID, To: "unresolved::*." + v.field, Kind: v.kind, FilePath: filePath, Line: v.line, } if recvType, ok := lookupRecvType(callerID, v.receiver); ok { edge.Meta = map[string]any{"receiver_type": recvType} } result.Edges = append(result.Edges, edge) } // ORM TableName() override resolution. detectGoORMModel uses gorm's // snake_case+plural naming convention, but gorm honours an explicit // `func (T) TableName() string { return "..." }` override. The // override method emits as a separate KindMethod node, so this // post-pass runs after every node in the file has been emitted and // rewires any convention-derived EdgeModelsTable to the literal. rewireORMTableNameOverrides(root, src, result) // WebSocket upgrade handlers → real-time endpoint edges. emitGoWebSocketEdges(src, filePath, result) // Same-file constant/variable value references → impact-radius reads. captureValueRefCandidates(result, root, filePath, src) captureFnValueCandidates(result, root, filePath, src) // Gin middleware-chain dispatcher + route registrations → resolver hints. mineGinMiddleware(src, result) // Composite/generic element type references — generic arguments, // map key/value, channel element. These positions are dropped by the // bare-name type passes, so without this find_usages of a type misses // every place it appears only as an argument/element type. emitGoTypeArgReferences(root, src, filePath, fileID, funcRanges, result) // GoFrame reflective routes: g.Meta-tagged request structs → controllers. captureGoFrameRoutes(result, root, filePath, src) return result, nil } // --- Per-match emit helpers ----------------------------------------- func (e *GoExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, paramNamesByFunc map[string]map[string]bool, imports map[string]string, seenIDs map[string]bool) { name := m.Captures["func.name"].Text def := m.Captures["func.def"] id, ok := disambiguateID(seenIDs, filePath+"::"+name, def.StartLine+1) if !ok { return } if pc, ok := m.Captures["func.params"]; ok && pc != nil { if names := extractGoParamNames(pc.Node, src); len(names) > 0 { paramNamesByFunc[id] = names } } node := &graph.Node{ ID: id, Kind: graph.KindFunction, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", Meta: make(map[string]any), } node.Meta["signature"] = buildFuncSignature(name, m.Captures["func.params"], m.Captures["func.result"]) if paramsCap, ok := m.Captures["func.params"]; ok && paramsCap != nil { if scope := extractGoParamTypes(paramsCap.Node, src); len(scope) > 0 { paramsByFunc[id] = scope } } if resultCap, ok := m.Captures["func.result"]; ok && resultCap.Text != "" { if rt := normalizeGoTypeName(resultCap.Text); rt != "" { node.Meta["return_type"] = rt } } if tp := goTypeParams(def.Node, src); len(tp) > 0 { node.Meta["type_params"] = tp } if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" { node.Meta["doc"] = doc } node.Meta["visibility"] = VisibilityByCase(name) if body := goFuncBody(def.Node); body != nil { StampFunctionMetrics(node, body, "go") StampLoopSignals(node, body, src, "go") } scanGoPragmas(src, def.StartLine, node) result.Nodes = append(result.Nodes, node) // Record func-returning-literal into ConstValues sidecar for const-deref dispatch. if v, ok := goFuncSingleReturnLiteral(def.Node, src); ok { result.ConstValues = append(result.ConstValues, parser.ConstValue{ NodeID: id, FilePath: filePath, Value: v, }) } result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) emitGoThrowsEdges(node, m.Captures["func.result"], filePath, result) emitGoFunctionShape(id, def.Node, m.Captures["func.params"], m.Captures["func.result"], src, filePath, def.StartLine+1, imports, result) } // goFuncBody returns the `block` body child of a function/method // declaration node, or nil for declarations without a body (interface // method shapes, abstract decls). Used by complexity counting. func goFuncBody(decl *sitter.Node) *sitter.Node { if decl == nil { return nil } if b := decl.ChildByFieldName("body"); b != nil { return b } for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c != nil && c.Type() == "block" { return c } } return nil } // ownReceiverField returns the single field name when a selector-call receiver // is exactly `.` — the one-hop case `s.counter.Increment()` // where counter is a field of the enclosing method's own receiver. Deeper // chains (s.a.b) and call expressions return ok=false so the indirect-mutation // synthesizer never over-attributes past the first own-receiver hop. func ownReceiverField(receiver, recvName string) (string, bool) { prefix := recvName + "." if !strings.HasPrefix(receiver, prefix) { return "", false } rest := receiver[len(prefix):] if rest == "" || strings.ContainsAny(rest, ".([ ") { return "", false } return rest, true } func (e *GoExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, paramNamesByFunc map[string]map[string]bool, imports map[string]string, seenIDs map[string]bool) { name := m.Captures["method.name"].Text def := m.Captures["method.def"] receiverText := m.Captures["method.receiver"].Text receiverType := extractReceiverType(receiverText) id, idOK := disambiguateID(seenIDs, filePath+"::"+receiverType+"."+name, def.StartLine+1) if !idOK { return } if paramsCap, ok := m.Captures["method.params"]; ok && paramsCap != nil { if names := extractGoParamNames(paramsCap.Node, src); len(names) > 0 { paramNamesByFunc[id] = names } } scope := typeEnv{} if recvName := extractReceiverName(receiverText); recvName != "" && receiverType != "" { scope[recvName] = receiverType } if paramsCap, ok := m.Captures["method.params"]; ok && paramsCap != nil { for k, v := range extractGoParamTypes(paramsCap.Node, src) { scope[k] = v } } if len(scope) > 0 { paramsByFunc[id] = scope } node := &graph.Node{ ID: id, Kind: graph.KindMethod, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", Meta: map[string]any{ "receiver": receiverType, }, } if recvName := extractReceiverName(receiverText); recvName != "" { node.Meta["recv_name"] = recvName } node.Meta["signature"] = buildMethodSignature(receiverText, name, m.Captures["method.params"], m.Captures["method.result"]) if resultCap, ok := m.Captures["method.result"]; ok && resultCap.Text != "" { if rt := normalizeGoTypeName(resultCap.Text); rt != "" { node.Meta["return_type"] = rt } } if tp := goTypeParams(def.Node, src); len(tp) > 0 { node.Meta["type_params"] = tp } if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" { node.Meta["doc"] = doc } node.Meta["visibility"] = VisibilityByCase(name) if body := goFuncBody(def.Node); body != nil { StampFunctionMetrics(node, body, "go") StampLoopSignals(node, body, src, "go") } scanGoPragmas(src, def.StartLine, node) result.Nodes = append(result.Nodes, node) // Record method-returning-literal into ConstValues sidecar for const-deref dispatch. if v, ok := goFuncSingleReturnLiteral(def.Node, src); ok { result.ConstValues = append(result.ConstValues, parser.ConstValue{ NodeID: id, FilePath: filePath, Value: v, }) } result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) typeID := filePath + "::" + receiverType result.Edges = append(result.Edges, &graph.Edge{ From: id, To: typeID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: def.StartLine + 1, }) emitGoThrowsEdges(node, m.Captures["method.result"], filePath, result) emitGoFunctionShape(id, def.Node, m.Captures["method.params"], m.Captures["method.result"], src, filePath, def.StartLine+1, imports, result) } // goTypeParams reads the `type_parameters` child of a Go declaration // node (function_declaration, method_declaration, type_spec) and // returns the declared type parameters as a list of {name, bound} // maps. Multi-name parameter declarations like `[T, U comparable]` // produce one entry per name, each with the same bound. Returns nil // when the declaration is not generic. func goTypeParams(decl *sitter.Node, src []byte) []map[string]string { if decl == nil { return nil } tps := decl.ChildByFieldName("type_parameters") if tps == nil { // type_spec uses a different field shape — fall back to a // child-type scan. for i, _nc := 0, int(decl.ChildCount()); i < _nc; i++ { c := decl.Child(i) if c != nil && c.Type() == "type_parameter_list" { tps = c break } } } if tps == nil { return nil } var out []map[string]string for i, _nc := 0, int(tps.NamedChildCount()); i < _nc; i++ { pd := tps.NamedChild(i) if pd == nil { continue } if pd.Type() != "parameter_declaration" && pd.Type() != "type_parameter_declaration" { continue } // One parameter_declaration may carry multiple identifier // names that share a single type/bound: // `[T, U comparable]` → two names, one bound. var names []string var bound string // Names appear via ChildByFieldName("name") — Go grammar uses // field 'name' for the leading identifier list. For multi-name // declarations the grammar emits multiple entries with the // same field name; we walk children to find them all. for j, _nc := 0, int(pd.ChildCount()); j < _nc; j++ { c := pd.Child(j) if c == nil { continue } t := c.Type() if t == "identifier" || t == "field_identifier" || t == "type_identifier" { names = append(names, c.Content(src)) } } if tn := pd.ChildByFieldName("type"); tn != nil { bound = strings.TrimSpace(tn.Content(src)) } for _, n := range names { entry := map[string]string{"name": n} if bound != "" { entry["bound"] = bound } out = append(out, entry) } } return out } // emitGoThrowsEdges inspects the result-type capture and emits an // EdgeThrows edge when the function returns an error. Two cases: // // - last return type is the bare `error` interface → edge to the // synthetic external::error sentinel so reverse-walks land on // a single node regardless of file/repo. // - last return type is a custom error type (`*MyErr`, `MyErr`) // → edge to that type, resolved by name later. // // Functions that return only non-error types produce no edge. func emitGoThrowsEdges(node *graph.Node, resultCap *parser.CapturedNode, filePath string, result *parser.ExtractionResult) { if resultCap == nil || resultCap.Text == "" { return } errType := parseLastReturnTypeForError(resultCap.Text) if errType == "" { return } target := "external::error" if errType != "error" { target = "unresolved::" + errType } result.Edges = append(result.Edges, &graph.Edge{ From: node.ID, To: target, Kind: graph.EdgeThrows, FilePath: filePath, Line: node.StartLine, Origin: graph.OriginASTInferred, }) } // parseLastReturnTypeForError pulls the last identifier from a Go // result type expression and returns it when it looks like an error // type. Recognises the bare `error` interface plus the conventional // `*MyError` / `MyError` shapes. Returns "" for non-error returns. func parseLastReturnTypeForError(result string) string { s := strings.TrimSpace(result) if s == "" { return "" } // Strip parens for tuple returns like `(int, error)`. if strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")") { s = s[1 : len(s)-1] } parts := strings.Split(s, ",") last := strings.TrimSpace(parts[len(parts)-1]) if last == "" { return "" } // Strip leading `*` for pointer returns. last = strings.TrimPrefix(last, "*") // Take the rightmost identifier of `pkg.Foo`. if i := strings.LastIndex(last, "."); i >= 0 { last = last[i+1:] } // Strip generic instantiation suffix `[T]`. if i := strings.Index(last, "["); i >= 0 { last = last[:i] } last = strings.TrimSpace(last) if last == "error" { return "error" } // Heuristic: identifier ending in "Error" or "Err" — common Go // error type convention. Avoids false positives on things like // `Result` or `Response` while catching MyError, ParseErr, etc. if strings.HasSuffix(last, "Error") || strings.HasSuffix(last, "Err") { return last } return "" } // emitTypeDecl handles the generic `type X ` form. The body node // discriminates struct vs interface vs named primitive — interfaces // carry their method-signature set in Meta for structural inference. func (e *GoExtractor) emitTypeDecl(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { name := m.Captures["typedef.name"].Text if seen[name] { return } seen[name] = true def := m.Captures["typedef.def"] body := m.Captures["typedef.body"] id := filePath + "::" + name node := &graph.Node{ ID: id, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", } isStruct := false isInterface := false if body != nil && body.Node != nil && body.Node.Type() == "interface_type" { node.Kind = graph.KindInterface node.Meta = map[string]any{"methods": extractInterfaceMethods(body.Node, src)} isInterface = true } else { node.Kind = graph.KindType node.Meta = map[string]any{} if body != nil && body.Node != nil && body.Node.Type() == "struct_type" { isStruct = true } } if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" { node.Meta["doc"] = doc } node.Meta["visibility"] = VisibilityByCase(name) // Structural flavor: struct/interface bodies are discriminated above; // everything else funnelling through `type X ` is a newtype. flavor := "newtype" if isInterface { flavor = "interface" } else if isStruct { flavor = "struct" } node.Meta["type_flavor"] = flavor result.Nodes = append(result.Nodes, node) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) if isStruct { emitGoStructFields(body.Node, src, id, name, filePath, fileID, result) // ORM model attribution: emit EdgeModelsTable when the struct // carries gorm tags or embeds gorm.Model. Runs after field // emission so the field metadata is in place for downstream // analyzers that walk both edges together. detectGoORMModel(body.Node, src, id, name, filePath, result) } if isInterface { for _, embed := range extractEmbeddedInterfaceTypes(body.Node, src) { result.Edges = append(result.Edges, &graph.Edge{ From: id, To: "unresolved::" + embed, Kind: graph.EdgeComposes, FilePath: filePath, Line: def.StartLine + 1, Origin: graph.OriginASTInferred, }) } } // Newtype `type X Y` where Y is a named type produces an // EdgeExtends so blast-radius walks catch the dependency. Struct // and interface bodies don't get an extends edge — their member // fields/methods carry the reference set instead. if !isStruct && body != nil && body.Node != nil { if isExtendsNewtypeBody(body.Node.Type()) { if target := canonicalizeGoTypeRef(body.Text); target != "" { result.Edges = append(result.Edges, &graph.Edge{ From: id, To: "unresolved::" + target, Kind: graph.EdgeExtends, FilePath: filePath, Line: def.StartLine + 1, Origin: graph.OriginASTInferred, }) } } } } // isExtendsNewtypeBody reports whether a type_spec body's AST node // is a named-type reference that should produce an EdgeExtends edge. // Composite anonymous bodies (struct_type / interface_type / map_type // / func_type) are excluded — their member set carries the // references; emitting an extends edge to "anonymous" provides no // additional signal. func isExtendsNewtypeBody(t string) bool { switch t { case "type_identifier", "qualified_type", "pointer_type", "slice_type", "array_type", "channel_type", "generic_type": return true } return false } // emitGoStructFields walks a struct_type node's field_declaration list // and emits one KindField node per declared field. Each field gets a // MemberOf edge to its owner type and a Defines edge from the file. // Embedded fields (anonymous struct/interface inclusion) emit a single // field node named after the embedded type. func emitGoStructFields(structNode *sitter.Node, src []byte, ownerID, ownerName, filePath, fileID string, result *parser.ExtractionResult) { if structNode == nil { return } var fieldList *sitter.Node for i, _nc := 0, int(structNode.ChildCount()); i < _nc; i++ { c := structNode.Child(i) if c != nil && c.Type() == "field_declaration_list" { fieldList = c break } } if fieldList == nil { return } for i, _nc := 0, int(fieldList.NamedChildCount()); i < _nc; i++ { decl := fieldList.NamedChild(i) if decl == nil || decl.Type() != "field_declaration" { continue } // Walk the field_declaration's children once: collect // field_identifier names and the trailing type node. The // grammar exposes both via ChildByFieldName, but real-world // trees contain a mix of named/positional children for // embedded vs explicit fields, so a manual walk is the // reliable form. var nameNodes []*sitter.Node var typeNode *sitter.Node for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ { c := decl.NamedChild(j) if c == nil { continue } switch c.Type() { case "field_identifier": nameNodes = append(nameNodes, c) case "type_identifier", "qualified_type", "pointer_type", "generic_type", "slice_type", "array_type", "map_type", "channel_type", "function_type", "interface_type", "struct_type": if typeNode == nil { typeNode = c } } } var fieldType string if typeNode != nil { // Keep the verbatim type text — primitives ("string", // "int", "[]byte", etc.) are valid field types and // agents want to see them. normalizeGoTypeName drops // primitives because it's tuned for receiver-type // resolution; field metadata has different needs. fieldType = strings.TrimSpace(typeNode.Content(src)) } if len(nameNodes) > 0 { for _, nm := range nameNodes { emitGoFieldNode(decl, nm, nm.Content(src), fieldType, ownerID, ownerName, filePath, fileID, src, result) } continue } // Embedded field: type itself is the field name. if typeNode != nil { if fieldName := embeddedFieldName(typeNode, src); fieldName != "" { emitGoFieldNode(decl, typeNode, fieldName, fieldType, ownerID, ownerName, filePath, fileID, src, result) // Composition signal: the owning type embeds another // type. Distinct from EdgeExtends (newtype) and from // the field's MemberOf — gives blast-radius walks // "what types are composed into X" in one hop. if target := canonicalizeGoTypeRef(fieldType); target != "" { result.Edges = append(result.Edges, &graph.Edge{ From: ownerID, To: "unresolved::" + target, Kind: graph.EdgeComposes, FilePath: filePath, Line: int(typeNode.StartPoint().Row) + 1, Origin: graph.OriginASTInferred, }) } } } } } func emitGoFieldNode(decl, anchor *sitter.Node, fieldName, fieldType, ownerID, ownerName, filePath, fileID string, src []byte, result *parser.ExtractionResult) { id := filePath + "::" + ownerName + "." + fieldName startLine := int(anchor.StartPoint().Row) + 1 endLine := int(decl.EndPoint().Row) + 1 meta := map[string]any{ "receiver": ownerName, "visibility": VisibilityByCase(fieldName), } if fieldType != "" { meta["field_type"] = fieldType } if doc := ExtractDocAbove(src, int(anchor.StartPoint().Row), DocLangSlashSlash); doc != "" { meta["doc"] = doc } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindField, Name: fieldName, FilePath: filePath, StartLine: startLine, EndLine: endLine, Language: "go", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: startLine, }) result.Edges = append(result.Edges, &graph.Edge{ From: id, To: ownerID, Kind: graph.EdgeMemberOf, FilePath: filePath, Line: startLine, }) } // embeddedFieldName returns the trailing identifier of a Go embedded // field type. Strips one leading `*` for pointer-embedded fields and // drops the package qualifier. Returns "" when no identifier is found. func embeddedFieldName(typeNode *sitter.Node, src []byte) string { if typeNode == nil { return "" } switch typeNode.Type() { case "type_identifier": return typeNode.Content(src) case "pointer_type": // Recurse into the pointed-to type. for i, _nc := 0, int(typeNode.NamedChildCount()); i < _nc; i++ { if n := embeddedFieldName(typeNode.NamedChild(i), src); n != "" { return n } } case "qualified_type": // pkg.Foo — take the trailing identifier. for i := int(typeNode.NamedChildCount()) - 1; i >= 0; i-- { c := typeNode.NamedChild(i) if c != nil && c.Type() == "type_identifier" { return c.Content(src) } } case "generic_type": // Foo[T] — name is the inner type_identifier. for i, _nc := 0, int(typeNode.NamedChildCount()); i < _nc; i++ { if n := embeddedFieldName(typeNode.NamedChild(i), src); n != "" { return n } } } return "" } func (e *GoExtractor) emitTypeAlias(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, seen map[string]bool) { name := m.Captures["alias.name"].Text if seen[name] { return } seen[name] = true def := m.Captures["alias.def"] id := filePath + "::" + name node := &graph.Node{ ID: id, Kind: graph.KindType, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", Meta: map[string]any{ // Mark this node as an alias so consumers can scope queries // without inspecting the edge set. Newtypes get no flag — // the absence is the signal. "alias": true, }, } if doc := ExtractDocAbove(src, def.StartLine, DocLangSlashSlash); doc != "" { node.Meta["doc"] = doc } node.Meta["visibility"] = VisibilityByCase(name) node.Meta["type_flavor"] = "type_alias" result.Nodes = append(result.Nodes, node) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) // EdgeAliases captures the `type X = Y` relationship distinctly // from EdgeExtends (newtype). Renaming Y is a no-op for X's // callers in the alias case but breaks promoted method sets in // the newtype case — agents need to distinguish. if aliasType, ok := m.Captures["alias.type"]; ok && aliasType != nil { if target := canonicalizeGoTypeRef(aliasType.Text); target != "" { result.Edges = append(result.Edges, &graph.Edge{ From: id, To: "unresolved::" + target, Kind: graph.EdgeAliases, FilePath: filePath, Line: def.StartLine + 1, Origin: graph.OriginASTInferred, }) } } } // emitImport records an import edge and populates the alias→path map // used when classifying selector calls against imported packages. Blank // and dot imports are skipped in the map (they don't introduce a // callable identifier) but still produce EdgeImports. // // In addition to the existing file→import edge, emits a per-import // node (KindImport) with Meta carrying the import path, alias (if // renamed), and is_external flag. Lets agents query "what does this // file import from " with one graph hop. func (e *GoExtractor) emitImport(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, imports map[string]string, fileNode *graph.Node) { pathCap := m.Captures["import.path"] importPath := strings.Trim(pathCap.Text, `"`) line := pathCap.StartLine + 1 // Mark the file as a cgo user when it imports "C" — the // pseudo-import that triggers Go's cgo preprocessor and // signals the file has a C source dependency. Agents asking // "which files would break if we removed cgo" or "what's the // porting surface to a non-cgo build" can then filter by // this meta flag without parsing source. if importPath == "C" && fileNode != nil { if fileNode.Meta == nil { fileNode.Meta = map[string]any{} } fileNode.Meta["uses_cgo"] = true } rawAlias := "" if a, ok := m.Captures["import.alias"]; ok { rawAlias = strings.TrimSpace(a.Text) } displayName := rawAlias mapAlias := rawAlias switch rawAlias { case "_", ".": // Blank and dot imports keep their special behaviour for // the alias map (no callable identifier introduced) but the // import node still gets emitted so reverse-walks work. displayName = importPath if i := strings.LastIndex(importPath, "/"); i >= 0 { displayName = importPath[i+1:] } case "": displayName = importPath if i := strings.LastIndex(importPath, "/"); i >= 0 { displayName = importPath[i+1:] } mapAlias = displayName } importNodeID := filePath + "::import::" + importPath importMeta := map[string]any{ "path": importPath, "is_external": isExternalGoImport(importPath), } if rawAlias != "" { importMeta["alias"] = rawAlias } result.Nodes = append(result.Nodes, &graph.Node{ ID: importNodeID, Kind: graph.KindImport, Name: displayName, FilePath: filePath, StartLine: line, EndLine: line, Language: "go", Meta: importMeta, }) // File → import-node edge. EdgeContains is the semantic fit (the // file *contains* an import statement; it doesn't *define* the // imported package). The disk-backed GetFileSubGraph walks // EdgeDefines ∪ EdgeContains from the file node to enumerate the // full neighbourhood in one edge-index pass. result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: importNodeID, Kind: graph.EdgeContains, FilePath: filePath, Line: line, }) // Existing file → unresolved import-path edge for resolver // behaviour (downstream code resolves the path to the imported // repo's file node). Kept additive so consumers that read // EdgeImports keep working unchanged. result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: "unresolved::import::" + importPath, Kind: graph.EdgeImports, FilePath: filePath, Line: line, }) if rawAlias == "_" || rawAlias == "." { return } imports[mapAlias] = importPath } // isExternalGoImport returns true when the import path doesn't look // like a stdlib import. Heuristic: the first path segment contains a // dot — i.e. it's a module path like `github.com/...` or // `golang.org/...`. Stdlib paths (`fmt`, `os/exec`) have no dot in // the first segment. func isExternalGoImport(path string) bool { if path == "" { return false } first := path if i := strings.Index(path, "/"); i >= 0 { first = path[:i] } return strings.Contains(first, ".") } func (e *GoExtractor) emitVar(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult, tenv typeEnv) { nameCap := m.Captures["var.name"] def := m.Captures["var.def"] if nameCap == nil || nameCap.Text == "" || nameCap.Text == "_" { return } name := nameCap.Text id := filePath + "::" + name result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: graph.KindVariable, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", Meta: map[string]any{"visibility": VisibilityByCase(name)}, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) if typeCap, ok := m.Captures["var.type"]; ok && typeCap.Text != "" { if typeName := normalizeGoTypeName(typeCap.Text); typeName != "" { tenv[name] = typeName } } } func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) { nameCap := m.Captures["const.name"] def := m.Captures["const.def"] if nameCap == nil || nameCap.Text == "" || nameCap.Text == "_" { return } name := nameCap.Text id := filePath + "::" + name // Detect Go's enum-by-convention: a const_declaration block that // contains the `iota` identifier anywhere within its body marks // every spec in that block as an enum member. The heuristic // matches both the explicit form (`Red Color = iota`) and the // implicit-continuation form (`Red Color = iota; Green; Blue`), // which share the same enclosing block. False positives are // possible if the user happens to write `iota` inside a const // block for an unrelated reason — vanishingly rare in practice. kind := graph.KindConstant if def != nil && def.Node != nil && containsGoIotaBlock(def.Text) { kind = graph.KindEnumMember } meta := map[string]any{"visibility": VisibilityByCase(name)} // Const-to-const alias (Cat 3): `const ALIAS = RealName` carries no // literal of its own, so record the referenced const NAME. The // resolver's const-deref map collapses alias chains (ALIAS = REAL = // "lit") against the constant_values sidecar by a bounded fixpoint, so // an ALL_CAPS dispatch name that is itself a const alias still // dereferences to the underlying literal. A const WITH a literal value // is handled by the constant_values sidecar below, not here. if def != nil && def.Node != nil { if _, ok := goConstLiteralValue(def.Node, name, src); !ok { if ref, ok := goConstRefName(def.Node, name, src); ok { meta["const_ref"] = ref } } } result.Nodes = append(result.Nodes, &graph.Node{ ID: id, Kind: kind, Name: name, FilePath: filePath, StartLine: def.StartLine + 1, EndLine: def.EndLine + 1, Language: "go", Meta: meta, }) result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) // Retain the literal value (string / numeric) for the queryable // constant_values sidecar, so the resolver can dereference a // const-identifier dispatch name to its value across files. Computed // constants (iota, expressions) carry no literal and are skipped. if kind == graph.KindConstant { if v, ok := goConstLiteralValue(def.Node, name, src); ok { result.ConstValues = append(result.ConstValues, parser.ConstValue{ NodeID: id, FilePath: filePath, Value: v, }) } } } // PURPOSE — reports the string literal a function body returns when the body // is exactly one `return ""` statement. Used by the const-value // sidecar to record func-returning-constant nodes for Temporal dispatch // resolution without adding a new resolver pass. // RATIONALE — mirrors goConstLiteralValue for the function case. // KEYWORDS — temporal, const-deref, single-return-literal func goFuncSingleReturnLiteral(declNode *sitter.Node, src []byte) (string, bool) { if declNode == nil { return "", false } body := declNode.ChildByFieldName("body") if body == nil || body.Type() != "block" { return "", false } // The Go grammar wraps a block's statements in a `statement_list` node; // descend into it when present so the single-statement scan sees the // real statements rather than the wrapper. stmtParent := body if body.NamedChildCount() == 1 { if only := body.NamedChild(0); only != nil && only.Type() == "statement_list" { stmtParent = only } } var ret *sitter.Node stmts := 0 for i, _nc := 0, int(stmtParent.NamedChildCount()); i < _nc; i++ { c := stmtParent.NamedChild(i) if c == nil || c.Type() == "comment" { continue } stmts++ if c.Type() == "return_statement" { ret = c } } if stmts != 1 || ret == nil { return "", false } // return_statement -> expression_list -> single string literal var expr *sitter.Node for i, _nc := 0, int(ret.NamedChildCount()); i < _nc; i++ { n := ret.NamedChild(i) if n == nil { continue } if n.Type() == "expression_list" { if n.NamedChildCount() != 1 { return "", false } expr = n.NamedChild(0) } else { expr = n } } if expr == nil { return "", false } switch expr.Type() { case "interpreted_string_literal", "raw_string_literal": t := expr.Content(src) if len(t) >= 2 && (t[0] == '"' || t[0] == '`') { return t[1 : len(t)-1], true } return t, true } return "", false } // goConstLiteralValue extracts the literal value of a const_spec // (`const X = "literal"` / `const X = 42`) from the spec's value field, // when that value is a string or numeric literal. Returns ("", false) // for computed / multi-value / non-literal specs. // // constSpec may be the const_spec itself or the enclosing // const_declaration. For a grouped block (`const ( A = ...; B = ... )`) // the declaration holds several specs; name selects the matching one so // each member's value is captured independently (not just single-spec // blocks). For a single-spec block name still selects correctly. func goConstLiteralValue(constSpec *sitter.Node, name string, src []byte) (string, bool) { if constSpec == nil { return "", false } spec := constSpec if spec.Type() != "const_spec" { // def captures the const_declaration; pick the spec whose name // field matches the const being emitted (grouped blocks hold // several), falling back to the lone spec when there is exactly // one and no name match (defensive). var found, only *sitter.Node count := 0 for i, _nc := 0, int(spec.NamedChildCount()); i < _nc; i++ { c := spec.NamedChild(i) if c == nil || c.Type() != "const_spec" { continue } count++ only = c if nameNode := c.ChildByFieldName("name"); nameNode != nil && nameNode.Content(src) == name { found = c break } } switch { case found != nil: spec = found case count == 1 && only != nil: spec = only default: return "", false } } valueList := spec.ChildByFieldName("value") if valueList == nil || valueList.NamedChildCount() != 1 { return "", false } v := valueList.NamedChild(0) if v == nil { return "", false } switch v.Type() { case "interpreted_string_literal", "raw_string_literal": return goTemporalNameFromExpr(v, src), true case "int_literal", "float_literal": return v.Content(src), true } return "", false } // goConstRefName returns the NAME of the constant that `name`'s value is a // plain one-hop reference to (`const ALIAS = RealName`), or ("", false) when // the value is not a single bare-identifier reference. The resolver aliases // one constant's value to another via the constant_values sidecar (chains // collapsed by a bounded fixpoint), so an ALL_CAPS dispatch name that is // itself a const alias still resolves to the underlying literal. func goConstRefName(constDecl *sitter.Node, name string, src []byte) (string, bool) { if constDecl == nil { return "", false } for i, _nc := 0, int(constDecl.NamedChildCount()); i < _nc; i++ { spec := constDecl.NamedChild(i) if spec == nil || spec.Type() != "const_spec" { continue } nameNode := spec.ChildByFieldName("name") if nameNode == nil || nameNode.Content(src) != name { continue } val := spec.ChildByFieldName("value") if val == nil { return "", false } expr := val if val.Type() == "expression_list" { expr = val.NamedChild(0) } if expr == nil || expr.Type() != "identifier" { return "", false } ref := expr.Content(src) if ref == "" || ref == name { return "", false } return ref, true } return "", false } // containsGoIotaBlock reports whether a const_declaration's source // text contains the bare `iota` identifier. Word-boundary anchoring // keeps `bigiotabig` and similar identifier substrings from // triggering. Simpler than walking the AST for a one-off detection. func containsGoIotaBlock(text string) bool { if text == "" { return false } for i := 0; i+4 <= len(text); i++ { if text[i:i+4] != "iota" { continue } before := byte(' ') if i > 0 { before = text[i-1] } after := byte(' ') if i+4 < len(text) { after = text[i+4] } if !isGoIdentByte(before) && !isGoIdentByte(after) { return true } } return false } func isGoIdentByte(b byte) bool { return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_' } // recordShortVarType infers a type from the RHS expression of a short // variable declaration (`x := NewFoo()` or `x := &Foo{...}`) and records // it in tenv so subsequent selector-call edges attach a receiver_type // meta. No graph output — short-var-declared locals are not first-class // nodes in the current schema. func (e *GoExtractor) recordShortVarType(m parser.QueryResult, src []byte, tenv typeEnv) { name := m.Captures["svar.name"].Text valueCap := m.Captures["svar.value"] if valueCap == nil || valueCap.Node == nil { return } if inferred := inferTypeFromGoExpr(valueCap.Node, src); inferred != "" { tenv[name] = inferred } } // --- gRPC stub call extraction (M4) -------------------------------- // // The Go gRPC code generator produces a stable surface that lets us // resolve cross-service RPC calls structurally, without a type checker: // // - Client construction: `pkg.NewClient(conn)` returns a // `Client` stub interface. // - RPC invocation: `stub.(ctx, req, ...)`. // - Server registration: `pkg.RegisterServer(registrar, impl)` // binds a concrete impl type to the service. // // recordGoGRPCStubVar / goGRPCStubService recognise the consumer side // and route each RPC call to an `unresolved::grpc::::` // placeholder; parseGoGRPCRegister recognises the server side and // stamps `grpc_register_*` meta on the registration call edge. The // resolver's ResolveGRPCStubCalls pass joins the two. // recordGoGRPCStubVar inspects a short-var declaration's RHS for the // gRPC client-stub constructor convention `pkg.NewClient(conn)` // and, when matched, records `varName → serviceName` so later method // calls on that variable resolve as gRPC stub calls. func recordGoGRPCStubVar(m parser.QueryResult, src []byte, stubs map[string]string) { nameCap := m.Captures["svar.name"] valueCap := m.Captures["svar.value"] if nameCap == nil || valueCap == nil || valueCap.Node == nil { return } if svc, ok := goGRPCServiceFromConstructor(valueCap.Node, src); ok { stubs[nameCap.Text] = svc } } // goGRPCServiceFromConstructor returns the gRPC service name when node // is a `NewClient(...)` call expression (qualified or not). func goGRPCServiceFromConstructor(node *sitter.Node, src []byte) (string, bool) { if node == nil || node.Type() != "call_expression" { return "", false } fn := node.ChildByFieldName("function") if fn == nil { return "", false } var name string switch fn.Type() { case "identifier": name = fn.Content(src) case "selector_expression": if field := fn.ChildByFieldName("field"); field != nil { name = field.Content(src) } } return grpcServiceFromClientCtor(name) } // grpcServiceFromClientCtor maps `NewClient` → ``. func grpcServiceFromClientCtor(name string) (string, bool) { const pfx, sfx = "New", "Client" if len(name) <= len(pfx)+len(sfx) { return "", false } if !strings.HasPrefix(name, pfx) || !strings.HasSuffix(name, sfx) { return "", false } return name[len(pfx) : len(name)-len(sfx)], true } // goGRPCStubService reports whether a selector call's receiver is a // gRPC client stub — either a variable seeded by recordGoGRPCStubVar, // or an inline `pkg.NewClient(conn)` chain — and returns the // service name. func goGRPCStubService(c goDeferredCall, stubVars map[string]string) (string, bool) { if svc, ok := stubVars[c.receiver]; ok { return svc, true } return grpcServiceFromInlineChain(c.receiver) } // grpcServiceFromInlineChain matches the inline-chain receiver text // `pkg.NewClient(...)` (or unqualified) and returns the // service. The receiver must be exactly one balanced call expression — // a longer chain like `NewClient(c).Foo()` is rejected so the // trailing method isn't mistaken for an RPC on the stub. func grpcServiceFromInlineChain(receiver string) (string, bool) { open := strings.IndexByte(receiver, '(') if open < 0 || !strings.HasSuffix(receiver, ")") { return "", false } depth := 0 for i := open; i < len(receiver); i++ { switch receiver[i] { case '(': depth++ case ')': depth-- if depth == 0 && i != len(receiver)-1 { return "", false } } } head := receiver[:open] if dot := strings.LastIndexByte(head, '.'); dot >= 0 { head = head[dot+1:] } return grpcServiceFromClientCtor(head) } // grpcRegisterArgNode recognises the generated gRPC server-registration // call `RegisterServer(registrar, impl)` (qualified or not) // and returns the service name plus the AST node of the second // argument (the impl). The impl *type* is resolved later, in the call // post-pass, once the file-wide type environment is fully populated. // Returns ok=false for any other call. func grpcRegisterArgNode(callNode *sitter.Node, funcName string) (service string, argNode *sitter.Node, ok bool) { service, ok = grpcServiceFromRegister(funcName) if !ok { return "", nil, false } if callNode == nil || callNode.Type() != "call_expression" { return "", nil, false } args := callNode.ChildByFieldName("arguments") if args == nil { return "", nil, false } // Second positional argument names the server implementation. count := 0 for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ { c := args.NamedChild(i) if c == nil { continue } count++ if count == 2 { return service, c, true } } return "", nil, false } // grpcServiceFromRegister maps `RegisterServer` → ``. func grpcServiceFromRegister(name string) (string, bool) { const pfx, sfx = "Register", "Server" if len(name) <= len(pfx)+len(sfx) { return "", false } if !strings.HasPrefix(name, pfx) || !strings.HasSuffix(name, sfx) { return "", false } return name[len(pfx) : len(name)-len(sfx)], true } // goGRPCImplType extracts a type name from a gRPC server-registration // second argument: `&userServer{}`, `userServer{}`, `pkg.userServer{}`, // `NewUserServer()`, `pkg.NewUserServer()`, or a bare identifier whose // type is known from the file's type environment. Returns "" when the // expression doesn't unambiguously name a type. func goGRPCImplType(node *sitter.Node, src []byte, tenv typeEnv) string { if node == nil { return "" } switch node.Type() { case "unary_expression", "composite_literal", "call_expression": return normalizeGoTypeName(inferTypeFromGoExpr(node, src)) case "identifier": return normalizeGoTypeName(tenv[node.Content(src)]) } return "" } // applyGoGRPCRegisterMeta resolves the impl type named by a gRPC // server-registration call's second argument — against the now-complete // file-wide type environment — and stamps `grpc_register_*` meta onto // the registration call's edge so the resolver's ResolveGRPCStubCalls // pass can join client-stub calls to the server handler. No-op for // non-registration calls and for impl arguments whose type can't be // named (e.g. a bare field selector). func applyGoGRPCRegisterMeta(edge *graph.Edge, c goDeferredCall, src []byte, tenv typeEnv) { if edge == nil || c.grpcRegService == "" || c.grpcRegArgNode == nil { return } impl := goGRPCImplType(c.grpcRegArgNode, src, tenv) if impl == "" { return } if edge.Meta == nil { edge.Meta = map[string]any{} } edge.Meta["grpc_register_service"] = c.grpcRegService edge.Meta["grpc_register_impl"] = impl } // --- Helpers ------------------------------------------------------- type funcRange struct { id string startLine int endLine int } func buildFuncRanges(result *parser.ExtractionResult) []funcRange { var ranges []funcRange for _, n := range result.Nodes { if n.Kind == graph.KindFunction || n.Kind == graph.KindMethod { ranges = append(ranges, funcRange{ id: n.ID, startLine: n.StartLine, endLine: n.EndLine, }) } } return ranges } func findEnclosingFunc(ranges []funcRange, line int) string { for _, r := range ranges { if line >= r.startLine && line <= r.endLine { return r.id } } return "" } // extractReceiverName extracts the receiver name from a Go receiver // parameter list. "(s *Server)" -> "s", "(Server)" -> "" (unnamed). // Used to seed paramsByFunc so calls like `s.foo()` inside the method // body resolve via the receiver's type. func extractReceiverName(receiver string) string { receiver = strings.Trim(receiver, "()") parts := strings.Fields(receiver) if len(parts) < 2 { return "" } name := parts[0] if name == "" || name == "_" { return "" } return name } // extractGoParamTypes walks a parameter_list AST node and returns a // map from parameter name to its declared (normalized) type. Multi-name // declarations like `(a, b *Foo)` produce one entry per name. Unnamed // parameters, blank identifiers, and types that normalizeGoTypeName // drops (primitives, map/chan/func) are skipped — callers only care // about names that point at receiver types we could resolve methods on. // extractGoParamNames returns the set of ALL parameter names declared in a // parameter_list, regardless of type (unlike extractGoParamTypes, which // keeps only params with a non-builtin type for receiver resolution). Used // by the Temporal wrapper detector to recognise a forwarded parameter. func extractGoParamNames(paramList *sitter.Node, src []byte) map[string]bool { if paramList == nil { return nil } out := map[string]bool{} for i, _nc := 0, int(paramList.NamedChildCount()); i < _nc; i++ { decl := paramList.NamedChild(i) if decl == nil { continue } if t := decl.Type(); t != "parameter_declaration" && t != "variadic_parameter_declaration" { continue } typeNode := decl.ChildByFieldName("type") for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ { c := decl.NamedChild(j) if c == nil || c == typeNode { continue } if c.Type() == "identifier" { out[c.Content(src)] = true } } } return out } func extractGoParamTypes(paramList *sitter.Node, src []byte) typeEnv { if paramList == nil { return nil } out := typeEnv{} for i, _nc := 0, int(paramList.NamedChildCount()); i < _nc; i++ { decl := paramList.NamedChild(i) if decl == nil { continue } t := decl.Type() if t != "parameter_declaration" && t != "variadic_parameter_declaration" { continue } typeNode := decl.ChildByFieldName("type") if typeNode == nil { continue } typeName := normalizeGoTypeName(typeNode.Content(src)) if typeName == "" { continue } // Walk all identifier children — Go allows multiple names per // parameter declaration sharing one type. Skip the type node // itself (which may also be reported as a named child). for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ { c := decl.NamedChild(j) if c == nil || c == typeNode { continue } if c.Type() == "identifier" { name := c.Content(src) if name == "" || name == "_" { continue } out[name] = typeName } } } return out } // extractReceiverType extracts the type name from a Go receiver parameter list. // "(s *Server)" -> "Server", "(s Server)" -> "Server". func extractReceiverType(receiver string) string { receiver = strings.Trim(receiver, "()") parts := strings.Fields(receiver) if len(parts) == 0 { return "" } typePart := parts[len(parts)-1] typePart = strings.TrimPrefix(typePart, "*") return typePart } func buildFuncSignature(name string, params, result *parser.CapturedNode) string { sig := fmt.Sprintf("func %s%s", name, captureText(params)) if result != nil && result.Text != "" { sig += " " + result.Text } return sig } func buildMethodSignature(receiver, name string, params, result *parser.CapturedNode) string { sig := fmt.Sprintf("func (%s) %s%s", receiver, name, captureText(params)) if result != nil && result.Text != "" { sig += " " + result.Text } return sig } // extractInterfaceMethods walks the children of an interface_type node // and returns the names of all method_spec / method_elem entries. func extractInterfaceMethods(ifaceNode *sitter.Node, src []byte) []string { var methods []string for i, _nc := 0, int(ifaceNode.NamedChildCount()); i < _nc; i++ { child := ifaceNode.NamedChild(i) if child.Type() == "method_elem" || child.Type() == "method_spec" { for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ { nameNode := child.NamedChild(j) if nameNode.Type() == "field_identifier" { methods = append(methods, nameNode.Content(src)) break } } } } return methods } // extractEmbeddedInterfaceTypes walks the children of an // interface_type node and returns the canonicalised names of // embedded interface references. The Go grammar wraps embedded // interfaces and type-set elements inside `type_elem` nodes; older // grammar versions (and gofmt edge cases) sometimes inline the bare // type_identifier / qualified_type directly. Both shapes are // accepted so EdgeComposes is emitted regardless of which the // grammar produces. func extractEmbeddedInterfaceTypes(ifaceNode *sitter.Node, src []byte) []string { var out []string visit := func(n *sitter.Node) { if n == nil { return } switch n.Type() { case "type_identifier", "qualified_type", "generic_type": if t := canonicalizeGoTypeRef(n.Content(src)); t != "" { out = append(out, t) } } } for i, _nc := 0, int(ifaceNode.NamedChildCount()); i < _nc; i++ { child := ifaceNode.NamedChild(i) if child == nil { continue } switch child.Type() { case "type_elem": // Modern grammar: each embedded type is one type_elem // child. The named child of type_elem is the type // reference itself. for j, _nc := 0, int(child.NamedChildCount()); j < _nc; j++ { visit(child.NamedChild(j)) } case "type_identifier", "qualified_type", "generic_type": visit(child) } } return out } func captureText(c *parser.CapturedNode) string { if c == nil { return "()" } return c.Text } // --- Type environment ----------------------------------------------- // typeEnv maps variable name → inferred type name within a file. type typeEnv map[string]string // normalizeGoTypeName strips pointer prefix and package qualifier. // "*pkg.Foo" → "Foo", "[]*Foo" → "Foo", "map[K]V" → "" (skipped — // receiver typing doesn't help for map/slice/chan types). func normalizeGoTypeName(t string) string { t = strings.TrimSpace(t) // Strip array / slice prefixes. t = strings.TrimPrefix(t, "[]") if strings.HasPrefix(t, "[") { if end := strings.Index(t, "]"); end >= 0 { t = t[end+1:] } } // Skip map/chan/func types — can't meaningfully resolve a method call // through them at the grain we support. if strings.HasPrefix(t, "map[") || strings.HasPrefix(t, "chan ") || strings.HasPrefix(t, "func(") { return "" } // Strip pointer prefix. t = strings.TrimPrefix(t, "*") // Keep only last segment of a package-qualified name. if i := strings.LastIndex(t, "."); i >= 0 { t = t[i+1:] } // Skip generics. if i := strings.Index(t, "["); i >= 0 { t = t[:i] } // Skip Go primitives — a method call receiver is never a primitive in // code we can link to. switch t { case "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64", "complex64", "complex128", "bool", "byte", "rune", "error", "any": return "" } if t == "" { return "" } return t } // inferTypeFromGoExpr inspects the AST of a short-var RHS and returns // the inferred type name — supports composite literals (`Foo{...}`), // pointer composite literals (`&Foo{...}`), qualified composite // literals (`pkg.Foo{...}`), and the Go constructor convention // (`NewFoo(...)` → `Foo`). Returns "" when the expression doesn't // unambiguously name a type. func inferTypeFromGoExpr(node *sitter.Node, src []byte) string { switch node.Type() { case "composite_literal": return compositeLiteralType(node, src) case "unary_expression": // `&Foo{...}` — operator is "&" (first child), operand is // the composite literal. for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ { c := node.NamedChild(i) if c != nil && c.Type() == "composite_literal" { return compositeLiteralType(c, src) } } case "call_expression": // Constructor convention: NewFoo(...) → Foo. Only applies // when the called identifier starts with "New". fn := node.ChildByFieldName("function") if fn == nil { return "" } var callName string switch fn.Type() { case "identifier": callName = fn.Content(src) case "selector_expression": field := fn.ChildByFieldName("field") if field != nil { callName = field.Content(src) } } if strings.HasPrefix(callName, "New") && len(callName) > 3 { return callName[3:] } } return "" } // compositeLiteralType returns the type name of a composite literal, // handling both `Foo{...}` (type_identifier) and `pkg.Foo{...}` // (qualified_type) shapes. func compositeLiteralType(lit *sitter.Node, src []byte) string { t := lit.ChildByFieldName("type") if t == nil { return "" } switch t.Type() { case "type_identifier": return t.Content(src) case "qualified_type": nameNode := t.ChildByFieldName("name") if nameNode != nil { return nameNode.Content(src) } case "pointer_type": // *Foo{...} — rare but defensible. for i, _nc := 0, int(t.NamedChildCount()); i < _nc; i++ { c := t.NamedChild(i) if c != nil && c.Type() == "type_identifier" { return c.Content(src) } } } return "" } // Chained-receiver / factory-chain return-type resolution (resolveChainType, // stripCallArgs, findMethodReturnType, findFactoryReturnType) is shared across // every AST extractor and lives in helpers_chaintype.go. // scanGoPragmas inspects up to 5 source lines immediately before a // function or method declaration looking for `//go:*` or `//export` // comments and stamps them onto the node's Meta. Lets callers flag // special Go entry points (cgo exports, linkname) so dead-code // detection doesn't mark them as dead. // // Allocation contract: scans backwards using bytes.LastIndexByte // against the original src — no per-call lineStarts slice, no per- // line string copy until a pragma actually matches. The predecessor // implementation built an O(startLine) []int of line offsets per // call, which on a Go monorepo like k8s (12 k+ .go files, hundreds // of symbols each) was the single biggest allocation in the // indexer (4.57 GB / 14 % of total). func scanGoPragmas(src []byte, startLine int, node *graph.Node) { // startLine is 0-based (matches tree-sitter's row numbering at the // call site). One forward scan locates the '\n' that ends row // startLine − 1, and the backward walk from there is bounded at 5 // iterations regardless of file size. if startLine <= 0 || len(src) == 0 { return } end := -1 { row := 0 for i := 0; i < len(src); i++ { if src[i] != '\n' { continue } row++ end = i if row == startLine { break } } } if end < 0 { return } for scanned := 0; scanned < 5 && end >= 0; scanned++ { prev := bytes.LastIndexByte(src[:end], '\n') line := bytes.TrimSpace(src[prev+1 : end]) if len(line) != 0 && !bytes.HasPrefix(line, []byte("//")) { return } if bytes.HasPrefix(line, []byte("//export ")) { node.Meta["cgo_export"] = true return } if bytes.HasPrefix(line, []byte("//go:linkname ")) { node.Meta["go_linkname"] = true return } if prev < 0 { return } end = prev } } // isGoBuiltinOrKeyword returns true for identifiers that should not be // treated as function-value references (common Go builtins, type names, // literals). func isGoBuiltinOrKeyword(name string) bool { switch name { case "nil", "true", "false", "err", "ok", "ctx", "string", "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64", "complex64", "complex128", "bool", "byte", "rune", "error", "any", "make", "new", "len", "cap", "append", "copy", "delete", "panic", "recover", "print", "println", "close": return true } // Skip lowercase single-letter identifiers (loop vars, etc.) if len(name) == 1 && name[0] >= 'a' && name[0] <= 'z' { return true } return false }