diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 00f49d9d..d8cfb769 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,37 @@ func setup(w Worker) { assert.Equal(t, child.ID, stubCall.To) assert.Equal(t, "workflow", stubCall.Meta["temporal_kind"]) } + +// TestTemporalE2E_GoQueryHandler exercises in-workflow handler detection: +// a workflow.SetQueryHandler call must surface as a via=temporal.handler +// edge from the enclosing workflow carrying its kind + name. +func TestTemporalE2E_GoQueryHandler(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func StatusWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + wf := g.FindNodesByName("StatusWorkflow")[0] + var handler *graph.Edge + for _, e := range g.GetOutEdges(wf.ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.handler" { + handler = e + break + } + } + require.NotNil(t, handler, "workflow must have an outbound temporal.handler edge") + assert.Equal(t, "query", handler.Meta["temporal_kind"]) + assert.Equal(t, "status", handler.Meta["temporal_name"]) +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..206f609e 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,146 @@ func setup(w Worker) { require.Len(t, stubs, 1) require.Len(t, registers, 2) } + +// --- In-workflow handler declarations (query / signal / update) ----- +// +// These mirror the Java SDK's @QueryMethod / @SignalMethod / +// @UpdateMethod annotations: from inside a workflow body the Go SDK +// declares the named query / signal / update channels the workflow +// serves. We surface each as a `via=temporal.handler` EdgeCalls edge +// carrying temporal_kind + temporal_name so the graph records, per +// workflow, the named handlers it exposes — symmetric with the Java +// side's per-method annotation edges. + +func TestGoTemporal_SetQueryHandler(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + e := edges[0] + assert.Equal(t, "query", e.Meta["temporal_kind"]) + assert.Equal(t, "status", e.Meta["temporal_name"]) + assert.Equal(t, "pkg/foo.go::OrderWorkflow", e.From, + "handler edge must originate from the enclosing workflow function") +} + +func TestGoTemporal_GetSignalChannel(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + ch := workflow.GetSignalChannel(ctx, "cancel") + _ = ch + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SetUpdateHandler(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetUpdateHandler(ctx, "retry", func() error { return nil }) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "update", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "retry", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SetUpdateHandlerWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetUpdateHandlerWithOptions(ctx, "retry", func() error { return nil }, workflow.UpdateHandlerOptions{}) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "update", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "retry", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SetQueryHandlerWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandlerWithOptions(ctx, "status", func() (string, error) { return "ok", nil }, workflow.QueryHandlerOptions{}) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "query", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "status", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_GetSignalChannelWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + ch := workflow.GetSignalChannelWithOptions(ctx, "cancel", workflow.SignalChannelOptions{}) + _ = ch + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_HandlerNonLiteralNameUndetected(t *testing.T) { + // Query / signal / update names are matched by string at runtime; + // a non-literal name (variable / selector) can't be pinned here, so + // no handler edge is emitted — high-precision, no guessing. + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, q string) error { + workflow.SetQueryHandler(ctx, q, func() (string, error) { return "ok", nil }) + return nil +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler"), + "non-literal handler name must not be detected") +} + +func TestGoTemporal_HandlerAliasedImportNotDetected(t *testing.T) { + // Consistent with the dispatch detector: only the canonical + // "workflow" receiver alias is recognised. + fix := runGoExtract(t, `package wf + +import wf "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx wf.Context) error { + wf.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler")) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..c86103c2 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 + // tempHandlerKind is "query" / "signal" / "update" when this call + // is a `workflow.SetQueryHandler` / `GetSignalChannel` / + // `SetUpdateHandler` in-workflow handler declaration; tempName then + // carries the handler's string name. `via=temporal.handler` meta is + // stamped on the emitted edge in the call post-pass below. + tempHandlerKind string } type goDeferredTypeRef struct { @@ -346,6 +352,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempKind = "register_" + kind dc.tempName = name } + } else if hkind, ok := goTemporalHandlerKind(receiver, method); ok { + // Temporal in-workflow handler declaration: + // `workflow.SetQueryHandler(ctx, "name", fn)` etc. + if name := goTemporalHandlerName(expr.Node, src); name != "" { + dc.tempHandlerKind = hkind + dc.tempName = name + } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { @@ -668,6 +681,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalHandlerMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -680,6 +694,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalHandlerMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 019aeec0..894c9a6f 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,6 +79,70 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } +// goTemporalHandlerKind reports whether (receiver, method) names one of +// the Temporal in-workflow handler-declaration helpers and, if so, +// returns the canonical kind ("query" / "signal" / "update"). +// +// workflow.SetQueryHandler(ctx, "name", fn) +// workflow.SetQueryHandlerWithOptions(ctx, "name", fn, opts) +// workflow.GetSignalChannel(ctx, "name") +// workflow.GetSignalChannelWithOptions(ctx, "name", opts) +// workflow.SetUpdateHandler(ctx, "name", fn) +// workflow.SetUpdateHandlerWithOptions(ctx, "name", fn, opts) +// +// These mirror the Java SDK's `@QueryMethod` / `@SignalMethod` / +// `@UpdateMethod` annotations: a workflow declares, from inside its +// body, the named query / signal / update channels it serves. As with +// the dispatch helpers we require the receiver text to be exactly the +// canonical "workflow" alias. +func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) { + if receiver != "workflow" { + return "", false + } + switch method { + case "SetQueryHandler", "SetQueryHandlerWithOptions": + return "query", true + case "GetSignalChannel", "GetSignalChannelWithOptions": + return "signal", true + case "SetUpdateHandler", "SetUpdateHandlerWithOptions": + return "update", true + } + return "", false +} + +// goTemporalHandlerName extracts the query / signal / update name from a +// handler-declaration call — the second positional argument (after the +// workflow.Context). Unlike dispatch names we accept ONLY a string +// literal: handler names are matched by string at runtime, so a +// non-literal (variable / selector) can't be pinned to a name here and +// is left undetected, keeping the detector high-precision. Returns "" +// when the second argument is missing or is not a string literal. +func goTemporalHandlerName(callNode *sitter.Node, 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 == 2 { + switch c.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(c, src) + } + return "" + } + } + return "" +} + // 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: @@ -168,6 +232,28 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// applyGoTemporalHandlerMeta stamps `via=temporal.handler` plus +// `temporal_kind` (query / signal / update) and `temporal_name` (the +// handler's string name) onto the EdgeCalls edge derived from a +// `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler` +// call. No-op when c.tempHandlerKind / c.tempName are unset. +// +// The edge originates from the enclosing workflow function, so the +// graph records — per workflow — the named query / signal / update +// handlers it exposes, symmetric with the Java side's per-method +// `@QueryMethod` / `@SignalMethod` / `@UpdateMethod` annotation edges. +func applyGoTemporalHandlerMeta(edge *graph.Edge, c goDeferredCall) { + if edge == nil || c.tempHandlerKind == "" || c.tempName == "" { + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["via"] = "temporal.handler" + edge.Meta["temporal_kind"] = c.tempHandlerKind + 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),