diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index c8424573..5ec6e7e4 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -509,3 +509,71 @@ func setup(w Worker) { assert.Equal(t, true, start.Meta["temporal_cross_lang"]) assert.Equal(t, graph.OriginSpeculative, start.Origin) } + +// TestTemporalE2E_GoFuncReturningConstantDispatch exercises the full pipeline +// for G2: a func that returns a string literal used as Temporal dispatch arg. +// +// func GetChargeActivityName() string { return "ChargeActivity" } +// workflow.ExecuteActivity(ctx, constants.GetChargeActivityName()) +// +// After indexing, the stub edge must point at the real ChargeActivity node. +func TestTemporalE2E_GoFuncReturningConstantDispatch(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "constants.go"), `package wf + +func GetChargeActivityName() string { return "ChargeActivity" } +`) + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + return workflow.ExecuteActivity(ctx, GetChargeActivityName()).Get(ctx, nil) +} +`) + writeFile(t, filepath.Join(dir, "activity.go"), `package wf + +import "context" + +func ChargeActivity(ctx context.Context) error { + return nil +} +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setupWorker(w Worker) { + w.RegisterWorkflow(OrderWorkflow) + w.RegisterActivity(ChargeActivity) +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + // ChargeActivity must be stamped as a registered activity + actNodes := g.FindNodesByName("ChargeActivity") + require.Len(t, actNodes, 1) + activity := actNodes[0] + assert.Equal(t, "activity", activity.Meta["temporal_role"]) + assert.Equal(t, "ChargeActivity", activity.Meta["temporal_name"]) + + // OrderWorkflow must have its stub edge resolved to ChargeActivity + wfNodes := g.FindNodesByName("OrderWorkflow") + require.Len(t, wfNodes, 1) + wf := wfNodes[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, "OrderWorkflow must have an outbound temporal.stub edge") + assert.Equal(t, activity.ID, stubCall.To, + "func-returning-constant dispatch must resolve to ChargeActivity") + assert.Equal(t, graph.OriginASTResolved, stubCall.Origin) +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index fb358201..36bbf5c8 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -7,6 +7,7 @@ import ( "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 @@ -724,3 +725,74 @@ 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_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"]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a19020a9..81a68009 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -1060,6 +1060,12 @@ func (e *GoExtractor) emitFunction(m parser.QueryResult, filePath, fileID string } scanGoPragmas(src, def.StartLine, node) result.Nodes = append(result.Nodes, node) + // Record func-returning-literal into ConstValues sidecar for const-deref dispatch. + if v, ok := goFuncSingleReturnLiteral(def.Node, src); ok { + result.ConstValues = append(result.ConstValues, parser.ConstValue{ + NodeID: id, FilePath: filePath, Value: v, + }) + } result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) @@ -1161,6 +1167,12 @@ func (e *GoExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, } scanGoPragmas(src, def.StartLine, node) result.Nodes = append(result.Nodes, node) + // Record method-returning-literal into ConstValues sidecar for const-deref dispatch. + if v, ok := goFuncSingleReturnLiteral(def.Node, src); ok { + result.ConstValues = append(result.ConstValues, parser.ConstValue{ + NodeID: id, FilePath: filePath, Value: v, + }) + } result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) @@ -1784,6 +1796,74 @@ func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, s } } +// PURPOSE — reports the string literal a function body returns when the body +// is exactly one `return ""` statement. Used by the const-value +// sidecar to record func-returning-constant nodes for Temporal dispatch +// resolution without adding a new resolver pass. +// RATIONALE — mirrors goConstLiteralValue for the function case. +// KEYWORDS — temporal, const-deref, single-return-literal +func goFuncSingleReturnLiteral(declNode *sitter.Node, src []byte) (string, bool) { + if declNode == nil { + return "", false + } + body := declNode.ChildByFieldName("body") + if body == nil || body.Type() != "block" { + return "", false + } + // The Go grammar wraps a block's statements in a `statement_list` node; + // descend into it when present so the single-statement scan sees the + // real statements rather than the wrapper. + stmtParent := body + if body.NamedChildCount() == 1 { + if only := body.NamedChild(0); only != nil && only.Type() == "statement_list" { + stmtParent = only + } + } + var ret *sitter.Node + stmts := 0 + for i := 0; i < int(stmtParent.NamedChildCount()); i++ { + c := stmtParent.NamedChild(i) + if c == nil || c.Type() == "comment" { + continue + } + stmts++ + if c.Type() == "return_statement" { + ret = c + } + } + if stmts != 1 || ret == nil { + return "", false + } + // return_statement -> expression_list -> single string literal + var expr *sitter.Node + for i := 0; i < int(ret.NamedChildCount()); i++ { + n := ret.NamedChild(i) + if n == nil { + continue + } + if n.Type() == "expression_list" { + if n.NamedChildCount() != 1 { + return "", false + } + expr = n.NamedChild(0) + } else { + expr = n + } + } + if expr == nil { + return "", false + } + switch expr.Type() { + case "interpreted_string_literal", "raw_string_literal": + t := expr.Content(src) + if len(t) >= 2 && (t[0] == '"' || t[0] == '`') { + return t[1 : len(t)-1], true + } + return t, true + } + return "", false +} + // goConstLiteralValue extracts the literal value of a single-spec // const_spec (`const X = "literal"` / `const X = 42`) from the spec's // value field, when that value is a string or numeric literal. Returns diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 18be4216..ec94f375 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -662,6 +662,14 @@ func goTemporalNameFromExpr(node *sitter.Node, src []byte) string { if op := node.ChildByFieldName("operand"); op != nil { return goTemporalNameFromExpr(op, src) } + case "call_expression": + // `GetX()` / `pkg.GetX()` — the dispatch name is the called func's + // trailing identifier; the resolver derefs it to the func's return + // literal via the const-deref map. Recurse into the `function` + // child: a selector resolves to "GetX", a bare identifier to "GetX". + if fn := node.ChildByFieldName("function"); fn != nil { + return goTemporalNameFromExpr(fn, src) + } } return "" } diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 7bdf003f..bd4dc6bd 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -912,7 +912,7 @@ func buildConstDerefMap(g graph.Store, names []string) map[string]string { var constIDs []string for name, cands := range candByName { for _, n := range cands { - if n == nil || n.Kind != graph.KindConstant { + if n == nil || (n.Kind != graph.KindConstant && n.Kind != graph.KindFunction && n.Kind != graph.KindMethod) { continue } constIDs = append(constIDs, n.ID) diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 520d6add..b37ec3c5 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -542,3 +542,43 @@ 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_FuncReturningConstantDeref(t *testing.T) { + // PURPOSE — a stub edge with temporal_name="GetChargeActivityName" must + // resolve to the ChargeActivity handler when the func node has a sidecar + // value "ChargeActivity" in the const-deref map. + b := newTemporalTestGraph() + + // The workflow function that dispatches via GetChargeActivityName() + b.addGoFunc("wf/workflow.go::OrderWorkflow", "OrderWorkflow", "wf/workflow.go", "svc") + call := b.addStubCall("wf/workflow.go::OrderWorkflow", "activity", "GetChargeActivityName", "wf/workflow.go") + + // The func-returning-literal: GetChargeActivityName is a KindFunction node + // whose ConstValue sidecar says "ChargeActivity" + getNameFunc := &graph.Node{ + ID: "wf/constants.go::GetChargeActivityName", + Kind: graph.KindFunction, + Name: "GetChargeActivityName", + FilePath: "wf/constants.go", + Language: "go", + } + b.g.AddNode(getNameFunc) + + // Inject its sidecar value via BulkSetConstantValues + writer, ok := b.g.(graph.ConstantValueWriter) + require.True(t, ok, "graph.New() must implement ConstantValueWriter") + require.NoError(t, writer.BulkSetConstantValues("svc", []graph.ConstantValueRow{ + {NodeID: getNameFunc.ID, FilePath: getNameFunc.FilePath, Value: "ChargeActivity"}, + })) + + // The registered ChargeActivity handler + activity := b.addGoFunc("wf/activity.go::ChargeActivity", "ChargeActivity", "wf/activity.go", "svc") + b.addGoFunc("wf/main.go::setup", "setup", "wf/main.go", "svc") + b.addGoRegister("wf/main.go::setup", "activity", "ChargeActivity", "wf/main.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 1, resolved) + assert.Equal(t, activity.ID, call.To, + "func-returning-constant dispatch must land on the registered ChargeActivity") + assert.Equal(t, graph.OriginASTResolved, call.Origin) +}