diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..5493393b 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,94 @@ func setup(w Worker) { require.Len(t, stubs, 1) require.Len(t, registers, 2) } + +// --- Outbound signal sends / query calls ---------------------------- +// +// A workflow (or a service holding a Temporal client) can signal or +// query an ALREADY-RUNNING workflow by name. These are the consumer +// side of the signal/query namespaces — distinct from the in-workflow +// handler declarations. We surface them as EdgeCalls edges tagged +// `via=temporal.signal-send` / `temporal.query-call` carrying the +// signal/query name (the 4th positional argument, a string literal). +// +// APIs (the name is always the 4th positional arg, after ctx + +// workflowID + runID): +// +// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // workflow -> workflow +// client.SignalWorkflow(ctx, wid, rid, "name", arg) // service -> workflow +// client.QueryWorkflow(ctx, wid, rid, "name", args...) // service -> workflow +// +// (Note: there is no workflow.QueryWorkflow — querying is a client-side +// operation; and SignalExternalWorkflow already returns a Future, so +// there is no ...Async variant.) + +func TestGoTemporal_SignalExternalWorkflow(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func Orchestrator(ctx workflow.Context) error { + return workflow.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) +} +`) + edges := temporalEdgesByVia(fix, "temporal.signal-send") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_ClientSignalWorkflow(t *testing.T) { + // Receiver is an arbitrary client variable, so detection is by + // method name (like the Register* helpers), gated on a string-literal + // name in the 4th position. + fix := runGoExtract(t, `package svc + +func Cancel(c Client) error { + return c.SignalWorkflow(ctx, "order-123", "", "cancel-request", nil) +} +`) + edges := temporalEdgesByVia(fix, "temporal.signal-send") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_ClientQueryWorkflow(t *testing.T) { + fix := runGoExtract(t, `package svc + +func Status(c Client) { + c.QueryWorkflow(ctx, "order-123", "", "get-status") +} +`) + edges := temporalEdgesByVia(fix, "temporal.query-call") + require.Len(t, edges, 1) + assert.Equal(t, "query", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "get-status", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_OutboundNonLiteralNameUndetected(t *testing.T) { + // Signal/query names are matched by string at runtime; a non-literal + // name can't be pinned, so no outbound edge is emitted. + fix := runGoExtract(t, `package svc + +func Cancel(c Client, name string) error { + return c.SignalWorkflow(ctx, "order-123", "", name, nil) +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) +} + +func TestGoTemporal_SignalExternalAliasedNotDetected(t *testing.T) { + // SignalExternalWorkflow is gated on the canonical "workflow" + // receiver (it is a workflow-package function); an aliased import + // is intentionally missed, consistent with the dispatch detector. + fix := runGoExtract(t, `package wf + +import wf "go.temporal.io/sdk/workflow" + +func Orchestrator(ctx wf.Context) error { + return wf.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..662678b2 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -201,6 +201,12 @@ type goDeferredCall struct { tempKind string tempName string tempLocal bool + // tempOutKind is "signal" / "query" when this call is an outbound + // signal-send / query-call against a running workflow + // (SignalExternalWorkflow / SignalWorkflow / QueryWorkflow); tempName + // then carries the signal/query name. `via=temporal.signal-send` / + // `temporal.query-call` meta is stamped on the emitted edge below. + tempOutKind string } type goDeferredTypeRef struct { @@ -346,6 +352,14 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempKind = "register_" + kind dc.tempName = name } + } else if okind, namePos, ok := goTemporalSignalQueryOutKind(receiver, method); ok { + // Outbound signal-send / query-call against a running + // workflow: SignalExternalWorkflow / SignalWorkflow / + // QueryWorkflow. The name is the 4th positional literal. + if name := goTemporalNthStringLiteralArg(expr.Node, namePos, src); name != "" { + dc.tempOutKind = okind + dc.tempName = name + } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { @@ -668,6 +682,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -680,6 +695,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -729,6 +745,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) 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 019aeec0..30d73ddc 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,6 +79,36 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } +// goTemporalSignalQueryOutKind reports whether (receiver, method) names +// an OUTBOUND signal-send or query-call against an already-running +// workflow and, if so, returns the kind ("signal" / "query") plus the +// 1-based position of the signal/query-name argument. +// +// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // wf -> wf +// client.SignalWorkflow(ctx, wid, rid, "name", arg) // svc -> wf +// client.QueryWorkflow(ctx, wid, rid, "name", args...) // svc -> wf +// +// SignalExternalWorkflow is gated on the canonical "workflow" receiver +// (it is a workflow-package function). SignalWorkflow / QueryWorkflow +// live on the client and are called on an arbitrary client variable, so +// — like the Register* helpers — they are matched by method name alone; +// the string-literal name gate below keeps that high-precision. There is +// deliberately no workflow.QueryWorkflow (querying is client-side) and no +// SignalExternalWorkflowAsync (SignalExternalWorkflow returns a Future). +func goTemporalSignalQueryOutKind(receiver, method string) (kind string, namePos int, ok bool) { + switch method { + case "SignalExternalWorkflow": + if receiver == "workflow" { + return "signal", 4, true + } + case "SignalWorkflow": + return "signal", 4, true + case "QueryWorkflow": + return "query", 4, true + } + return "", 0, false +} + // goTemporalDispatchName extracts the activity (or child-workflow) // name from a `workflow.ExecuteActivity(ctx, X, args...)` call. X is // the second positional argument and is either: @@ -115,6 +145,38 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { return "" } +// goTemporalNthStringLiteralArg returns the unquoted value of the n-th +// (1-based) positional argument of a call when that argument is a string +// literal, else "". Used to extract the signal/query name from an +// outbound send/call — names are matched by string at runtime, so only a +// literal can be pinned here (a variable / constant is left undetected, +// keeping the detector high-precision). +func goTemporalNthStringLiteralArg(callNode *sitter.Node, n int, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == n { + switch c.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(c, src) + } + return "" + } + } + return "" +} + // goTemporalRegisterName extracts the registered function name from a // `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call — // the first positional argument, which is the function reference. @@ -168,6 +230,37 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// applyGoTemporalSignalQueryMeta stamps the outbound signal-send / +// query-call meta onto an EdgeCalls edge derived from +// `SignalExternalWorkflow` / `SignalWorkflow` / `QueryWorkflow`: +// `via=temporal.signal-send` or `temporal.query-call`, plus +// `temporal_kind` (signal / query) and `temporal_name` (the literal +// signal/query name). No-op when c.tempOutKind / c.tempName are unset. +// +// These are the consumer side of the signal/query namespaces; the +// provider side is the in-workflow handler (GetSignalChannel / +// SetQueryHandler), tagged via=temporal.handler. +func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) { + if edge == nil || c.tempOutKind == "" || c.tempName == "" { + return + } + var via string + switch c.tempOutKind { + case "signal": + via = "temporal.signal-send" + case "query": + via = "temporal.query-call" + default: + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["via"] = via + edge.Meta["temporal_kind"] = c.tempOutKind + edge.Meta["temporal_name"] = c.tempName +} + // goTemporalNameFromExpr reduces a single argument expression to the // trailing identifier that names the activity / workflow. Handles // string literals (`"MyActivity"` and the Go raw-string variant),