Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

1086 lines
35 KiB
Go

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"])
}