package languages import ( "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/parser" ) // temporalEdgesByVia returns every EdgeCalls edge tagged with the given // `via` value (e.g. "temporal.stub" or "temporal.register"). func temporalEdgesByVia(fix *extractedFixture, via string) []*graph.Edge { var found []*graph.Edge for _, e := range fix.edgesByKind[graph.EdgeCalls] { if e.Meta != nil && e.Meta["via"] == via { found = append(found, e) } } return found } func TestGoTemporal_ExecuteActivity_IdentifierName(t *testing.T) { fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func OrderWorkflow(ctx workflow.Context, id string) error { workflow.ExecuteActivity(ctx, ChargeCard, id) return nil } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "unresolved::temporal::activity::ChargeCard", e.To) assert.Equal(t, "activity", e.Meta["temporal_kind"]) assert.Equal(t, "ChargeCard", e.Meta["temporal_name"]) _, isLocal := e.Meta["temporal_local"] assert.False(t, isLocal, "ExecuteActivity must not flag temporal_local") } func TestGoTemporal_ExecuteActivity_StringLiteralName(t *testing.T) { fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func WF(ctx workflow.Context) { workflow.ExecuteActivity(ctx, "RemoteActivity", nil) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "unresolved::temporal::activity::RemoteActivity", edges[0].To) assert.Equal(t, "RemoteActivity", edges[0].Meta["temporal_name"]) } func TestGoTemporal_ExecuteActivity_SelectorName(t *testing.T) { // `workflow.ExecuteActivity(ctx, pkg.Charge, ...)` → name is "Charge" // (the trailing identifier of the selector). fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/activities" ) func WF(ctx workflow.Context) { workflow.ExecuteActivity(ctx, activities.Charge, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "unresolved::temporal::activity::Charge", edges[0].To) } func TestGoTemporal_ExecuteLocalActivity_FlagsTemporalLocal(t *testing.T) { fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func WF(ctx workflow.Context) { workflow.ExecuteLocalActivity(ctx, Lookup, "k") } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "activity", e.Meta["temporal_kind"]) assert.Equal(t, true, e.Meta["temporal_local"], "ExecuteLocalActivity must flag temporal_local") } func TestGoTemporal_ExecuteChildWorkflow_KindIsWorkflow(t *testing.T) { fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func Parent(ctx workflow.Context) { workflow.ExecuteChildWorkflow(ctx, ChildWorkflow, 42) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "unresolved::temporal::workflow::ChildWorkflow", edges[0].To) assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) } func TestGoTemporal_RegisterActivity(t *testing.T) { fix := runGoExtract(t, `package main func setup(w Worker) { w.RegisterActivity(ChargeCard) } `) edges := temporalEdgesByVia(fix, "temporal.register") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "activity", e.Meta["temporal_kind"]) assert.Equal(t, "ChargeCard", e.Meta["temporal_name"]) } func TestGoTemporal_RegisterActivityWithOptions(t *testing.T) { fix := runGoExtract(t, `package main import "go.temporal.io/sdk/activity" func setup(w Worker) { w.RegisterActivityWithOptions(ChargeCard, activity.RegisterOptions{Name: "Charge"}) } `) edges := temporalEdgesByVia(fix, "temporal.register") require.Len(t, edges, 1) assert.Equal(t, "activity", edges[0].Meta["temporal_kind"]) assert.Equal(t, "ChargeCard", edges[0].Meta["temporal_name"], "temporal_name keeps the function-reference identifier") assert.Equal(t, "Charge", edges[0].Meta["temporal_registered_name"], "RegisterOptions{Name} override is captured as the registered name") } func TestGoTemporal_RegisterWorkflow(t *testing.T) { fix := runGoExtract(t, `package main func setup(w Worker) { w.RegisterWorkflow(OrderWorkflow) } `) edges := temporalEdgesByVia(fix, "temporal.register") require.Len(t, edges, 1) assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) } func TestGoTemporal_OtherWorkflowMethodNotStubbed(t *testing.T) { // `workflow.Sleep` / `workflow.Now` / etc. must NOT be stamped as // temporal.stub — only the four explicit dispatch helpers are. fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func WF(ctx workflow.Context) { workflow.Sleep(ctx, 5) workflow.Now(ctx) } `) assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub"), "only ExecuteActivity / ExecuteLocalActivity / ExecuteChildWorkflow should be stub-tagged") } func TestGoTemporal_AliasedImportDetected(t *testing.T) { // An aliased `import wf "go.temporal.io/sdk/workflow"` is resolved from // the file's import table and canonicalised to the "workflow" receiver, // so dispatch through the alias is detected. fix := runGoExtract(t, `package wf import wf "go.temporal.io/sdk/workflow" func WF(ctx wf.Context) { wf.ExecuteActivity(ctx, Charge, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "Charge", edges[0].Meta["temporal_name"]) } func TestGoTemporal_NonWorkflowReceiverStillIgnored(t *testing.T) { // A same-named receiver that is NOT the workflow package alias must // not be misread as a dispatch — the alias gate only canonicalises the // actual workflow import. fix := runGoExtract(t, `package wf import wf "go.temporal.io/sdk/workflow" func WF(ctx wf.Context, other Helper) { other.ExecuteActivity(ctx, Charge, 1) } `) assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub"), "a non-workflow receiver must not be detected even when workflow is aliased") } func TestGoTemporal_StubAndRegisterCoexistInSameFile(t *testing.T) { fix := runGoExtract(t, `package main import "go.temporal.io/sdk/workflow" func Charge() error { return nil } func WF(ctx workflow.Context) { workflow.ExecuteActivity(ctx, Charge, 1) } func setup(w Worker) { w.RegisterActivity(Charge) w.RegisterWorkflow(WF) } `) stubs := temporalEdgesByVia(fix, "temporal.stub") registers := temporalEdgesByVia(fix, "temporal.register") require.Len(t, stubs, 1) require.Len(t, registers, 2) } // --- 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) } func TestGoTemporal_ExecuteActivity_EnvDefault_CmpOrFirstLiteral(t *testing.T) { // cmp.Or returns the FIRST non-zero argument, so with the env unset // the runtime value is the first literal ("First"), not the last. fix := runGoExtract(t, `package wf import ( "cmp" "os" "go.temporal.io/sdk/workflow" ) func WF(ctx workflow.Context) { actName := cmp.Or(os.Getenv("A"), os.Getenv("B"), "First", "Second") workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "First", edges[0].Meta["temporal_name"], "cmp.Or default must be the first literal, not the last") assert.Equal(t, "env_default", edges[0].Meta["temporal_name_origin"]) } func TestEnvFallbackViaHelper_GetEnvOrDefaultValue(t *testing.T) { // The `...Value`-suffixed sibling of GetEnvOrDefault is a real variant // seen in production env-helper packages (L1 corpus audit); it must be // recognised exactly like GetEnvOrDefault since the allow-list matches // the full callee name, not a substring. fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/envhelper" ) func WF(ctx workflow.Context) { actName := envhelper.GetEnvOrDefaultValue("PROCESS_CANCEL_ACTIVITY", "ProcessCancelActivity") workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "ProcessCancelActivity", e.Meta["temporal_name"]) assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) } func TestEnvFallbackViaHelper_BareIdentifierEnvOr(t *testing.T) { // Bare (un-qualified) helper call: `EnvOr(KEY, "Default")`. fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func WF(ctx workflow.Context) { actName := EnvOr("REFUND_ACTIVITY", "RefundActivity") workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "RefundActivity", e.Meta["temporal_name"]) assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) } func TestEnvFallbackHeuristic_EnvNamedHelperFlaggedSpeculative(t *testing.T) { // Generic recall layer: a helper NOT in the allow-list but whose name // contains "env" (case-insensitive) is recognised by the structural // heuristic — its 2nd string-literal argument is taken as the default, // tagged temporal_env_source=heuristic so the resolver can keep it at the // hidden speculative tier (the LLM cleaning pass verifies it later). fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/cfg" ) func WF(ctx workflow.Context) { actName := cfg.ActivityFromEnv("CHARGE_ACTIVITY", "ChargeActivity") workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "ChargeActivity", e.Meta["temporal_name"]) assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) assert.Equal(t, "heuristic", e.Meta["temporal_env_source"]) } func TestEnvFallbackViaHelper_AllowlistTaggedSource(t *testing.T) { // An allow-list helper is tagged temporal_env_source=allowlist so the // resolver can promote it above the generic-heuristic tier. fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/wfutils" ) func WF(ctx workflow.Context) { actName := wfutils.GetEnvOrDefault("CHARGE_ACTIVITY", "ChargeActivity") workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "allowlist", edges[0].Meta["temporal_env_source"]) } func TestEnvFallbackViaHelper_ConstSelectorDefault(t *testing.T) { // The #1 corpus gap: env-helper default is a selector_expression constant // reference, not a literal. temporal_name stays the dispatch variable; the // const NAME is recorded in temporal_default_const at the const_ref tier. fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/config" "example.com/app/wfutils" ) func WF(ctx workflow.Context) { actName := wfutils.GetEnvOrDefault(config.ACTIVITY_NAME_ENV, config.ACTIVITY_NAME_DEFAULT) workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "actName", e.Meta["temporal_name"], "temporal_name stays the dispatch variable for a const default") assert.Equal(t, "ACTIVITY_NAME_DEFAULT", e.Meta["temporal_default_const"]) assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) assert.Equal(t, "const_ref", e.Meta["temporal_env_source"]) } func TestEnvFallbackViaHelper_ConstBareDefault(t *testing.T) { // Local (un-qualified) constant default — a bare identifier. fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" const VALIDATE_ACTIVITY_NAME_DEFAULT = "ValidateActivity" func WF(ctx workflow.Context) { actName := EnvOr("VALIDATE_ACTIVITY_NAME_ENV", VALIDATE_ACTIVITY_NAME_DEFAULT) workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "VALIDATE_ACTIVITY_NAME_DEFAULT", e.Meta["temporal_default_const"]) assert.Equal(t, "const_ref", e.Meta["temporal_env_source"]) } func TestEnvFallbackHeuristic_ConstDefaultStaysHeuristic(t *testing.T) { // An env-NAMED (heuristic, not allow-listed) helper with a const default: // the const is recorded, but the source stays "heuristic" (the helper // itself is the unproven link → hidden speculative tier). fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/cfg" ) func WF(ctx workflow.Context) { actName := cfg.ActivityFromEnv("CHARGE_ACTIVITY", cfg.CHARGE_ACTIVITY_DEFAULT) workflow.ExecuteActivity(ctx, actName, 1) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "CHARGE_ACTIVITY_DEFAULT", e.Meta["temporal_default_const"]) assert.Equal(t, "heuristic", e.Meta["temporal_env_source"], "heuristic helper stays heuristic even with a const default") } func TestEnvFallbackAllowlist_ConfigPromotesHelper(t *testing.T) { // A helper that is neither built-in NOR "env"-named is invisible to both // layers by default — but installing it in the per-repo corporate // allow-list promotes it to the allowlist tier (source=allowlist). src := `package wf import ( "go.temporal.io/sdk/workflow" "example.com/app/cfg" ) func WF(ctx workflow.Context) { actName := cfg.FetchActivityName("CHARGE_ACTIVITY", "ChargeActivity") workflow.ExecuteActivity(ctx, actName, 1) } ` // Default: the unknown helper is not recognised (the dispatch keeps the // bare variable name, no env_default flag). def := temporalEdgesByVia(runGoExtract(t, src), "temporal.stub") require.Len(t, def, 1) assert.Equal(t, "actName", def[0].Meta["temporal_name"]) _, flagged := def[0].Meta["temporal_name_origin"] assert.False(t, flagged, "unknown non-env helper must not be flagged by default") // With the corporate allow-list, the same helper resolves to its literal // default at the allowlist tier. got := temporalEdgesByVia(runGoExtractWithEnvHelpers(t, src, []string{"FetchActivityName"}), "temporal.stub") require.Len(t, got, 1) assert.Equal(t, "ChargeActivity", got[0].Meta["temporal_name"]) assert.Equal(t, "env_default", got[0].Meta["temporal_name_origin"]) assert.Equal(t, "allowlist", got[0].Meta["temporal_env_source"]) } func TestEnvFallbackViaHelper_UnknownHelperNotFlagged(t *testing.T) { // A helper whose name is NOT in the tight allow-list AND is not // "env"-named must not be treated as an env-default — precision over // recall. (`combine` mixes an env read with a literal but is neither // cmp.Or nor an env-named helper.) fix := runGoExtract(t, `package wf import ( "os" "go.temporal.io/sdk/workflow" ) func combine(a, b string) string { return a + b } func WF(ctx workflow.Context) { actName := combine(os.Getenv("K"), "Suffix") 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, "non-cmp.Or callee must not be treated as env_default") } func TestGoTemporal_ExecuteActivity_EnvDefaultOverwrittenNotFlagged(t *testing.T) { // A later plain reassignment is the live value at the call site; the // earlier env-default write must not win — leave it unresolved. fix := runGoExtract(t, `package wf import ( "cmp" "os" "go.temporal.io/sdk/workflow" ) func pick() string { return "Other" } func WF(ctx workflow.Context) { actName := cmp.Or(os.Getenv("K"), "ChargeCard") actName = pick() 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, "a later non-env reassignment must clear the env_default flag") } func TestGoTemporal_ExecuteActivity_ShadowInNestedClosureNotMatched(t *testing.T) { // A same-named variable declared in a nested closure is a different // scope; it must not be matched for the outer dispatch's name. fix := runGoExtract(t, `package wf import ( "cmp" "os" "go.temporal.io/sdk/workflow" ) func run(f func()) { f() } func WF(ctx workflow.Context, picked string) { actName := picked run(func() { actName := cmp.Or(os.Getenv("K"), "Inner") _ = actName }) 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, "a shadowing var in a nested closure must not flag the outer dispatch") } // --- 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_HandlerAliasedImportDetected(t *testing.T) { // Consistent with the dispatch detector: an aliased workflow import is // canonicalised and the handler declaration is detected. 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 } `) 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"]) } // --- 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_SignalExternalAliasedDetected(t *testing.T) { // SignalExternalWorkflow is a workflow-package function; an aliased // import is canonicalised to the "workflow" receiver and detected. 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) } `) 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"]) } // --- Service-side workflow START (ExecuteWorkflow / SignalWithStartWorkflow) --- func TestGoTemporal_ExecuteWorkflowStart(t *testing.T) { // client.ExecuteWorkflow(ctx, opts, WorkflowFn, args...) — workflow is // the 3rd positional arg, reduced from the func reference. fix := runGoExtract(t, `package svc func Start(c Client) { c.ExecuteWorkflow(ctx, opts, OrderWorkflow, 1) } `) edges := temporalEdgesByVia(fix, "temporal.start") require.Len(t, edges, 1) assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) } func TestGoTemporal_SignalWithStartWorkflow(t *testing.T) { // client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, ...) // — the workflow is the 6th positional arg. fix := runGoExtract(t, `package svc func Start(c Client) { c.SignalWithStartWorkflow(ctx, "order-1", "cancel", nil, opts, OrderWorkflow, 1) } `) edges := temporalEdgesByVia(fix, "temporal.start") require.Len(t, edges, 1) assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) } // --- Dispatch wrapper detection (issue #80 Q2) ---------------------- // // A function that forwards one of its parameters as the dispatch name is // a wrapper; the parameter is not a real activity name, so emitting a stub // for it is noise. We suppress the stub and mark the function as a wrapper // (temporal_wrapper_*) for a future interprocedural follower. Propagating // the caller's argument through the wrapper (cross-file) is not yet done. func TestGoTemporal_WrapperParamDispatchSuppressed(t *testing.T) { fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func executeActivity(ctx workflow.Context, name string, args ...any) error { return workflow.ExecuteActivity(ctx, name, args...).Get(ctx, nil) } `) // 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 the interprocedural follower. var wrapper *graph.Node for _, n := range fix.nodesByKind[graph.KindFunction] { if n.Name == "executeActivity" { wrapper = n } } require.NotNil(t, wrapper) 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]) } func TestGoTemporal_ExecutorFieldDispatch_EmitsStubMeta(t *testing.T) { // The dispatch `e.ActivityName` on a receiver of type ActivityExecutor // must carry temporal_name_field + temporal_recv_type on the stub edge. fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" type ActivityExecutor struct{ ActivityName string } func (e ActivityExecutor) Run(ctx workflow.Context) { workflow.ExecuteActivity(ctx, e.ActivityName) } `) stubs := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, stubs, 1) s := stubs[0] assert.Equal(t, "ActivityName", s.Meta["temporal_name_field"]) assert.Equal(t, "ActivityExecutor", s.Meta["temporal_recv_type"]) // Construction site: ActivityExecutor{ActivityName: "ChargeCard"} // should emit a temporal.executor-field marker edge. fix2 := runGoExtract(t, `package wf func setup() { _ = ActivityExecutor{ActivityName: "ChargeCard"} } `) markers := temporalEdgesByVia(fix2, "temporal.executor-field") require.Len(t, markers, 1) m := markers[0] assert.Equal(t, "ActivityExecutor", m.Meta["executor_type"]) assert.Equal(t, "ActivityName", m.Meta["executor_field"]) assert.Equal(t, "ChargeCard", m.Meta["executor_value"]) } func TestGoTemporal_FuncReturningConstant_EmitsConstValue(t *testing.T) { // PURPOSE — a func that returns a single string literal must emit a // ConstValue entry (not just a KindFunction node) so the resolver's // const-deref map can map the func name to its literal value. ext := NewGoExtractor() result, err := ext.Extract("pkg/foo.go", []byte(`package foo func GetChargeActivityName() string { return "ChargeActivity" } `)) require.NoError(t, err) // Find the function node var funcNode *graph.Node for _, n := range result.Nodes { if n.Kind == graph.KindFunction && n.Name == "GetChargeActivityName" { funcNode = n break } } require.NotNil(t, funcNode, "KindFunction node GetChargeActivityName must exist") // Find the ConstValue for it var cv *parser.ConstValue for i := range result.ConstValues { if result.ConstValues[i].NodeID == funcNode.ID { cv = &result.ConstValues[i] break } } require.NotNil(t, cv, "ConstValue for GetChargeActivityName must be emitted") assert.Equal(t, "ChargeActivity", cv.Value) assert.Equal(t, "pkg/foo.go", cv.FilePath) } func TestGoTemporal_ExecuteActivity_CallExprName(t *testing.T) { // PURPOSE — workflow.ExecuteActivity(ctx, GetX()) must emit a temporal.stub // edge with temporal_name == "GetX" so the resolver can deref the func name // to its return literal via the const-deref map. fix := runGoExtract(t, `package wf import "go.temporal.io/sdk/workflow" func WF(ctx workflow.Context) { workflow.ExecuteActivity(ctx, GetX()) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) e := edges[0] assert.Equal(t, "GetX", e.Meta["temporal_name"]) assert.Equal(t, "activity", e.Meta["temporal_kind"]) assert.Equal(t, "unresolved::temporal::activity::GetX", e.To) } func TestGoTemporal_ExecuteActivity_PackageCallExprName(t *testing.T) { fix := runGoExtract(t, `package wf import ( "go.temporal.io/sdk/workflow" "example.com/constants" ) func WF(ctx workflow.Context) { workflow.ExecuteActivity(ctx, constants.GetChargeActivityName()) } `) edges := temporalEdgesByVia(fix, "temporal.stub") require.Len(t, edges, 1) assert.Equal(t, "GetChargeActivityName", edges[0].Meta["temporal_name"]) }