From 5c524a24014771275b7d44ef2100feb2d43bfb34 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Sat, 13 Jun 2026 11:26:06 +0300 Subject: [PATCH] feat(temporal): resolve executor struct-field dispatch Resolve Temporal dispatch where the activity/workflow name is a receiver field whose literal is set at struct construction: type ActivityExecutor struct{ ActivityName string } func (e ActivityExecutor) Run(ctx workflow.Context) { workflow.ExecuteActivity(ctx, e.ActivityName) // name = recv field } exec := ActivityExecutor{ActivityName: "ChargeCard"} // literal here The dispatch sees `e.ActivityName` (not a literal) and the literal lives at the construction site; this joins the two by (receiver type, field). Extractor (golang.go / golang_temporal.go): - a `fieldstr` tree-sitter pattern collects `Type{Field: "literal"}` struct field assignments; emitted as `via=temporal.executor-field` marker edges carrying executor_type / executor_field / executor_value; - the dispatch stub is stamped `temporal_name_field` + `temporal_recv_type` when the dispatch arg is `.`; - helper `goCompositeLiteralType`; `recvTypeByID` map; `callNode` on the deferred-call struct so the calls loop can inspect the dispatch arg. Resolver (temporal_calls.go): - `resolveTemporalExecutorFields` joins dispatch (recv_type, field) to a construction literal and rewrites the method's stub name to that literal, so the existing sweep lands it on the registered handler and callers of the activity surface the dispatching method. Wired at the top of `ResolveTemporalCalls`; self-guards; recompute-safe; unique-or-nothing on conflicting construction literals. Cross-language / const-deref / env-default / wrapper-following untouched. Tests: resolver unit, extractor unit (field meta + marker), e2e. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 64 +++++++++++ internal/parser/languages/go_temporal_test.go | 35 ++++++ internal/parser/languages/golang.go | 82 ++++++++++++- internal/parser/languages/golang_temporal.go | 34 ++++++ internal/resolver/temporal_calls.go | 108 ++++++++++++++++++ internal/resolver/temporal_calls_test.go | 43 +++++++ 6 files changed, 365 insertions(+), 1 deletion(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 7380f625c..dd0d93c2e 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -576,3 +576,67 @@ func setupWorker(w Worker) { assert.Equal(t, activity[0].ID, stubCall.To, "the wrapper-following stub must resolve to the registered ChargeCard activity") } + +// TestTemporalE2E_GoExecutorFieldDispatch exercises the full pipeline for +// executor struct-field dispatch: a struct method that dispatches via a +// field, constructed with a string literal, must resolve through the +// real indexer to the registered activity. +func TestTemporalE2E_GoExecutorFieldDispatch(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "executor.go"), `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) +} +`) + writeFile(t, filepath.Join(dir, "activity.go"), `package wf + +import "context" + +func ChargeCard(ctx context.Context) error { return nil } +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setup(w Worker) { + w.RegisterActivity(ChargeCard) + _ = ActivityExecutor{ActivityName: "ChargeCard"} +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + // Find the Run method node. + runners := g.FindNodesByName("Run") + require.NotEmpty(t, runners, "Run method must be indexed") + var runNode *graph.Node + for _, n := range runners { + if n.Language == "go" { + runNode = n + break + } + } + require.NotNil(t, runNode) + + activity := g.FindNodesByName("ChargeCard") + require.NotEmpty(t, activity) + + // The stub from Run must resolve to ChargeCard. + var stubCall *graph.Edge + for _, e := range g.GetOutEdges(runNode.ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.stub" { + stubCall = e + break + } + } + require.NotNil(t, stubCall, "Run must have an outbound temporal.stub edge") + assert.Equal(t, activity[0].ID, stubCall.To, + "executor-field dispatch must resolve to ChargeCard") +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index e181ce1ad..c9a4e273b 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -783,3 +783,38 @@ func OrderWorkflow(ctx workflow.Context, id string) error { 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"]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 4fa7ba8a3..84fc1e0ad 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -110,6 +110,13 @@ const qGoAll = ` (literal_element (identifier) @fieldval.key) (literal_element (identifier) @fieldval.value)) @fieldval.elem + ; Struct field set to a string literal, e.g. ActivityName: "Charge". + ; The Temporal step/executor resolver joins these to a struct whose + ; method dispatches via that field. + (keyed_element + (literal_element (identifier) @fieldstr.key) + (literal_element (interpreted_string_literal) @fieldstr.val)) @fieldstr.elem + (keyed_element (literal_element (identifier) @fieldsel.key) (literal_element @@ -244,10 +251,25 @@ type goDeferredCall struct { // callNode is the call_expression AST node, retained so the general // call-edge emission can extract positional arg names // (attachGoTemporalCallArgNames) for the resolver's wrapper-following - // pass. Nil for synthetic / non-call entries. + // pass; the executor-field resolver also uses it to inspect the + // dispatch argument shape (e.g. `e.ActivityName` as a selector). + // Nil for synthetic / non-call entries. callNode *sitter.Node } +// PURPOSE — captures a struct-literal field assignment to a string literal +// so the Temporal executor-field resolver can join the construction site +// to a dispatch method that reads the same field. +// RATIONALE — separate from goDeferredCall because it fires on composite +// literals, not call expressions; the two passes share a single tree walk. +// KEYWORDS — temporal, executor, struct-field +type goExecutorField struct { + typeName string + field string + value string + line int +} + type goDeferredTypeRef struct { typeName string pkg string // optional qualifier @@ -336,6 +358,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // function and (when known) carries the receiver type for the // resolver to land on the right field node. var writes []goDeferredValueSel + var executorFields []goExecutorField parser.EachMatch(e.qAll, root, src, func(m parser.QueryResult) { switch { @@ -387,6 +410,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec), callNode: expr.Node, } + dc.callNode = expr.Node if svc, argNode, ok := grpcRegisterArgNode(expr.Node, method); ok { dc.grpcRegService, dc.grpcRegArgNode = svc, argNode } @@ -626,6 +650,21 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe line: elem.StartLine + 1, }) + case m.Captures["fieldstr.elem"] != nil: + elem := m.Captures["fieldstr.elem"] + if typeName := goCompositeLiteralType(elem.Node, src); typeName != "" { + val := m.Captures["fieldstr.val"].Text + if len(val) >= 2 && (val[0] == '"' || val[0] == '`') { + val = val[1 : len(val)-1] + } + executorFields = append(executorFields, goExecutorField{ + typeName: typeName, + field: m.Captures["fieldstr.key"].Text, + value: val, + line: elem.StartLine + 1, + }) + } + case m.Captures["assign.def"] != nil: def := m.Captures["assign.def"] writes = append(writes, goDeferredValueSel{ @@ -707,11 +746,15 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // (s.counter.Increment() / s.helper()) — the basis for indirect // receiver-field-mutation attribution. recvNameByID := map[string]string{} + recvTypeByID := map[string]string{} for _, n := range result.Nodes { if n.Kind == graph.KindMethod && n.Meta != nil { if rn, _ := n.Meta["recv_name"].(string); rn != "" { recvNameByID[n.ID] = rn } + if rt, _ := n.Meta["receiver"].(string); rt != "" { + recvTypeByID[n.ID] = rt + } } } @@ -805,6 +848,18 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe if c.tempEnvDefault { meta["temporal_name_origin"] = "env_default" } + if recvName := recvNameByID[callerID]; recvName != "" { + if arg := goTemporalDispatchArg(c.callNode); arg != nil && arg.Type() == "selector_expression" { + op := arg.ChildByFieldName("operand") + fld := arg.ChildByFieldName("field") + if op != nil && fld != nil && op.Content(src) == recvName { + meta["temporal_name_field"] = fld.Content(src) + if rt := recvTypeByID[callerID]; rt != "" { + meta["temporal_recv_type"] = rt + } + } + } + } edge := &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, @@ -1028,6 +1083,31 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe result.Edges = append(result.Edges, edge) } + // Temporal step/executor field: `ActivityExecutor{ActivityName: "X"}`. + // PURPOSE — emits a marker edge so the resolver can join the string + // literal at the construction site to the dispatch that reads this field. + // RATIONALE — separate pass because it fires on keyed_element nodes, not + // call expressions; the construction site and dispatch site may be in + // different functions or even files. + // KEYWORDS — temporal, executor-field, marker + for _, ef := range executorFields { + callerID := findEnclosingFunc(funcRanges, ef.line) + if callerID == "" { + callerID = filePath + } + result.Edges = append(result.Edges, &graph.Edge{ + From: callerID, + To: "unresolved::temporal-executor::" + ef.typeName + "::" + ef.field, + Kind: graph.EdgeCalls, FilePath: filePath, Line: ef.line, + Meta: map[string]any{ + "via": "temporal.executor-field", + "executor_type": ef.typeName, + "executor_field": ef.field, + "executor_value": ef.value, + }, + }) + } + // Assignment / inc / dec selector LHS — EdgeWrites from the // enclosing function to the assigned field. Same resolution path // as the value-side selectors: the resolver lands on the field diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 14d70a980..a6e569f64 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -951,3 +951,37 @@ func attachGoTemporalCallArgNames(edge *graph.Edge, c goDeferredCall, callNode * edge.Meta["callee"] = c.callName } } + +// goCompositeLiteralType walks up from a keyed_element node to find the +// enclosing composite_literal and returns the simple type name. +// +// PURPOSE — extracts the receiver struct type from a struct literal so the +// executor-field pass can key the field assignment by (type, field). +// RATIONALE — tree-sitter does not expose a direct parent-of-kind API; +// walking the Parent chain is the standard idiom in this codebase. +// KEYWORDS — composite-literal, type-name, executor-field +func goCompositeLiteralType(keyed *sitter.Node, src []byte) string { + for n := keyed; n != nil; n = n.Parent() { + if n.Type() != "composite_literal" { + continue + } + t := n.ChildByFieldName("type") + if t == nil { + return "" + } + switch t.Type() { + case "type_identifier": + return t.Content(src) + case "pointer_type": + if inner := t.NamedChild(0); inner != nil && inner.Type() == "type_identifier" { + return inner.Content(src) + } + case "qualified_type": + if f := t.ChildByFieldName("name"); f != nil { + return f.Content(src) + } + } + return "" + } + return "" +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index c27b29aff..628d6c72a 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -290,6 +290,11 @@ func ResolveTemporalCalls(g graph.Store) int { // are picked up and resolved by the existing loop below. resolveTemporalWrapperCalls(g) + // Executor-field pre-pass: rewrite struct-field dispatch stubs to the + // literal name supplied at the executor's construction site. Also runs + // before the sweep so the rewritten stubs resolve below. + resolveTemporalExecutorFields(g) + // Single sweep over EdgeCalls — the largest edge class — collecting // both the temporal.register edges (index inputs) and the // temporal.stub edges (edges to resolve), instead of scanning it once @@ -1206,3 +1211,106 @@ func methodsOfJavaTypeFromIndex(t *graph.Node, methodsByReceiver map[string][]*g } return out } + +// resolveTemporalExecutorFields rewrites the dispatch name of a method +// stub that reads a receiver field to the string literal the struct was +// constructed with at its (possibly remote) construction site. +// +// PURPOSE — when a struct method reads a field to dispatch an activity/workflow +// (e.g. `workflow.ExecuteActivity(ctx, e.ActivityName)`) and the struct was +// constructed with a string literal for that field +// (`ActivityExecutor{ActivityName: "ChargeCard"}`), this pass rewrites the +// method stub's `temporal_name` from the field name to that literal, so the +// main resolver sweep lands it on the registered handler. The dispatch happens +// IN the method, so the call edge stays anchored to the method (get_callers on +// the activity surfaces the dispatching method, not the construction site). +// RATIONALE — two-edge join: the method-stub edge carries (recvType, field) +// from the dispatch site; the executor-field marker edge carries +// (type, field, value) from the construction site. The join key is +// `recvType::fieldName`. The rewrite is re-derived from the marker edges on +// every pass (never relying on the prior pass's mutation surviving), so it is +// recompute-safe under the full-recompute contract of ResolveTemporalCalls: +// the parser re-emits the stub with `temporal_name=` on reindex, and +// this pass re-applies the literal before the main sweep runs. A +// recvType::field with conflicting construction-site literals is left +// unresolved — same unique-or-nothing policy as the const-deref join. +// KEYWORDS — temporal, executor-field, resolver +func resolveTemporalExecutorFields(g graph.Store) { + // Phase 1: collect the method-stub edges that read a receiver field, + // grouped by `recvType::field`. + type dispatch struct { + stubs []*graph.Edge + } + byField := map[string]*dispatch{} + for e := range g.EdgesByKind(graph.EdgeCalls) { + if e == nil || e.Meta == nil { + continue + } + if v, _ := e.Meta["via"].(string); v != "temporal.stub" { + continue + } + field, _ := e.Meta["temporal_name_field"].(string) + rtype, _ := e.Meta["temporal_recv_type"].(string) + kind, _ := e.Meta["temporal_kind"].(string) + if field == "" || rtype == "" || kind == "" { + continue + } + key := rtype + "::" + field + d := byField[key] + if d == nil { + d = &dispatch{} + byField[key] = d + } + d.stubs = append(d.stubs, e) + } + if len(byField) == 0 { + return + } + + // Phase 2: for each executor-field marker edge, collect the literal + // construction value per `recvType::field`. A key with conflicting + // values across construction sites is ambiguous and dropped. + valByField := map[string]string{} + ambiguous := map[string]struct{}{} + for e := range g.EdgesByKind(graph.EdgeCalls) { + if e == nil || e.Meta == nil || e.From == "" { + continue + } + if v, _ := e.Meta["via"].(string); v != "temporal.executor-field" { + continue + } + rtype, _ := e.Meta["executor_type"].(string) + field, _ := e.Meta["executor_field"].(string) + value, _ := e.Meta["executor_value"].(string) + if rtype == "" || field == "" || value == "" { + continue + } + key := rtype + "::" + field + if _, ok := byField[key]; !ok { + continue + } + if existing, seen := valByField[key]; seen && existing != value { + ambiguous[key] = struct{}{} + continue + } + valByField[key] = value + } + for key := range ambiguous { + delete(valByField, key) + } + + // Phase 3: rewrite each matched method stub's dispatch name to the + // construction literal. e.To is left for the main sweep to recompute + // from the new temporal_name; temporal_name_field / temporal_recv_type + // are preserved as the join key for the next full-recompute pass. + for key, value := range valByField { + d := byField[key] + if d == nil { + continue + } + for _, e := range d.stubs { + e.Meta["temporal_name"] = value + e.Meta["temporal_via_executor"] = true + } + } +} diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 0bceaf499..04b1a436a 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -620,3 +620,46 @@ func TestResolveTemporalCalls_RegisterNameOverride(t *testing.T) { "the impl is known under the registered (override) name") assert.Equal(t, "activity", impl.Meta["temporal_role"]) } + +func TestResolveTemporalCalls_ExecutorFieldDispatch(t *testing.T) { + b := newTemporalTestGraph() + // Method stub edge with temporal_name_field + temporal_recv_type. + b.addGoFunc("wf/executor.go::ActivityExecutor.Run", "Run", "wf/executor.go", "svc") + methodStub := &graph.Edge{ + From: "wf/executor.go::ActivityExecutor.Run", + To: temporalStubPlaceholder("activity", "ActivityName"), + Kind: graph.EdgeCalls, FilePath: "wf/executor.go", Line: 10, + Meta: map[string]any{ + "via": "temporal.stub", + "temporal_kind": "activity", + "temporal_name": "ActivityName", + "temporal_name_field": "ActivityName", + "temporal_recv_type": "ActivityExecutor", + }, + } + b.g.AddEdge(methodStub) + + // Executor-field marker edge: construction site. + b.addGoFunc("wf/main.go::setup", "setup", "wf/main.go", "svc") + markerEdge := &graph.Edge{ + From: "wf/main.go::setup", + To: "unresolved::temporal-executor::ActivityExecutor::ActivityName", + Kind: graph.EdgeCalls, FilePath: "wf/main.go", Line: 5, + Meta: map[string]any{ + "via": "temporal.executor-field", + "executor_type": "ActivityExecutor", + "executor_field": "ActivityName", + "executor_value": "ChargeCard", + }, + } + b.g.AddEdge(markerEdge) + + // Registered activity. + activity := b.addGoFunc("wf/activity.go::ChargeCard", "ChargeCard", "wf/activity.go", "svc") + b.addGoRegister("wf/main.go::setup", "activity", "ChargeCard", "wf/main.go") + + resolved := ResolveTemporalCalls(b.g) + assert.GreaterOrEqual(t, resolved, 1, "ChargeCard must be resolved") + assert.Equal(t, activity.ID, methodStub.To, + "the method stub must be rewritten to the registered activity") +}