diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index c8424573..7380f625 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -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") +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index fb358201..e181ce1a 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -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" { @@ -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]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 97a1a00a..4fa7ba8a 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -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 { @@ -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 @@ -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 @@ -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 } } @@ -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 @@ -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 @@ -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) } diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 18be4216..14d70a98 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -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 + } +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 7bdf003f..c27b29af 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -1,6 +1,7 @@ package resolver import ( + "strconv" "strings" "unicode" "unicode/utf8" @@ -89,6 +90,186 @@ const ( // // Returns the number of temporal.stub edges pointing at a resolved // handler after the pass. +// argNameAt reads the positional arg name recorded on a call edge. +// +// PURPOSE — read the positional arg name recorded on a call edge by the extractor +// RATIONALE — arg_names can be []string (most paths) or []any (json-round-tripped) +// KEYWORDS — arg_names, wrapper-following, position +func argNameAt(e *graph.Edge, pos int) string { + if e == nil || e.Meta == nil || pos < 0 { + return "" + } + switch a := e.Meta["arg_names"].(type) { + case []string: + if pos < len(a) { + return a[pos] + } + case []any: + if pos < len(a) { + if s, ok := a[pos].(string); ok { + return s + } + } + } + return "" +} + +// metaIntValue coerces an int-ish meta value to an int. +// +// PURPOSE — coerce various numeric representations of a position to int +// RATIONALE — meta values can be stored as int, int64, float64, or string depending on serialization +// KEYWORDS — position, coercion, param, wrapper-following +func metaIntValue(v any) (int, bool) { + switch x := v.(type) { + case int: + return x, true + case int64: + return int(x), true + case float64: + return int(x), true + case string: + if n, err := strconv.Atoi(x); err == nil { + return n, true + } + } + return 0, false +} + +// temporalWrapperStubExists is the idempotence guard for the wrapper pass. +// +// PURPOSE — prevent duplicate wrapper-synthesized stub edges on repeated resolver runs +// RATIONALE — resolveTemporalWrapperCalls runs on every settle; the guard is O(out-edges of caller) +// KEYWORDS — idempotence, temporal.stub, wrapper +func temporalWrapperStubExists(g graph.Store, from, kind, name string) bool { + for _, e := range g.GetOutEdges(from) { + if e == nil || e.Meta == nil { + continue + } + if v, _ := e.Meta["via"].(string); v != "temporal.stub" { + continue + } + if k, _ := e.Meta["temporal_kind"].(string); k != kind { + continue + } + if n, _ := e.Meta["temporal_name"].(string); n == name { + return true + } + } + return false +} + +// resolveTemporalWrapperCalls synthesises temporal.stub edges at callers of +// dispatch wrappers, propagating the caller's literal arg value through the +// wrapper's forwarded parameter. +// +// PURPOSE — synthesize temporal.stub edges at callers of wrapper functions that forward +// +// a parameter as the dispatch name, propagating the caller's literal arg value +// +// RATIONALE — single-level pass: find edges WITH temporal_name_param, find their callers, +// +// extract the arg at the wrapper's param position, emit a new stub +// +// KEYWORDS — wrapper-following, temporal.stub, arg_names, single-level +func resolveTemporalWrapperCalls(g graph.Store) { + type wrapper struct { + id, kind, name string + pos int + } + byID := map[string]wrapper{} + byName := map[string][]wrapper{} + + for e := range g.EdgesByKind(graph.EdgeCalls) { + if e == nil || e.Meta == nil || e.From == "" { + continue + } + if v, _ := e.Meta["via"].(string); v != "temporal.stub" { + continue + } + param, _ := e.Meta["temporal_name_param"].(string) + kind, _ := e.Meta["temporal_kind"].(string) + if param == "" || kind == "" { + continue + } + if _, seen := byID[e.From]; seen { + continue + } + pn := g.GetNode(e.From + "#param:" + param) + if pn == nil { + continue + } + pos, ok := metaIntValue(pn.Meta["position"]) + if !ok { + continue + } + wname := "" + if wnode := g.GetNode(e.From); wnode != nil { + wname = wnode.Name + } + w := wrapper{id: e.From, kind: kind, name: wname, pos: pos} + byID[e.From] = w + if wname != "" { + byName[wname] = append(byName[wname], w) + } + } + if len(byID) == 0 { + return + } + + type pending struct { + from, file, kind, name, wrapperName string + line int + } + var out []pending + emit := func(w wrapper, ce *graph.Edge) { + if ce.From == w.id { + return + } + name := argNameAt(ce, w.pos) + if name == "" { + return + } + out = append(out, pending{from: ce.From, file: ce.FilePath, line: ce.Line, + kind: w.kind, name: name, wrapperName: w.name}) + } + + for ce := range g.EdgesByKind(graph.EdgeCalls) { + if ce == nil || ce.From == "" || ce.Meta == nil { + continue + } + if _, ok := ce.Meta["arg_names"]; !ok { + continue + } + if w, ok := byID[ce.To]; ok { + emit(w, ce) + continue + } + callee, _ := ce.Meta["callee"].(string) + if callee == "" { + continue + } + for _, w := range byName[callee] { + emit(w, ce) + } + } + + for _, p := range out { + if temporalWrapperStubExists(g, p.from, p.kind, p.name) { + continue + } + g.AddEdge(&graph.Edge{ + From: p.from, To: temporalStubPlaceholder(p.kind, p.name), + Kind: graph.EdgeCalls, FilePath: p.file, Line: p.line, + Meta: map[string]any{ + "via": "temporal.stub", + "temporal_kind": p.kind, + "temporal_name": p.name, + "temporal_via_wrapper": p.wrapperName, + }, + }) + } +} + func ResolveTemporalCalls(g graph.Store) int { if g == nil { return 0 @@ -103,6 +284,12 @@ func ResolveTemporalCalls(g graph.Store) int { mu.Lock() defer mu.Unlock() + // Wrapper-following pre-pass: synthesise temporal.stub edges at callers of + // wrapper functions that forward a parameter as the Temporal dispatch name. + // Must run before the stub-collection sweep so the freshly synthesised stubs + // are picked up and resolved by the existing loop below. + resolveTemporalWrapperCalls(g) + // Single sweep over EdgeCalls — the largest edge class — collecting // both the temporal.register edges (index inputs) and the // temporal.stub edges (edges to resolve), instead of scanning it once diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 520d6add..0bceaf49 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -139,6 +139,84 @@ func capitalise(s string) string { return string(s[0]-32) + s[1:] } +// addWrapperStubCall adds a Temporal wrapper stub edge: from=wrapperID, to=placeholder, +// with temporal_name_param set so resolveTemporalWrapperCalls recognises it as a wrapper. +func (b *temporalTestGraph) addWrapperStubCall(wrapperID, kind, paramName, filePath string) *graph.Edge { + e := &graph.Edge{ + From: wrapperID, To: temporalStubPlaceholder(kind, paramName), + Kind: graph.EdgeCalls, FilePath: filePath, Line: 5, + Meta: map[string]any{ + "via": "temporal.stub", + "temporal_kind": kind, + "temporal_name": paramName, + "temporal_name_param": paramName, + }, + } + b.g.AddEdge(e) + return e +} + +// addCallerEdgeWithArgNames adds an EdgeCalls from callerID to calleeID carrying arg_names. +func (b *temporalTestGraph) addCallerEdgeWithArgNames(callerID, calleeID string, argNames []string, line int) *graph.Edge { + e := &graph.Edge{ + From: callerID, To: calleeID, + Kind: graph.EdgeCalls, FilePath: "wf/caller.go", Line: line, + Meta: map[string]any{"arg_names": argNames}, + } + b.g.AddEdge(e) + return e +} + +func TestResolveTemporalCalls_WrapperFollowing(t *testing.T) { + // Graph layout: + // execAct (wrapper): temporal.stub edge with temporal_name_param="name" + // execAct#param:name: KindParam at position 1 (0-indexed: ctx=0, name=1) + // OrderWorkflow: calls execAct with arg_names=["ctx", "ChargeCard", "in"] at position 1 + // ChargeCard: registered activity + b := newTemporalTestGraph() + + // The wrapper function + b.addGoFunc("wf/wrap.go::execAct", "execAct", "wf/wrap.go", "svc") + // Add the #param:name node at position 1 + b.g.AddNode(&graph.Node{ + ID: "wf/wrap.go::execAct#param:name", Kind: graph.KindParam, Name: "name", + FilePath: "wf/wrap.go", Language: "go", + Meta: map[string]any{"position": 1}, + }) + // The wrapper stub edge + b.addWrapperStubCall("wf/wrap.go::execAct", "activity", "name", "wf/wrap.go") + + // The caller + b.addGoFunc("wf/workflow.go::OrderWorkflow", "OrderWorkflow", "wf/workflow.go", "svc") + b.addCallerEdgeWithArgNames("wf/workflow.go::OrderWorkflow", "wf/wrap.go::execAct", + []string{"ctx", "ChargeCard", "in"}, 15) + + // The registered activity + activity := b.addGoFunc("wf/activity.go::ChargeCard", "ChargeCard", "wf/activity.go", "svc") + b.addGoFunc("wf/main.go::setup", "setup", "wf/main.go", "svc") + b.addGoRegister("wf/main.go::setup", "activity", "ChargeCard", "wf/main.go") + + resolved := ResolveTemporalCalls(b.g) + assert.GreaterOrEqual(t, resolved, 1, "wrapper-following must resolve at least one stub") + + // The OrderWorkflow must now have a temporal.stub edge pointing at ChargeCard + var wrapperStub *graph.Edge + for _, e := range b.g.GetOutEdges("wf/workflow.go::OrderWorkflow") { + if e == nil || e.Meta == nil { + continue + } + if e.Meta["via"] == "temporal.stub" && e.Meta["temporal_name"] == "ChargeCard" { + wrapperStub = e + break + } + } + require.NotNil(t, wrapperStub, "caller must have a synthesized temporal.stub edge for ChargeCard") + assert.Equal(t, activity.ID, wrapperStub.To, + "synthesized stub must resolve to the registered ChargeCard activity") + assert.Equal(t, "execAct", wrapperStub.Meta["temporal_via_wrapper"], + "synthesized edge must carry the wrapper name") +} + // --- Go-side tests -------------------------------------------------- func TestResolveTemporalCalls_GoActivityRegistration(t *testing.T) {