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