Files
zzet--gortex/internal/parser/languages/go_function_shape.go
T
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

832 lines
26 KiB
Go

package languages
import (
"strconv"
"strings"
sitter "github.com/zzet/gortex/internal/parser/tsitter"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/parser"
)
// emitGoFunctionShape emits the per-function structural detail that
// the coverage layer surfaces as queryable graph: parameters, return
// types, type parameters, and inline closures. The function-shape
// domain has a strip pass downstream (Indexer.applyCoverageDomains)
// that drops these when CoverageConfig.FunctionShape is disabled, so
// the extractor always emits.
//
// ownerID is the function/method node ID (e.g. "pkg/foo.go::Run" or
// "pkg/foo.go::Server.Handle"). defNode is the *_declaration AST
// node. paramsCap / resultCap are the named-capture results for
// `func.params`/`method.params` and `func.result`/`method.result`.
// declLine is the 1-based line of the declaration, used as the
// anchor for nodes/edges that don't have a finer-grained AST
// position to reference.
func emitGoFunctionShape(ownerID string, defNode *sitter.Node, paramsCap, resultCap *parser.CapturedNode, src []byte, filePath string, declLine int, imports map[string]string, result *parser.ExtractionResult) {
if defNode == nil {
return
}
emitGoParamNodes(ownerID, paramsCap, src, filePath, declLine, result)
emitGoReturnEdges(ownerID, resultCap, src, filePath, declLine, result)
emitGoGenericParamNodes(ownerID, defNode, src, filePath, declLine, result)
if body := goFuncBody(defNode); body != nil {
emitGoClosureNodes(ownerID, declLine, body, src, filePath, result)
emitGoChannelOps(ownerID, body, src, filePath, result)
// CPG-lite intra-procedural dataflow: emits EdgeValueFlow,
// EdgeArgOf, and EdgeReturnsTo placeholders. Inter-procedural
// targets are lifted by the indexer's
// MaterializeDataflowParams pass once the call resolver
// has landed every callee. declLine anchors local-binding
// IDs as offsets so edits above the function don't churn
// every binding inside. imports are the file's package
// aliases so selector-expression cases inside the walker
// can rewrite `pkg.Method` calls to the proper
// `unresolved::extern::<importPath>::<Method>` shape
// instead of dropping the qualifier.
paramsByName := goParamNamesFromCapture(paramsCap, src)
emitGoDataflow(ownerID, declLine, body, paramsByName, imports, src, filePath, result)
}
}
// goParamNamesFromCapture returns a name → declared-type map for
// the parameters captured by the function/method shape query. The
// type isn't load-bearing for dataflow today (only the name set is)
// but is kept on the map so future improvements can use it for
// argument-binding precision without changing the call signature.
func goParamNamesFromCapture(paramsCap *parser.CapturedNode, src []byte) map[string]string {
out := map[string]string{}
if paramsCap == nil || paramsCap.Node == nil {
return out
}
list := paramsCap.Node
for i, _nc := 0, int(list.NamedChildCount()); i < _nc; i++ {
decl := list.NamedChild(i)
if decl == nil {
continue
}
t := decl.Type()
if t != "parameter_declaration" && t != "variadic_parameter_declaration" {
continue
}
typeNode := decl.ChildByFieldName("type")
typeText := ""
if typeNode != nil {
typeText = strings.TrimSpace(typeNode.Content(src))
}
for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ {
c := decl.NamedChild(j)
if c == nil || c == typeNode {
continue
}
if c.Type() != "identifier" {
continue
}
name := c.Content(src)
if name == "" || name == "_" {
continue
}
out[name] = typeText
}
}
return out
}
// emitGoChannelOps walks a function body and emits EdgeSends /
// EdgeRecvs edges from the enclosing function to the channel
// variable for each `ch <- v` send statement and `<-ch` receive
// expression. Channel names resolve through the existing
// unresolved-target convention so the resolver can later patch
// them to the variable's actual node when in-scope.
//
// v1 limitations:
//
// - Receives inside larger expressions (`x := <-ch` is fine,
// but `f(<-ch + 1)` only flags the immediate `<-ch` operand).
// - Range-over-channel (`for v := range ch`) doesn't currently
// emit a recv edge. The grammar wraps it in for_statement
// rather than unary_expression.
// - `select` statement cases are walked normally (their bodies
// contain send_statement / unary_expression children).
// - Closure bodies are skipped — closures are walked separately
// by emitGoClosureNodes; their channel ops attribute to the
// closure node when re-attribution lands as a follow-up.
// Today they attribute to the enclosing function, matching
// the same v1 limitation as call edges in closures.
func emitGoChannelOps(ownerID string, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
if body == nil {
return
}
walkGoNodes(body, func(n *sitter.Node) bool {
switch n.Type() {
case "func_literal":
// Don't recurse into nested closures — handled
// elsewhere. Same convention as emitGoClosureNodes.
return false
case "send_statement":
channel := n.ChildByFieldName("channel")
if channel != nil {
name := strings.TrimSpace(channel.Content(src))
if name != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + name,
Kind: graph.EdgeSends,
FilePath: filePath,
Line: int(n.StartPoint().Row) + 1,
Origin: graph.OriginASTInferred,
})
}
}
case "unary_expression":
// Receive operations have operator "<-" and an
// operand pointing at the channel.
op := n.ChildByFieldName("operator")
if op == nil || op.Content(src) != "<-" {
return true
}
operand := n.ChildByFieldName("operand")
if operand != nil {
name := strings.TrimSpace(operand.Content(src))
if name != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: "unresolved::" + name,
Kind: graph.EdgeRecvs,
FilePath: filePath,
Line: int(n.StartPoint().Row) + 1,
Origin: graph.OriginASTInferred,
})
}
}
// `for v := range ch` is the third receive shape in
// Go but distinguishing channel-range from map-range
// or slice-range needs type info we don't propagate
// here. Emitting a recv edge for every range target
// would over-fire on every map/slice iteration; the
// alternative — name-pattern heuristics — has worse
// precision than just leaving the gap. Tracked as a
// v1 limitation; a future pass that threads
// paramsByFunc into the channel walker can filter
// range RHSes by chan-typed variables only.
}
return true
})
}
// emitGoParamNodes walks a parameter_list and emits one KindParam
// per identifier. Multi-name parameter declarations like
// `(a, b int)` produce two param nodes that share a typed_as target.
// Variadic parameters carry meta.variadic=true on the param node.
// The blank identifier `_` is skipped. The line argument is the
// declaration's anchor line, kept for parity with the other
// helpers though the param's own start line wins where present.
func emitGoParamNodes(ownerID string, paramsCap *parser.CapturedNode, src []byte, filePath string, _ int, result *parser.ExtractionResult) {
if paramsCap == nil || paramsCap.Node == nil {
return
}
list := paramsCap.Node
pos := 0
for i, _nc := 0, int(list.NamedChildCount()); i < _nc; i++ {
decl := list.NamedChild(i)
if decl == nil {
continue
}
t := decl.Type()
isVariadic := t == "variadic_parameter_declaration"
if t != "parameter_declaration" && !isVariadic {
continue
}
typeNode := decl.ChildByFieldName("type")
typeName := ""
if typeNode != nil {
typeName = canonicalizeGoTypeRef(typeNode.Content(src))
}
// One declaration may carry multiple identifier names sharing
// a single type. Walk all identifier children, skipping the
// type node itself.
for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ {
c := decl.NamedChild(j)
if c == nil || c == typeNode {
continue
}
if c.Type() != "identifier" {
continue
}
name := c.Content(src)
if name == "" || name == "_" {
continue
}
paramID := goParamNodeID(ownerID, name, pos)
pos++
meta := map[string]any{
"position": pos - 1,
}
if isVariadic {
meta["variadic"] = true
}
if typeName != "" {
meta["type"] = typeName
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: paramID,
Kind: graph.KindParam,
Name: name,
FilePath: filePath,
StartLine: int(c.StartPoint().Row) + 1,
EndLine: int(c.EndPoint().Row) + 1,
Language: "go",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: paramID,
To: ownerID,
Kind: graph.EdgeParamOf,
FilePath: filePath,
Line: int(c.StartPoint().Row) + 1,
Origin: graph.OriginASTResolved,
})
if typeName != "" {
result.Edges = append(result.Edges, &graph.Edge{
From: paramID,
To: "unresolved::" + typeName,
Kind: graph.EdgeTypedAs,
FilePath: filePath,
Line: int(c.StartPoint().Row) + 1,
Origin: graph.OriginASTInferred,
})
}
}
}
}
// emitGoReturnEdges emits one EdgeReturns per declared return type.
// Multi-return signatures like `(int, error)` produce two edges,
// preserving order via meta.position. Resolution is left to the
// resolver (target is `unresolved::<typeName>`); the bare `error`
// interface gets the same external::error sentinel that EdgeThrows
// uses so reverse walks share a single landing point.
func emitGoReturnEdges(ownerID string, resultCap *parser.CapturedNode, src []byte, filePath string, line int, result *parser.ExtractionResult) {
if resultCap == nil || resultCap.Node == nil {
return
}
types := splitGoReturnTypes(resultCap.Node, src)
for i, t := range types {
t = canonicalizeGoTypeRef(t)
if t == "" {
continue
}
target := "unresolved::" + t
if t == "error" {
target = "external::error"
}
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: target,
Kind: graph.EdgeReturns,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTInferred,
Meta: map[string]any{
"position": i,
},
})
}
}
// splitGoReturnTypes returns the declared return types in source
// order. Two AST shapes occur: a `parameter_list` parent (when the
// signature wraps results in parens) holding zero or more
// parameter_declaration children, or a bare type node (single
// unparenthesised result). Anonymous results — common in Go — are
// emitted as their type with no associated parameter name.
func splitGoReturnTypes(node *sitter.Node, src []byte) []string {
if node == nil {
return nil
}
if node.Type() != "parameter_list" {
return []string{strings.TrimSpace(node.Content(src))}
}
var out []string
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
decl := node.NamedChild(i)
if decl == nil {
continue
}
switch decl.Type() {
case "parameter_declaration", "variadic_parameter_declaration":
if tn := decl.ChildByFieldName("type"); tn != nil {
// Multi-name declarations duplicate the type once per name.
names := 0
for j, _nc := 0, int(decl.NamedChildCount()); j < _nc; j++ {
c := decl.NamedChild(j)
if c == nil || c == tn {
continue
}
if c.Type() == "identifier" {
names++
}
}
if names == 0 {
names = 1
}
typeText := strings.TrimSpace(tn.Content(src))
for n := 0; n < names; n++ {
out = append(out, typeText)
}
}
default:
// Bare type node nested under parameter_list (rare but
// the grammar permits it for unnamed single results).
out = append(out, strings.TrimSpace(decl.Content(src)))
}
}
return out
}
// emitGoGenericParamNodes turns a function/method declaration's
// type_parameters into KindGenericParam nodes with EdgeMemberOf
// pointing at the owner. Bound types are stored as meta.bound so
// queries can filter by constraint.
func emitGoGenericParamNodes(ownerID string, defNode *sitter.Node, src []byte, filePath string, line int, result *parser.ExtractionResult) {
tparams := goTypeParams(defNode, src)
if len(tparams) == 0 {
return
}
for _, tp := range tparams {
name := tp["name"]
if name == "" {
continue
}
gpID := ownerID + "#tparam:" + name
meta := map[string]any{}
if b := tp["bound"]; b != "" {
meta["bound"] = b
}
result.Nodes = append(result.Nodes, &graph.Node{
ID: gpID,
Kind: graph.KindGenericParam,
Name: name,
FilePath: filePath,
StartLine: line,
EndLine: line,
Language: "go",
Meta: meta,
})
result.Edges = append(result.Edges, &graph.Edge{
From: gpID,
To: ownerID,
Kind: graph.EdgeMemberOf,
FilePath: filePath,
Line: line,
Origin: graph.OriginASTResolved,
})
}
}
// emitGoClosureNodes walks a function/method body looking for
// func_literal nodes (Go's anonymous-function syntax) and emits a
// KindClosure for each one. EdgeMemberOf links the closure back to
// the enclosing function so blast-radius walks reach it.
//
// v1 limitation: call edges inside a closure still attribute to the
// enclosing function. Re-attributing them would require teaching
// the call-emit walker to recognise closure boundaries — tracked as
// a Phase 1.5 follow-up.
func emitGoClosureNodes(ownerID string, ownerStartLine int, body *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
if body == nil {
return
}
idx := 0
walkGoNodes(body, func(n *sitter.Node) bool {
if n.Type() != "func_literal" {
return true
}
startLine := int(n.StartPoint().Row) + 1
// ID anchors on the owner-relative offset (+ prefix) so edits
// above the enclosing function don't churn the closure's ID.
// Name keeps the absolute line for human readability in search
// results / outlines.
offset := startLine
if ownerStartLine > 0 {
offset = startLine - ownerStartLine + 1
}
closureID := ownerID + "#closure@+" + strconv.Itoa(offset)
// If two anonymous functions start on the same line, append a
// stable suffix so IDs stay unique. Rare in practice but
// defensive.
if idx > 0 {
closureID += "#" + strconv.Itoa(idx)
}
idx++
result.Nodes = append(result.Nodes, &graph.Node{
ID: closureID,
Kind: graph.KindClosure,
Name: "closure@" + strconv.Itoa(startLine),
FilePath: filePath,
StartLine: startLine,
EndLine: int(n.EndPoint().Row) + 1,
Language: "go",
})
result.Edges = append(result.Edges, &graph.Edge{
From: closureID,
To: ownerID,
Kind: graph.EdgeMemberOf,
FilePath: filePath,
Line: startLine,
Origin: graph.OriginASTResolved,
})
// `go func() {...}()` — the closure is launched as a
// goroutine. Emit an EdgeSpawns from the enclosing function
// to the closure, mirroring how named-call spawns produce
// EdgeSpawns to the called function. Without this, agents
// asking "what goroutines does Run launch?" miss the entire
// inline-closure pattern.
if isGoroutineSpawnedClosure(n) {
result.Edges = append(result.Edges, &graph.Edge{
From: ownerID,
To: closureID,
Kind: graph.EdgeSpawns,
FilePath: filePath,
Line: startLine,
Origin: graph.OriginASTResolved,
Meta: map[string]any{
"mode": "goroutine",
},
})
}
// EdgeCaptures: every bare identifier the closure references
// without locally declaring/binding is a capture of an outer
// scope binding. The resolver later lands the unresolved::
// targets on the actual variable / function node.
emitGoClosureCaptures(closureID, n, src, filePath, result)
// Don't recurse into nested func_literals — they belong to
// the inner closure, not the outer one. The outer walker will
// pick them up when (if) closures-within-closures are
// supported. For Phase 1 the flat enumeration is sufficient.
return false
})
}
// emitGoClosureCaptures walks a func_literal and emits one
// EdgeCaptures per free variable. "Free" means: the identifier is
// used as a value somewhere in the closure body but isn't bound by
// a parameter, short-var-decl, var-spec, or const-spec inside the
// closure. Locally re-declared shadowing names suppress the capture
// (matches Go scoping rules).
//
// v1 limitations:
// - We don't recurse into nested closures; a nested closure's
// captures emit against the nested closure node when its own
// emitGoClosureNodes pass runs.
// - Selector RHS (`x.field`) only captures the operand `x`; the
// `.field` part is a field reference, not a free variable.
// - Identifiers in type position (e.g. `MyType` in `var x MyType`)
// count as captures — Go closures do close over file-scope type
// names, and the resolver can land them on the type node.
func emitGoClosureCaptures(closureID string, funcLit *sitter.Node, src []byte, filePath string, result *parser.ExtractionResult) {
if funcLit == nil {
return
}
body := funcLit.ChildByFieldName("body")
if body == nil {
return
}
locals := map[string]bool{}
collectGoClosureLocals(funcLit, src, locals)
seen := map[string]bool{}
walkGoNodes(body, func(n *sitter.Node) bool {
// Skip nested closures — they own their own captures.
if n.Type() == "func_literal" {
return false
}
if n.Type() != "identifier" {
return true
}
// Filter out identifiers that aren't actually a value-side
// reference: the LHS of a short-var-decl, the name on a var
// or const spec, the parameter name in a function decl, the
// field portion of a selector — none of these are captures.
if !isGoClosureValueRef(n) {
return true
}
name := n.Content(src)
if name == "" || name == "_" || locals[name] || isGoBuiltinOrKeyword(name) {
return true
}
if seen[name] {
return true
}
seen[name] = true
result.Edges = append(result.Edges, &graph.Edge{
From: closureID,
To: "unresolved::" + name,
Kind: graph.EdgeCaptures,
FilePath: filePath,
Line: int(n.StartPoint().Row) + 1,
Origin: graph.OriginASTInferred,
Meta: map[string]any{
"name": name,
},
})
return true
})
}
// collectGoClosureLocals records every name declared inside the
// closure (parameters, return-named results, var/const/short-var
// decls). Mutates locals in place.
func collectGoClosureLocals(funcLit *sitter.Node, src []byte, locals map[string]bool) {
if funcLit == nil {
return
}
addIdentNames := func(root *sitter.Node) {
if root == nil {
return
}
walkGoNodes(root, func(n *sitter.Node) bool {
if n.Type() == "func_literal" {
return false
}
if n.Type() == "identifier" {
locals[n.Content(src)] = true
}
return true
})
}
if params := funcLit.ChildByFieldName("parameters"); params != nil {
// Parameter names live under parameter_declaration → name.
walkGoNodes(params, func(n *sitter.Node) bool {
if n.Type() == "func_literal" {
return false
}
if n.Type() == "parameter_declaration" {
if name := n.ChildByFieldName("name"); name != nil {
addIdentNames(name)
}
}
return true
})
}
if res := funcLit.ChildByFieldName("result"); res != nil {
walkGoNodes(res, func(n *sitter.Node) bool {
if n.Type() == "func_literal" {
return false
}
if n.Type() == "parameter_declaration" {
if name := n.ChildByFieldName("name"); name != nil {
addIdentNames(name)
}
}
return true
})
}
body := funcLit.ChildByFieldName("body")
if body == nil {
return
}
walkGoNodes(body, func(n *sitter.Node) bool {
if n.Type() == "func_literal" {
return false
}
switch n.Type() {
case "short_var_declaration":
if left := n.ChildByFieldName("left"); left != nil {
addIdentNames(left)
}
case "var_spec", "const_spec":
if name := n.ChildByFieldName("name"); name != nil {
addIdentNames(name)
}
case "range_clause":
// `for k, v := range x { … }` — k, v are loop locals.
if left := n.ChildByFieldName("left"); left != nil {
addIdentNames(left)
}
case "for_statement":
// Init clause locals (not common in Go but possible).
if init := n.ChildByFieldName("initializer"); init != nil {
addIdentNames(init)
}
case "type_switch_statement":
// `switch v := x.(type)` — v is a local.
walkGoNodes(n, func(c *sitter.Node) bool {
if c.Type() == "expression_list" {
addIdentNames(c)
return false
}
return true
})
}
return true
})
}
// isGoClosureValueRef reports whether an identifier node is used as
// a value-side reference rather than a binding-site declaration.
// Returns false for the LHS of a short-var-decl, parameter names,
// variable/const declaration names, and the .field portion of a
// selector expression.
func isGoClosureValueRef(n *sitter.Node) bool {
parent := n.Parent()
if parent == nil {
return true
}
switch parent.Type() {
case "selector_expression":
// We capture the operand (LHS), not the field name (RHS).
if field := parent.ChildByFieldName("field"); field != nil && field.Equal(n) {
return false
}
case "parameter_declaration":
if name := parent.ChildByFieldName("name"); name != nil {
declared := false
walkGoNodes(name, func(c *sitter.Node) bool {
if c.Equal(n) {
declared = true
return false
}
return true
})
if declared {
return false
}
}
case "var_spec", "const_spec":
if name := parent.ChildByFieldName("name"); name != nil {
declared := false
walkGoNodes(name, func(c *sitter.Node) bool {
if c.Equal(n) {
declared = true
return false
}
return true
})
if declared {
return false
}
}
case "short_var_declaration":
if left := parent.ChildByFieldName("left"); left != nil {
declared := false
walkGoNodes(left, func(c *sitter.Node) bool {
if c.Equal(n) {
declared = true
return false
}
return true
})
if declared {
return false
}
}
case "range_clause":
if left := parent.ChildByFieldName("left"); left != nil {
declared := false
walkGoNodes(left, func(c *sitter.Node) bool {
if c.Equal(n) {
declared = true
return false
}
return true
})
if declared {
return false
}
}
case "function_declaration", "method_declaration":
// The function/method's own name isn't a free var inside
// its closure children — but closures can't be at this
// scope, so this is mostly defensive.
return false
case "field_identifier":
return false
}
return true
}
// isGoroutineSpawnedClosure reports whether a func_literal node is
// the operand of an immediately-invoked call inside a go_statement —
// i.e. the `func() {...}` in `go func() {...}()`. The Go grammar
// shape is go_statement → call_expression → func_literal, so two
// Parent() hops are sufficient.
func isGoroutineSpawnedClosure(funcLit *sitter.Node) bool {
if funcLit == nil {
return false
}
call := funcLit.Parent()
if call == nil || call.Type() != "call_expression" {
return false
}
stmt := call.Parent()
if stmt == nil {
return false
}
return stmt.Type() == "go_statement"
}
// walkGoNodes is a small DFS helper that calls visit on each node
// and recurses into named children when visit returns true.
func walkGoNodes(node *sitter.Node, visit func(*sitter.Node) bool) {
if node == nil {
return
}
if !visit(node) {
return
}
for i, _nc := 0, int(node.NamedChildCount()); i < _nc; i++ {
walkGoNodes(node.NamedChild(i), visit)
}
}
// isGoroutineSpawn reports whether a call_expression node is the
// direct child of a go_statement, meaning the call launches a
// goroutine rather than executing synchronously. The check is a
// single Parent() hop — Go's grammar wraps `go f()` as
// `go_statement -> call_expression`, so deeper walks are unnecessary.
func isGoroutineSpawn(callExpr *sitter.Node) bool {
if callExpr == nil {
return false
}
parent := callExpr.Parent()
if parent == nil {
return false
}
return parent.Type() == "go_statement"
}
// emitGoSpawnEdge appends an EdgeSpawns from caller → target when
// the underlying call was launched via `go`. Emitted in addition to
// EdgeCalls so synchronous-reachability queries can scope by edge
// kind (drop spawns) while concurrency analyses can see both. Meta
// records mode=goroutine so downstream consumers can distinguish
// from future async/Promise spawn modes.
func emitGoSpawnEdge(c goDeferredCall, callerID, target, filePath string, result *parser.ExtractionResult) {
if !c.spawn {
return
}
result.Edges = append(result.Edges, &graph.Edge{
From: callerID,
To: target,
Kind: graph.EdgeSpawns,
FilePath: filePath,
Line: c.line,
Origin: graph.OriginASTResolved,
Meta: map[string]any{
"mode": "goroutine",
},
})
}
// canonicalizeGoTypeRef returns a type-name string suitable for use
// as the target of a typed_as / returns edge. Unlike
// normalizeGoTypeName it preserves primitives — the agent-facing
// query "find me functions taking io.Reader" benefits from having
// the same shape for primitives ("find me functions returning int")
// even though no graph node exists for the primitive itself; the
// string serves as a stable, searchable target.
//
// Strips: leading whitespace, slice/array prefix, pointer prefix,
// generic-instantiation suffix, package qualifier.
// Returns "" for map/chan/func/struct/interface anonymous types and
// for empty input.
func canonicalizeGoTypeRef(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
t = strings.TrimPrefix(t, "[]")
if strings.HasPrefix(t, "[") {
if end := strings.Index(t, "]"); end >= 0 {
t = t[end+1:]
}
}
if strings.HasPrefix(t, "map[") ||
strings.HasPrefix(t, "chan ") ||
strings.HasPrefix(t, "func(") ||
strings.HasPrefix(t, "struct{") ||
strings.HasPrefix(t, "interface{") {
return ""
}
t = strings.TrimPrefix(t, "*")
if i := strings.LastIndex(t, "."); i >= 0 {
t = t[i+1:]
}
if i := strings.Index(t, "["); i >= 0 {
t = t[:i]
}
return strings.TrimSpace(t)
}
// goParamNodeID is the canonical ID convention for a Go parameter
// node: `<owner-id>#param:<name>`. Duplicate parameter names are
// already filtered (we skip `_`), so a position-disambiguating
// suffix isn't needed in the common case. The pos argument is kept
// in the signature for symmetry with future languages where
// duplicate names are legal.
func goParamNodeID(ownerID, name string, _ int) string {
return ownerID + "#param:" + name
}