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
832 lines
26 KiB
Go
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
|
|
}
|