diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 00f49d9d..814f2686 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,65 @@ 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") +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..77efc5c5 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,100 @@ func setup(w Worker) { 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) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..7bca3c17 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 + // 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 { @@ -334,10 +340,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: @@ -650,6 +666,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 019aeec0..8bfe7e0c 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,27 +79,24 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } -// 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 { +// goTemporalDispatchArg returns the second positional argument node of a +// 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 "" + return nil } args := callNode.ChildByFieldName("arguments") if args == nil { - return "" + return nil } count := 0 for i := 0; i < int(args.NamedChildCount()); i++ { @@ -109,16 +106,16 @@ 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 // `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 "" @@ -200,3 +197,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")