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
67 changes: 67 additions & 0 deletions internal/indexer/temporal_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,3 +509,70 @@ func setup(w Worker) {
assert.Equal(t, true, start.Meta["temporal_cross_lang"])
assert.Equal(t, graph.OriginSpeculative, start.Origin)
}

// TestTemporalE2E_WrapperFollowing exercises the full wrapper-following pipeline:
// a thin dispatch wrapper forwards its `name` parameter to ExecuteActivity,
// and a workflow calls the wrapper with a literal activity name. The pipeline
// must produce a resolved temporal.stub edge from the workflow caller to the
// registered ChargeCard activity.
func TestTemporalE2E_WrapperFollowing(t *testing.T) {
dir := t.TempDir()

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

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

func execAct(ctx workflow.Context, name string, in any) workflow.Future {
return workflow.ExecuteActivity(ctx, name, in)
}
`)
writeFile(t, filepath.Join(dir, "workflow.go"), `package wf

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

func OrderWorkflow(ctx workflow.Context, id string) error {
return execAct(ctx, "ChargeCard", id).Get(ctx, nil)
}
`)
writeFile(t, filepath.Join(dir, "activity.go"), `package wf

import "context"

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

func setupWorker(w Worker) {
w.RegisterWorkflow(OrderWorkflow)
w.RegisterActivity(ChargeCard)
}
`)

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

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

// Find the temporal.stub edge from OrderWorkflow that names ChargeCard
var stubCall *graph.Edge
for _, e := range g.GetOutEdges(wf[0].ID) {
if e == nil || e.Meta == nil {
continue
}
if e.Meta["via"] == "temporal.stub" && e.Meta["temporal_name"] == "ChargeCard" {
stubCall = e
break
}
}
require.NotNil(t, stubCall,
"workflow caller must have a wrapper-synthesized temporal.stub edge for ChargeCard")
assert.Equal(t, activity[0].ID, stubCall.To,
"the wrapper-following stub must resolve to the registered ChargeCard activity")
}
67 changes: 63 additions & 4 deletions internal/parser/languages/go_temporal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -709,11 +709,16 @@ func executeActivity(ctx workflow.Context, name string, args ...any) error {
return workflow.ExecuteActivity(ctx, name, args...).Get(ctx, nil)
}
`)
// The parameter-named dispatch must NOT emit a (never-resolvable) stub.
assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub"),
"a parameter-forwarded dispatch must not emit a junk stub")
// PR2: the parameter-forwarded dispatch now emits a wrapper-anchor stub
// carrying temporal_name_param (instead of being fully suppressed). The
// resolver's wrapper-following pass uses this anchor to synthesise real
// stubs at callers that pass a literal activity name.
stubs := temporalEdgesByVia(fix, "temporal.stub")
require.Len(t, stubs, 1, "wrapper must emit exactly one anchor temporal.stub edge")
assert.Equal(t, "name", stubs[0].Meta["temporal_name_param"],
"wrapper anchor stub must carry temporal_name_param")

// The wrapper function is marked for a future interprocedural follower.
// The wrapper function is marked for the interprocedural follower.
var wrapper *graph.Node
for _, n := range fix.nodesByKind[graph.KindFunction] {
if n.Name == "executeActivity" {
Expand All @@ -724,3 +729,57 @@ 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_WrapperEmitsParamStub(t *testing.T) {
// A wrapper function that forwards its `name` param as the dispatch name
// must emit a temporal.stub edge with temporal_name_param="name".
fix := runGoExtract(t, `package wf

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

func execAct(ctx workflow.Context, name string, in any) workflow.Future {
return workflow.ExecuteActivity(ctx, name, in)
}
`)
stubs := temporalEdgesByVia(fix, "temporal.stub")
require.Len(t, stubs, 1, "wrapper must emit exactly one temporal.stub edge")
e := stubs[0]
assert.Equal(t, "activity", e.Meta["temporal_kind"])
assert.Equal(t, "name", e.Meta["temporal_name_param"],
"wrapper stub must carry temporal_name_param")
}

func TestGoTemporal_CallerEdgeHasArgNames(t *testing.T) {
// A call to a wrapper-like function with a string-literal argument
// must carry arg_names on the call edge.
fix := runGoExtract(t, `package wf

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

func execAct(ctx workflow.Context, name string, in any) workflow.Future {
return workflow.ExecuteActivity(ctx, name, in)
}

func OrderWorkflow(ctx workflow.Context, id string) error {
execAct(ctx, "ChargeCard", id)
return nil
}
`)
// Find the call edge from OrderWorkflow to execAct
var callerEdge *graph.Edge
for _, e := range fix.edgesByKind[graph.EdgeCalls] {
if e.Meta != nil {
if names, ok := e.Meta["arg_names"]; ok {
_ = names
callerEdge = e
break
}
}
}
require.NotNil(t, callerEdge, "call edge to execAct must carry arg_names")
argNames, ok := callerEdge.Meta["arg_names"].([]string)
require.True(t, ok, "arg_names must be []string")
// Position 1 (0-indexed) should be "ChargeCard"
require.Greater(t, len(argNames), 1)
assert.Equal(t, "ChargeCard", argNames[1])
}
32 changes: 32 additions & 0 deletions internal/parser/languages/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,11 @@ 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, retained so the general
// call-edge emission can extract positional arg names
// (attachGoTemporalCallArgNames) for the resolver's wrapper-following
// pass. Nil for synthetic / non-call entries.
callNode *sitter.Node
}

type goDeferredTypeRef struct {
Expand Down Expand Up @@ -362,6 +367,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
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
Expand All @@ -379,6 +385,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
isSelector: true,
spawn: isGoroutineSpawn(expr.Node),
returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec),
callNode: expr.Node,
}
if svc, argNode, ok := grpcRegisterArgNode(expr.Node, method); ok {
dc.grpcRegService, dc.grpcRegArgNode = svc, argNode
Expand Down Expand Up @@ -760,6 +767,28 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
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
}
}
Expand Down Expand Up @@ -800,6 +829,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
applyGoTemporalSignalQueryMeta(edge, c)
applyGoTemporalStartMeta(edge, c)
stampReturnUsage(edge, c.returnUsage)
attachGoTemporalCallArgNames(edge, c, c.callNode, src)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand All @@ -816,6 +846,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
applyGoTemporalSignalQueryMeta(edge, c)
applyGoTemporalStartMeta(edge, c)
stampReturnUsage(edge, c.returnUsage)
attachGoTemporalCallArgNames(edge, c, c.callNode, src)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
continue
Expand Down Expand Up @@ -868,6 +899,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
applyGoTemporalSignalQueryMeta(edge, c)
applyGoTemporalStartMeta(edge, c)
stampReturnUsage(edge, c.returnUsage)
attachGoTemporalCallArgNames(edge, c, c.callNode, src)
result.Edges = append(result.Edges, edge)
emitGoSpawnEdge(c, callerID, target, filePath, result)
}
Expand Down
73 changes: 73 additions & 0 deletions internal/parser/languages/golang_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -878,3 +878,76 @@ func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) {
}
return "", false
}

// goTemporalCallArgNames extracts positional arg names from a call expression.
//
// PURPOSE — extract positional arg names from a call expression for wrapper-following
// RATIONALE — only qualifying args (string literals, selectors, Capitalized identifiers)
//
// are included; plain lowercase vars are not useful as activity names
//
// KEYWORDS — arg_names, wrapper-following, call_expression
func goTemporalCallArgNames(callNode *sitter.Node, src []byte) ([]string, bool) {
if callNode == nil || callNode.Type() != "call_expression" {
return nil, false
}
args := callNode.ChildByFieldName("arguments")
if args == nil {
return nil, false
}
const maxArgs = 8
var out []string
qualifying := false
count := 0
for i := 0; i < int(args.NamedChildCount()) && count < maxArgs; i++ {
c := args.NamedChild(i)
if c == nil {
continue
}
count++
name := ""
switch c.Type() {
case "interpreted_string_literal", "raw_string_literal":
name = goTemporalNameFromExpr(c, src)
qualifying = true
case "selector_expression":
name = goTemporalNameFromExpr(c, src)
qualifying = true
case "identifier":
name = c.Content(src)
if name != "" && name[0] >= 'A' && name[0] <= 'Z' {
qualifying = true
}
}
out = append(out, name)
}
if !qualifying {
return nil, false
}
return out, true
}

// attachGoTemporalCallArgNames attaches arg_names + callee meta to a call edge.
//
// PURPOSE — attach arg_names and callee meta to a call edge for wrapper-following
// RATIONALE — the resolver's wrapper pass needs both the arg values and the callee name
//
// to match caller edges to wrapper definitions
//
// KEYWORDS — arg_names, callee, wrapper-following, edge meta
func attachGoTemporalCallArgNames(edge *graph.Edge, c goDeferredCall, callNode *sitter.Node, src []byte) {
names, ok := goTemporalCallArgNames(callNode, src)
if !ok {
return
}
if edge.Meta == nil {
edge.Meta = map[string]any{}
}
edge.Meta["arg_names"] = names
// callee: the function/method name being called
if c.isSelector {
edge.Meta["callee"] = c.method
} else if c.callName != "" {
edge.Meta["callee"] = c.callName
}
}
Loading
Loading