Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions internal/indexer/temporal_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,67 @@ func setup(w Worker) {
assert.Equal(t, true, start.Meta["temporal_cross_lang"])
assert.Equal(t, graph.OriginSpeculative, start.Origin)
}

// TestTemporalE2E_GoExecutorFieldDispatch exercises the full pipeline for
// executor struct-field dispatch: a struct method that dispatches via a
// field, constructed with a string literal, must resolve through the
// real indexer to the registered activity.
func TestTemporalE2E_GoExecutorFieldDispatch(t *testing.T) {
dir := t.TempDir()

writeFile(t, filepath.Join(dir, "executor.go"), `package wf

import "go.temporal.io/sdk/workflow"

type ActivityExecutor struct{ ActivityName string }

func (e ActivityExecutor) Run(ctx workflow.Context) {
workflow.ExecuteActivity(ctx, e.ActivityName)
}
`)
writeFile(t, filepath.Join(dir, "activity.go"), `package wf

import "context"

func ChargeCard(ctx context.Context) error { return nil }
`)
writeFile(t, filepath.Join(dir, "main.go"), `package wf

func setup(w Worker) {
w.RegisterActivity(ChargeCard)
_ = ActivityExecutor{ActivityName: "ChargeCard"}
}
`)

g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

// Find the Run method node.
runners := g.FindNodesByName("Run")
require.NotEmpty(t, runners, "Run method must be indexed")
var runNode *graph.Node
for _, n := range runners {
if n.Language == "go" {
runNode = n
break
}
}
require.NotNil(t, runNode)

activity := g.FindNodesByName("ChargeCard")
require.NotEmpty(t, activity)

// The stub from Run must resolve to ChargeCard.
var stubCall *graph.Edge
for _, e := range g.GetOutEdges(runNode.ID) {
if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.stub" {
stubCall = e
break
}
}
require.NotNil(t, stubCall, "Run must have an outbound temporal.stub edge")
assert.Equal(t, activity[0].ID, stubCall.To,
"executor-field dispatch must resolve to ChargeCard")
}
35 changes: 35 additions & 0 deletions internal/parser/languages/go_temporal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -724,3 +724,38 @@ func executeActivity(ctx workflow.Context, name string, args ...any) error {
assert.Equal(t, "activity", wrapper.Meta["temporal_wrapper_kind"])
assert.Equal(t, "name", wrapper.Meta["temporal_wrapper_param"])
}

func TestGoTemporal_ExecutorFieldDispatch_EmitsStubMeta(t *testing.T) {
// The dispatch `e.ActivityName` on a receiver of type ActivityExecutor
// must carry temporal_name_field + temporal_recv_type on the stub edge.
fix := runGoExtract(t, `package wf

import "go.temporal.io/sdk/workflow"

type ActivityExecutor struct{ ActivityName string }

func (e ActivityExecutor) Run(ctx workflow.Context) {
workflow.ExecuteActivity(ctx, e.ActivityName)
}
`)
stubs := temporalEdgesByVia(fix, "temporal.stub")
require.Len(t, stubs, 1)
s := stubs[0]
assert.Equal(t, "ActivityName", s.Meta["temporal_name_field"])
assert.Equal(t, "ActivityExecutor", s.Meta["temporal_recv_type"])

// Construction site: ActivityExecutor{ActivityName: "ChargeCard"}
// should emit a temporal.executor-field marker edge.
fix2 := runGoExtract(t, `package wf

func setup() {
_ = ActivityExecutor{ActivityName: "ChargeCard"}
}
`)
markers := temporalEdgesByVia(fix2, "temporal.executor-field")
require.Len(t, markers, 1)
m := markers[0]
assert.Equal(t, "ActivityExecutor", m.Meta["executor_type"])
assert.Equal(t, "ActivityName", m.Meta["executor_field"])
assert.Equal(t, "ChargeCard", m.Meta["executor_value"])
}
82 changes: 82 additions & 0 deletions internal/parser/languages/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ const qGoAll = `
(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
Expand Down Expand Up @@ -241,6 +248,23 @@ type goDeferredCall struct {
// 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 for this deferred call.
// Used by the temporal executor-field resolver to inspect the
// dispatch argument shape (e.g. `e.ActivityName` as a selector).
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 {
Expand Down Expand Up @@ -331,6 +355,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
// 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 {
Expand Down Expand Up @@ -380,6 +405,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
spawn: isGoroutineSpawn(expr.Node),
returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec),
}
dc.callNode = expr.Node
if svc, argNode, ok := grpcRegisterArgNode(expr.Node, method); ok {
dc.grpcRegService, dc.grpcRegArgNode = svc, argNode
}
Expand Down Expand Up @@ -619,6 +645,21 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
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{
Expand Down Expand Up @@ -700,11 +741,15 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
// (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
}
}
}

Expand Down Expand Up @@ -776,6 +821,18 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
if c.tempEnvDefault {
meta["temporal_name_origin"] = "env_default"
}
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,
Expand Down Expand Up @@ -996,6 +1053,31 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
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
Expand Down
34 changes: 34 additions & 0 deletions internal/parser/languages/golang_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -878,3 +878,37 @@ func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) {
}
return "", false
}

// goCompositeLiteralType walks up from a keyed_element node to find the
// enclosing composite_literal and returns the simple type name.
//
// PURPOSE — extracts the receiver struct type from a struct literal so the
// executor-field pass can key the field assignment by (type, field).
// RATIONALE — tree-sitter does not expose a direct parent-of-kind API;
// walking the Parent chain is the standard idiom in this codebase.
// KEYWORDS — composite-literal, type-name, executor-field
func goCompositeLiteralType(keyed *sitter.Node, src []byte) string {
for n := keyed; n != nil; n = n.Parent() {
if n.Type() != "composite_literal" {
continue
}
t := n.ChildByFieldName("type")
if t == nil {
return ""
}
switch t.Type() {
case "type_identifier":
return t.Content(src)
case "pointer_type":
if inner := t.NamedChild(0); inner != nil && inner.Type() == "type_identifier" {
return inner.Content(src)
}
case "qualified_type":
if f := t.ChildByFieldName("name"); f != nil {
return f.Content(src)
}
}
return ""
}
return ""
}
Loading
Loading