From 9198ded9c3c128e6e2d67197d9b0eee6a9e99b00 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 01:37:11 +0300 Subject: [PATCH 1/2] feat(temporal): detect Go in-workflow query/signal/update handler declarations Surface workflow.SetQueryHandler / GetSignalChannel / SetUpdateHandler[WithOptions] calls as via=temporal.handler EdgeCalls edges carrying temporal_kind (query/signal/update) + temporal_name, originating from the enclosing workflow function. This mirrors the Java side's per-method @QueryMethod / @SignalMethod / @UpdateMethod annotation edges, giving the graph a symmetric, queryable record of the named handlers each Go workflow exposes. High-precision: only the canonical "workflow" receiver alias is matched and the handler name must be a string literal (runtime-matched names can't be pinned from a variable), consistent with the existing dispatch detector. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 34 ++++++ internal/parser/languages/go_temporal_test.go | 110 ++++++++++++++++++ internal/parser/languages/golang.go | 15 +++ internal/parser/languages/golang_temporal.go | 84 +++++++++++++ 4 files changed, 243 insertions(+) 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..54f771f8 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,113 @@ 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_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..056092dc 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,6 +79,68 @@ 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.GetSignalChannel(ctx, "name") +// 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": + return "query", true + case "GetSignalChannel": + 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 +230,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), From 87c4baf9097d53170e6e4669fc30c5d861729e02 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 11:10:58 +0300 Subject: [PATCH 2/2] feat(temporal): also detect SetQueryHandlerWithOptions / GetSignalChannelWithOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the in-workflow handler set: the detector already handled SetUpdateHandlerWithOptions but missed the WithOptions variants of the query and signal handlers. Note: the Go SDK has no SetSignalHandler — signals are received via GetSignalChannel[WithOptions]. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/parser/languages/go_temporal_test.go | 33 +++++++++++++++++++ internal/parser/languages/golang_temporal.go | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 54f771f8..206f609e 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -279,6 +279,39 @@ func OrderWorkflow(ctx workflow.Context) error { 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 diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 056092dc..894c9a6f 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -84,7 +84,9 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { // 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) // @@ -98,9 +100,9 @@ func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) { return "", false } switch method { - case "SetQueryHandler": + case "SetQueryHandler", "SetQueryHandlerWithOptions": return "query", true - case "GetSignalChannel": + case "GetSignalChannel", "GetSignalChannelWithOptions": return "signal", true case "SetUpdateHandler", "SetUpdateHandlerWithOptions": return "update", true