From 7ceff42f5809ddf63b504f3afee90e0039482d00 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 01:37:11 +0300 Subject: [PATCH 1/3] 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/parser/languages/go_temporal_test.go | 110 ++++++++++++++++++ internal/parser/languages/golang.go | 15 +++ internal/parser/languages/golang_temporal.go | 84 +++++++++++++ 3 files changed, 209 insertions(+) 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 648c4d2f91142775b706ed164334356713683b02 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 01:48:28 +0300 Subject: [PATCH 2/3] feat(temporal): resolve activity/workflow names from env-var-with-default vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workflow names its activity/child-workflow through a local variable read from an env var with a literal fallback, resolve the dispatch to that literal default instead of leaving it unresolved: actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") workflow.ExecuteActivity(ctx, actName, id) // -> activity "ChargeCard" name := os.Getenv("K"); if name == "" { name = "ChargeCard" } The parser does a narrow, intra-procedural lookup anchored on a literal os.Getenv / os.LookupEnv read (so the value is provably env-sourced, not a general data-flow guess) and tags the stub edge temporal_name_origin= env_default. The resolver then lands the edge at the speculative tier (OriginSpeculative, confidence 0.4, MetaSpeculative=true) rather than ast_resolved — the runtime env override may name a different handler than the literal default, so the edge is present but hidden from default queries. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 96 +++++++++ internal/parser/languages/go_temporal_test.go | 97 ++++++++++ internal/parser/languages/golang.go | 21 +- internal/parser/languages/golang_temporal.go | 182 +++++++++++++++++- internal/resolver/temporal_calls.go | 24 +++ internal/resolver/temporal_calls_test.go | 39 ++++ 6 files changed, 454 insertions(+), 5 deletions(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 00f49d9d..932c5f32 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,99 @@ func setup(w Worker) { assert.Equal(t, child.ID, stubCall.To) assert.Equal(t, "workflow", stubCall.Meta["temporal_kind"]) } + +// TestTemporalE2E_GoEnvDefaultActivity exercises the env-var-with-literal +// -default dispatch name: the workflow names its activity through a +// variable read from os.Getenv with a literal fallback. The pipeline must +// land the call on the default activity but at the speculative tier. +func TestTemporalE2E_GoEnvDefaultActivity(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import ( + "cmp" + "os" + + "go.temporal.io/sdk/workflow" +) + +func OrderWorkflow(ctx workflow.Context, id string) error { + actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") + return workflow.ExecuteActivity(ctx, actName, 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")[0] + activity := g.FindNodesByName("ChargeCard")[0] + + var stubCall *graph.Edge + for _, e := range g.GetOutEdges(wf.ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.stub" { + stubCall = e + break + } + } + require.NotNil(t, stubCall, "workflow must have an outbound temporal.stub edge") + assert.Equal(t, activity.ID, stubCall.To, + "env-default dispatch must land on the default activity") + assert.Equal(t, "env_default", stubCall.Meta["temporal_name_origin"]) + assert.Equal(t, graph.OriginSpeculative, stubCall.Origin, + "env-default resolution must be speculative") + assert.Equal(t, true, stubCall.Meta[graph.MetaSpeculative], + "env-default edge must be hidden-by-default") +} + +// 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 54f771f8..5b0a3ef2 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -310,3 +310,100 @@ func OrderWorkflow(ctx wf.Context) error { `) assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler")) } + +// --- Dispatch name from an env-var-with-literal-default variable ----- +// +// When the activity / workflow name is a local variable read from an +// env var with a literal fallback, resolve to the literal default and +// flag the stub edge `temporal_name_origin=env_default` so the resolver +// lands it at the speculative tier (the runtime env override may differ +// from the default). Anchored on a literal os.Getenv / os.LookupEnv read +// so the value is provably env-sourced — no general data-flow guessing. + +func TestGoTemporal_ExecuteActivity_EnvDefault_CmpOr(t *testing.T) { + fix := runGoExtract(t, `package wf + +import ( + "cmp" + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + e := edges[0] + assert.Equal(t, "unresolved::temporal::activity::ChargeCard", e.To, + "name must resolve to the literal default, not the variable identifier") + assert.Equal(t, "ChargeCard", e.Meta["temporal_name"]) + assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) +} + +func TestGoTemporal_ExecuteActivity_EnvDefault_IfEmpty(t *testing.T) { + fix := runGoExtract(t, `package wf + +import ( + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + name := os.Getenv("CHARGE_ACTIVITY") + if name == "" { + name = "ChargeCard" + } + workflow.ExecuteActivity(ctx, name, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "unresolved::temporal::activity::ChargeCard", edges[0].To) + assert.Equal(t, "ChargeCard", edges[0].Meta["temporal_name"]) + assert.Equal(t, "env_default", edges[0].Meta["temporal_name_origin"]) +} + +func TestGoTemporal_ExecuteActivity_PlainVarNotEnvDefault(t *testing.T) { + // A variable NOT sourced from an env read keeps the existing + // behaviour (trailing identifier as the name) and carries no + // env_default flag — we don't guess at arbitrary variables. + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func WF(ctx workflow.Context, picked string) { + actName := picked + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "actName", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged, "plain variable must not be flagged env_default") +} + +func TestGoTemporal_ExecuteActivity_EnvReadNoLiteralDefault(t *testing.T) { + // os.Getenv with no literal fallback can't be pinned to a name — + // keep the variable identifier, no env_default flag. + fix := runGoExtract(t, `package wf + +import ( + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + name := os.Getenv("CHARGE_ACTIVITY") + workflow.ExecuteActivity(ctx, name, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "name", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index c86103c2..64520207 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -207,6 +207,12 @@ type goDeferredCall struct { // carries the handler's string name. `via=temporal.handler` meta is // stamped on the emitted edge in the call post-pass below. tempHandlerKind string + // tempEnvDefault is set when tempName was resolved from a bare + // variable read from an env var with a literal default (e.g. + // `cmp.Or(os.Getenv("K"), "Default")`). The stub edge is then tagged + // `temporal_name_origin=env_default` so the resolver lands it at the + // speculative tier — the runtime env value may differ from the default. + tempEnvDefault bool } type goDeferredTypeRef struct { @@ -340,10 +346,20 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // Temporal workflow → activity dispatch: // `workflow.ExecuteActivity(ctx, X, ...)` etc. if kind, local, ok := goTemporalDispatchKind(receiver, method); ok { - if name := goTemporalDispatchName(expr.Node, src); name != "" { + argNode := goTemporalDispatchArg(expr.Node) + if name := goTemporalNameFromExpr(argNode, src); name != "" { dc.tempKind = kind dc.tempName = name dc.tempLocal = local + // Env-default refinement: when the name is a bare local + // variable, try to resolve it to an env-var-with-literal + // -default so the dispatch lands on the default activity. + if argNode != nil && argNode.Type() == "identifier" { + if def, ok := goTemporalEnvDefaultName(expr.Node, name, src); ok { + dc.tempName = def + dc.tempEnvDefault = true + } + } } } else if kind, _, ok := goTemporalRegisterKind(method); ok { // Temporal worker registration: @@ -663,6 +679,9 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe if c.tempLocal { meta["temporal_local"] = true } + if c.tempEnvDefault { + meta["temporal_name_origin"] = "env_default" + } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 056092dc..48fcabcf 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -156,12 +156,21 @@ func goTemporalHandlerName(callNode *sitter.Node, src []byte) string { // can't reduce to a name (e.g. a function literal), or when the call // has fewer than two positional arguments. func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { + return goTemporalNameFromExpr(goTemporalDispatchArg(callNode), src) +} + +// goTemporalDispatchArg returns the second positional argument node of a +// dispatch call (`workflow.ExecuteActivity(ctx, X, ...)` → X), or nil. +// Exposed separately from goTemporalDispatchName so the env-default +// refinement can inspect the argument's shape (a bare identifier is the +// only case it tries to resolve to a literal default). +func goTemporalDispatchArg(callNode *sitter.Node) *sitter.Node { if callNode == nil || callNode.Type() != "call_expression" { - return "" + return nil } args := callNode.ChildByFieldName("arguments") if args == nil { - return "" + return nil } count := 0 for i := 0; i < int(args.NamedChildCount()); i++ { @@ -171,10 +180,10 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { } count++ if count == 2 { - return goTemporalNameFromExpr(c, src) + return c } } - return "" + return nil } // goTemporalRegisterName extracts the registered function name from a @@ -284,3 +293,168 @@ func goTemporalNameFromExpr(node *sitter.Node, src []byte) string { } return "" } + +// goTemporalEnvDefaultName attempts to resolve a bare-identifier dispatch +// name to the string-literal default of an env-var-with-default +// assignment in the enclosing function. Returns the default and true for +// one of these shapes (anchored on a literal os.Getenv / os.LookupEnv +// read so the value is provably env-sourced): +// +// name := cmp.Or(os.Getenv("KEY"), "Default") // any call mixing an +// // os.Getenv read with a +// // string-literal arg +// name := os.Getenv("KEY") +// if name == "" { name = "Default" } // (or `name, ok := os.LookupEnv(...)` +// // followed by a literal assign) +// +// Intra-procedural and literal-only: only assignments lexically before +// the dispatch call are considered, and anything that isn't an +// os.Getenv-anchored literal default returns "", false. This is a +// deliberately narrow data-flow shortcut, not general constant +// propagation — see the speculative tier the resolver lands it at. +func goTemporalEnvDefaultName(callNode *sitter.Node, name string, src []byte) (string, bool) { + body := goEnclosingFuncBody(callNode) + if body == nil { + return "", false + } + limit := callNode.StartByte() + envDeclSeen := false + var result string + var found bool + var walk func(n *sitter.Node) + walk = func(n *sitter.Node) { + if n == nil || found { + return + } + // Only consider assignments lexically before the dispatch call. + if (n.Type() == "short_var_declaration" || n.Type() == "assignment_statement") && + n.StartByte() < limit && goAssignHasTarget(n, name, src) { + if rhs := goAssignRHSExpr(n); rhs != nil { + if rhs.Type() == "call_expression" { + if goIsEnvRead(rhs, src) { + envDeclSeen = true + } else if def, ok := goCallEnvDefaultLiteral(rhs, src); ok { + result, found = def, true + return + } + } else if envDeclSeen { + if lit, ok := goStringLiteralValue(rhs, src); ok { + result, found = lit, true + return + } + } + } + } + for i := 0; i < int(n.NamedChildCount()); i++ { + walk(n.NamedChild(i)) + if found { + return + } + } + } + walk(body) + return result, found +} + +// goEnclosingFuncBody walks up from n to the nearest function-like +// ancestor and returns its body block, or nil. +func goEnclosingFuncBody(n *sitter.Node) *sitter.Node { + for cur := n; cur != nil; cur = cur.Parent() { + switch cur.Type() { + case "function_declaration", "method_declaration", "func_literal": + return cur.ChildByFieldName("body") + } + } + return nil +} + +// goAssignHasTarget reports whether `name` appears among the left-hand +// targets of a short_var_declaration / assignment_statement. +func goAssignHasTarget(assign *sitter.Node, name string, src []byte) bool { + left := assign.ChildByFieldName("left") + if left == nil { + return false + } + for i := 0; i < int(left.NamedChildCount()); i++ { + c := left.NamedChild(i) + if c != nil && c.Type() == "identifier" && c.Content(src) == name { + return true + } + } + return false +} + +// goAssignRHSExpr returns the first right-hand expression of an +// assignment (the value for a single-target assign, or the lone call for +// a multi-return `a, b := f()`), or nil. +func goAssignRHSExpr(assign *sitter.Node) *sitter.Node { + right := assign.ChildByFieldName("right") + if right == nil || right.NamedChildCount() == 0 { + return nil + } + return right.NamedChild(0) +} + +// goIsEnvRead reports whether a call_expression is `os.Getenv(...)` or +// `os.LookupEnv(...)`. +func goIsEnvRead(call *sitter.Node, src []byte) bool { + fn := call.ChildByFieldName("function") + if fn == nil || fn.Type() != "selector_expression" { + return false + } + op := fn.ChildByFieldName("operand") + field := fn.ChildByFieldName("field") + if op == nil || field == nil || op.Content(src) != "os" { + return false + } + switch field.Content(src) { + case "Getenv", "LookupEnv": + return true + } + return false +} + +// goCallEnvDefaultLiteral inspects a call's arguments for the +// env-or-default shape `f(os.Getenv("KEY"), "Default")`: at least one +// argument is an os.Getenv / os.LookupEnv read AND at least one is a +// string literal. Returns the last string-literal argument and true on a +// match. +func goCallEnvDefaultLiteral(call *sitter.Node, src []byte) (string, bool) { + args := call.ChildByFieldName("arguments") + if args == nil { + return "", false + } + hasEnvRead := false + lastLiteral := "" + haveLiteral := false + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + if c.Type() == "call_expression" && goIsEnvRead(c, src) { + hasEnvRead = true + continue + } + if lit, ok := goStringLiteralValue(c, src); ok { + lastLiteral, haveLiteral = lit, true + } + } + if hasEnvRead && haveLiteral { + return lastLiteral, true + } + return "", false +} + +// goStringLiteralValue returns the unquoted value of a Go string literal +// node, or ("", false) for any other node type. +func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) { + if n == nil { + return "", false + } + switch n.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(n, src), true + } + return "", false +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 63cac106..5264ca5f 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -12,6 +12,14 @@ import ( // (`unresolved::temporal::::`). const temporalStubPrefix = unresolvedPrefix + "temporal::" +// temporalEnvDefaultConfidence is stamped on a stub edge whose name was +// resolved through an env-var-with-literal-default variable (the parser +// tags it `temporal_name_origin=env_default`). It sits in the +// speculative band (< 0.5) so the edge lands at the AMBIGUOUS label and, +// together with MetaSpeculative, is hidden from default queries: the +// runtime env override may name a different handler than the default. +const temporalEnvDefaultConfidence = 0.4 + // Temporal annotation node IDs the Java extractor emits via // EmitAnnotationEdge. The resolver consumes these to discover // temporal-tagged interfaces and methods. @@ -127,6 +135,18 @@ func ResolveTemporalCalls(g graph.Store) int { } handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo) + // When the name came from an env-var-with-literal-default + // variable, the value is a best-guess: land the resolved edge at + // the speculative tier instead of ast_resolved. + envDefault := false + if v, _ := e.Meta["temporal_name_origin"].(string); v == "env_default" { + envDefault = true + } + if handlerID != "" && envDefault { + origin = graph.OriginSpeculative + conf = temporalEnvDefaultConfidence + } + want := handlerID if want == "" { want = temporalStubPlaceholder(s.kind, s.name) @@ -145,6 +165,9 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = conf e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf) e.Meta["temporal_resolution"] = origin + if envDefault { + e.Meta[graph.MetaSpeculative] = true + } StampSynthesized(e, SynthTemporalStub) resolved++ } else { @@ -152,6 +175,7 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = 0 e.ConfidenceLabel = "" delete(e.Meta, "temporal_resolution") + delete(e.Meta, graph.MetaSpeculative) UnstampSynthesized(e) } reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 82c7922d..0b994cd0 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -44,6 +44,17 @@ func (b *temporalTestGraph) addStubCall(callerID, kind, name, filePath string) * return e } +// addStubCallEnvDefault adds a Temporal stub-call edge whose name was +// resolved from an env-var-with-literal-default variable +// (temporal_name_origin=env_default). The resolver must still land it on +// the registered handler but at the speculative tier (the runtime env +// override may differ from the default). +func (b *temporalTestGraph) addStubCallEnvDefault(callerID, kind, name, filePath string) *graph.Edge { + e := b.addStubCall(callerID, kind, name, filePath) + e.Meta["temporal_name_origin"] = "env_default" + return e +} + // addGoRegister adds a Go `worker.RegisterActivity(F)` edge: an // EdgeCalls edge from the worker-setup function to a placeholder, // carrying the temporal.register meta the resolver consumes. @@ -152,6 +163,34 @@ func TestResolveTemporalCalls_GoActivityRegistration(t *testing.T) { require.Len(t, b.g.GetInEdges(activity.ID), 1, "activity must see the inbound call edge") } +func TestResolveTemporalCalls_EnvDefaultResolvesSpeculative(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/workflow.go::OrderWorkflow", "OrderWorkflow", "wf/workflow.go", "svc") + call := b.addStubCallEnvDefault("wf/workflow.go::OrderWorkflow", "activity", "ChargeCard", "wf/workflow.go") + activity := b.addGoFunc("wf/activity.go::ChargeCard", "ChargeCard", "wf/activity.go", "svc") + b.addGoFunc("wf/main.go::setupWorker", "setupWorker", "wf/main.go", "svc") + b.addGoRegister("wf/main.go::setupWorker", "activity", "ChargeCard", "wf/main.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 1, resolved) + assert.Equal(t, activity.ID, call.To, "env-default stub must still land on the registered activity") + assert.Equal(t, graph.OriginSpeculative, call.Origin, "env-default resolution must be speculative tier") + assert.Less(t, call.Confidence, 0.5, "speculative confidence must be below the inferred threshold") + assert.Equal(t, true, call.Meta[graph.MetaSpeculative], "env-default edge must be hidden-by-default") +} + +func TestResolveTemporalCalls_EnvDefaultUnresolvedStaysPlaceholder(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/workflow.go::WF", "WF", "wf/workflow.go", "svc") + call := b.addStubCallEnvDefault("wf/workflow.go::WF", "activity", "MissingActivity", "wf/workflow.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 0, resolved) + assert.Equal(t, temporalStubPlaceholder("activity", "MissingActivity"), call.To) + _, speculative := call.Meta[graph.MetaSpeculative] + assert.False(t, speculative, "unresolved env-default edge must not carry the speculative flag") +} + func TestResolveTemporalCalls_GoChildWorkflowRegistration(t *testing.T) { b := newTemporalTestGraph() b.addGoFunc("a/parent.go::ParentWorkflow", "ParentWorkflow", "a/parent.go", "svc") From 2c7771de6507c3dd6bcd75cad7b49338a6010215 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 08:35:18 +0300 Subject: [PATCH 3/3] fix(temporal): drop now-unused goTemporalDispatchName (golangci unused) The G4 refactor routed dispatch-name extraction through goTemporalDispatchArg + goTemporalNameFromExpr, leaving goTemporalDispatchName with no call sites. golangci-lint's 'unused' linter (not caught by local 'go vet') flagged it. Fold its doc into goTemporalDispatchArg and remove the dead wrapper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/scheduled_tasks.lock | 1 + internal/parser/languages/golang_temporal.go | 34 +++++++------------- 2 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 00000000..f275d61c --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"ba66ac51-bf76-4fbf-9092-e8ebc75117eb","pid":1532766,"procStart":"15266838","acquiredAt":1781242320901} \ No newline at end of file diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 48fcabcf..44046192 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -141,29 +141,17 @@ func goTemporalHandlerName(callNode *sitter.Node, src []byte) string { 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: -// -// - a string literal: "MyActivity" -// - a bare identifier: MyActivity -// - a selector expression: pkg.MyActivity, recv.Method -// -// In every case we return the trailing identifier — that's the name -// the worker registers under (Temporal Go SDK convention: the bare -// function name unless `RegisterActivityWithOptions` overrides it). -// Returns "" when the second argument is missing, an expression we -// can't reduce to a name (e.g. a function literal), or when the call -// has fewer than two positional arguments. -func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { - return goTemporalNameFromExpr(goTemporalDispatchArg(callNode), src) -} - // goTemporalDispatchArg returns the second positional argument node of a -// dispatch call (`workflow.ExecuteActivity(ctx, X, ...)` → X), or nil. -// Exposed separately from goTemporalDispatchName so the env-default -// refinement can inspect the argument's shape (a bare identifier is the -// only case it tries to resolve to a literal default). +// dispatch call (`workflow.ExecuteActivity(ctx, X, args...)` → X), or +// nil. X is either a string literal ("MyActivity"), a bare identifier +// (MyActivity), or a selector expression (pkg.MyActivity, recv.Method); +// goTemporalNameFromExpr reduces it to the trailing identifier — the +// name the worker registers under (the bare function name unless +// `RegisterActivityWithOptions` overrides it). Returned as a node, not a +// reduced name, so the env-default refinement can inspect the argument's +// shape (a bare identifier is the only case it tries to resolve to a +// literal default). Returns nil when the call has fewer than two +// positional arguments. func goTemporalDispatchArg(callNode *sitter.Node) *sitter.Node { if callNode == nil || callNode.Type() != "call_expression" { return nil @@ -189,7 +177,7 @@ func goTemporalDispatchArg(callNode *sitter.Node) *sitter.Node { // goTemporalRegisterName extracts the registered function name from a // `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call — // the first positional argument, which is the function reference. -// Same expression shapes as goTemporalDispatchName. +// Same expression shapes as the dispatch-name argument. func goTemporalRegisterName(callNode *sitter.Node, src []byte) string { if callNode == nil || callNode.Type() != "call_expression" { return ""