Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/indexer/temporal_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
72 changes: 72 additions & 0 deletions internal/parser/languages/go_temporal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
}
80 changes: 80 additions & 0 deletions internal/parser/languages/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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 "<literal>"` 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
Expand Down
8 changes: 8 additions & 0 deletions internal/parser/languages/golang_temporal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}
Expand Down
2 changes: 1 addition & 1 deletion internal/resolver/temporal_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 40 additions & 0 deletions internal/resolver/temporal_calls_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading