From de36f953f45d7491bb5ed3fa5658a1a1bfe3ef5e 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 | 105 ++++++++++++++++++ internal/resolver/temporal_calls_test.go | 43 +++++++ 6 files changed, 363 insertions(+) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index c8424573..86086125 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -509,3 +509,67 @@ func setup(w Worker) { assert.Equal(t, true, start.Meta["temporal_cross_lang"]) assert.Equal(t, graph.OriginSpeculative, start.Origin) } + +// 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 fb358201..c47ef843 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -724,3 +724,38 @@ func executeActivity(ctx workflow.Context, name string, args ...any) error { assert.Equal(t, "activity", wrapper.Meta["temporal_wrapper_kind"]) assert.Equal(t, "name", wrapper.Meta["temporal_wrapper_param"]) } + +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 97a1a00a..e51fcd64 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 @@ -241,6 +248,23 @@ type goDeferredCall struct { // meta is stamped on the emitted edge and the resolver rewrites it to // the registered workflow — the "who starts this workflow" edge. tempStartName string + // callNode is the call_expression AST node for this deferred call. + // Used by the temporal executor-field resolver to inspect the + // dispatch argument shape (e.g. `e.ActivityName` as a selector). + 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 { @@ -331,6 +355,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 { @@ -380,6 +405,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe spawn: isGoroutineSpawn(expr.Node), returnUsage: classifyReturnUsage(expr.Node, src, goReturnUsageSpec), } + dc.callNode = expr.Node if svc, argNode, ok := grpcRegisterArgNode(expr.Node, method); ok { dc.grpcRegService, dc.grpcRegArgNode = svc, argNode } @@ -619,6 +645,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{ @@ -700,11 +741,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 + } } } @@ -776,6 +821,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, @@ -996,6 +1053,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 18be4216..6a9f3022 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -878,3 +878,37 @@ func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) { } return "", false } + +// 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 7bdf003f..dbfeaca8 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -103,6 +103,8 @@ func ResolveTemporalCalls(g graph.Store) int { mu.Lock() defer mu.Unlock() + 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 @@ -1019,3 +1021,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 520d6add..560a4280 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -542,3 +542,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") +}