Skip to content
Merged
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
57 changes: 57 additions & 0 deletions internal/indexer/temporal_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -708,3 +708,60 @@ func setupWorker(w Worker) {
"func-returning-constant dispatch must resolve to ChargeActivity")
assert.Equal(t, graph.OriginASTResolved, stubCall.Origin)
}

// TestTemporalE2E_GoUnregisteredActivityByConvention exercises Pattern 2 /
// Stage 1.2: the activity function lives here but is registered by a
// separate worker-runner (no RegisterActivity in this workspace). The
// dispatch names it through a two-part const (ActivityFuncName), and the
// resolver must fall back to the function-name convention.
func TestTemporalE2E_GoUnregisteredActivityByConvention(t *testing.T) {
dir := t.TempDir()

writeFile(t, filepath.Join(dir, "activity.go"), `package wf

import "context"

// Registered elsewhere (a separate worker-runner); no RegisterActivity here.
func GetProductOfferingActivity(ctx context.Context) error { return nil }
`)
writeFile(t, filepath.Join(dir, "constants.go"), `package wf

const (
ActivityPackageName = "browse-product-catalog-activities"
ActivityFuncName = "GetProductOfferingActivity"
)
`)
writeFile(t, filepath.Join(dir, "workflow.go"), `package wf

import "go.temporal.io/sdk/workflow"

func BrowseWorkflow(ctx workflow.Context) error {
return workflow.ExecuteActivity(ctx, ActivityFuncName).Get(ctx, nil)
}
`)
writeFile(t, filepath.Join(dir, "main.go"), `package wf

func setupWorker(w Worker) {
w.RegisterWorkflow(BrowseWorkflow)
}
`)

g := graph.New()
idx := newTestIndexer(g)
_, err := idx.Index(dir)
require.NoError(t, err)

wf := g.FindNodesByName("BrowseWorkflow")[0]
act := g.FindNodesByName("GetProductOfferingActivity")[0]

var stub *graph.Edge
for _, e := range g.GetOutEdges(wf.ID) {
if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.stub" {
stub = e
}
}
require.NotNil(t, stub)
assert.Equal(t, act.ID, stub.To, "unregistered activity must resolve by func-name convention")
assert.Equal(t, "convention", stub.Meta["temporal_resolution_via"])
assert.Equal(t, graph.OriginASTInferred, stub.Origin, "convention match is inferred-tier")
}
42 changes: 30 additions & 12 deletions internal/parser/languages/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -1914,7 +1914,7 @@ func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, s
// const-identifier dispatch name to its value across files. Computed
// constants (iota, expressions) carry no literal and are skipped.
if kind == graph.KindConstant {
if v, ok := goConstLiteralValue(def.Node, src); ok {
if v, ok := goConstLiteralValue(def.Node, name, src); ok {
result.ConstValues = append(result.ConstValues, parser.ConstValue{
NodeID: id, FilePath: filePath, Value: v,
})
Expand Down Expand Up @@ -1990,30 +1990,48 @@ func goFuncSingleReturnLiteral(declNode *sitter.Node, src []byte) (string, bool)
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
// ("", false) for computed / multi-value / non-literal specs.
func goConstLiteralValue(constSpec *sitter.Node, src []byte) (string, bool) {
// goConstLiteralValue extracts the literal value of a const_spec
// (`const X = "literal"` / `const X = 42`) from the spec's value field,
// when that value is a string or numeric literal. Returns ("", false)
// for computed / multi-value / non-literal specs.
//
// constSpec may be the const_spec itself or the enclosing
// const_declaration. For a grouped block (`const ( A = ...; B = ... )`)
// the declaration holds several specs; name selects the matching one so
// each member's value is captured independently (not just single-spec
// blocks). For a single-spec block name still selects correctly.
func goConstLiteralValue(constSpec *sitter.Node, name string, src []byte) (string, bool) {
if constSpec == nil {
return "", false
}
spec := constSpec
if spec.Type() != "const_spec" {
// def captures the const_declaration; descend to the lone spec.
var found *sitter.Node
// def captures the const_declaration; pick the spec whose name
// field matches the const being emitted (grouped blocks hold
// several), falling back to the lone spec when there is exactly
// one and no name match (defensive).
var found, only *sitter.Node
count := 0
for i := 0; i < int(spec.NamedChildCount()); i++ {
c := spec.NamedChild(i)
if c != nil && c.Type() == "const_spec" {
if c == nil || c.Type() != "const_spec" {
continue
}
count++
only = c
if nameNode := c.ChildByFieldName("name"); nameNode != nil && nameNode.Content(src) == name {
found = c
count++
break
}
}
if count != 1 || found == nil {
switch {
case found != nil:
spec = found
case count == 1 && only != nil:
spec = only
default:
return "", false
}
spec = found
}
valueList := spec.ChildByFieldName("value")
if valueList == nil || valueList.NamedChildCount() != 1 {
Expand Down
107 changes: 106 additions & 1 deletion internal/resolver/temporal_calls.go
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,31 @@ func ResolveTemporalCalls(g graph.Store) int {
}
}
}
// Convention fallback: dispatch to an activity/workflow FUNCTION
// by name when the worker registers it elsewhere (unregistered
// here) — Pattern 2 / Stage 1.2. Try the dispatch name, then its
// const-deref value. Landed at the inferred tier (name convention,
// not a register-confirmed binding). Tried before the cross-language
// join: a same-language convention match is a stronger signal than a
// speculative by-string match across a type-system boundary.
convention := false
if handlerID == "" {
candNames := []string{s.name}
if v, ok := derefByName[s.name]; ok && v != "" && v != s.name {
candNames = append(candNames, v)
}
for _, nm := range candNames {
if id := idx.lookupConvention(s.kind, nm, callerRepo, callerLang); id != "" {
handlerID, origin, conf = id, graph.OriginASTInferred, 0.6
convention = true
if nm != s.name {
constDeref = nm
}
break
}
}
}

// Cross-language join: a consumer (typically a temporal.start, e.g.
// a Java service starting a Go workflow) with no same-language
// handler is matched to a unique other-language candidate by
Expand Down Expand Up @@ -453,6 +478,11 @@ func ResolveTemporalCalls(g graph.Store) int {
} else {
delete(e.Meta, "temporal_const_deref")
}
if convention {
e.Meta["temporal_resolution_via"] = "convention"
} else {
delete(e.Meta, "temporal_resolution_via")
}
StampSynthesized(e, SynthTemporalStub)
resolved++
} else {
Expand All @@ -463,6 +493,7 @@ func ResolveTemporalCalls(g graph.Store) int {
delete(e.Meta, graph.MetaSpeculative)
delete(e.Meta, "temporal_const_deref")
delete(e.Meta, "temporal_cross_lang")
delete(e.Meta, "temporal_resolution_via")
UnstampSynthesized(e)
}
reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo})
Expand All @@ -485,6 +516,15 @@ func temporalStubPlaceholder(kind, name string) string {
type temporalIndex struct {
// byKindName maps "<kind>::<name>" → handler candidate nodes.
byKindName map[string][]*graph.Node
// funcByName indexes Go functions / methods whose name follows the
// activity / workflow naming convention (suffix "Activity" /
// "Workflow"), keyed by bare name. Used as a last-resort, lower-
// confidence resolution for dispatch to an UNREGISTERED activity —
// the common case where activity repos hold the functions but the
// `worker.Register*` calls live in a separate worker-runner (so the
// register-based byKindName index never sees them). Pattern 2's
// two-part name resolves here once F1/F2 reduce it to the func name.
funcByName map[string][]*graph.Node
}

func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id, origin string, confidence float64) {
Expand Down Expand Up @@ -529,6 +569,47 @@ func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id,
return "", "", 0
}

// lookupConvention resolves a dispatch name to a convention-named Go
// function (suffix "Activity" / "Workflow" matching the kind) when no
// registered handler matched — the unregistered-activity case (Pattern 2
// / Stage 1.2). Returns "" when there's no unambiguous candidate. The
// caller stamps this at a lower (inferred) confidence than a
// register-confirmed match.
//
// callerLang mirrors the same-language gate idx.lookup applies: a Go
// workflow.ExecuteActivity dispatch resolves only to a Go function, never
// to a like-named symbol in another language.
func (idx *temporalIndex) lookupConvention(kind, name, callerRepo, callerLang string) string {
cands := idx.funcByName[name]
if len(cands) == 0 {
return ""
}
suffix := "Activity"
if kind == "workflow" {
suffix = "Workflow"
}
var filtered, sameRepo []*graph.Node
for _, n := range cands {
if !strings.HasSuffix(n.Name, suffix) {
continue
}
if callerLang != "" && n.Language != callerLang {
continue
}
filtered = append(filtered, n)
if callerRepo != "" && n.RepoPrefix == callerRepo {
sameRepo = append(sameRepo, n)
}
}
if len(sameRepo) == 1 {
return sameRepo[0].ID
}
if len(filtered) == 1 {
return filtered[0].ID
}
return ""
}

// lookupCrossLang is the cross-language fallback for a Temporal consumer
// whose same-language lookup found no handler: it matches a candidate in a
// DIFFERENT language by canonical name (e.g. a Java service that starts a
Expand Down Expand Up @@ -566,7 +647,31 @@ func (idx *temporalIndex) lookupCrossLang(kind, name, callerLang string) (id str
// avoids re-scanning the (largest) EdgeCalls class and the EdgeAnnotated
// class a second time.
func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Edge) *temporalIndex {
idx := &temporalIndex{byKindName: map[string][]*graph.Node{}}
idx := &temporalIndex{byKindName: map[string][]*graph.Node{}, funcByName: map[string][]*graph.Node{}}

// Convention index: Go functions / methods named like activities or
// workflows (suffix "Activity" / "Workflow"), for resolving dispatch
// to functions the worker-runner registers elsewhere (unregistered
// here). Bounded to the convention-named set to keep it small. Consumed
// by lookupConvention as a last-resort fallback after the register- and
// const-deref-based byKindName lookups miss.
indexConventionFunc := func(n *graph.Node) {
if n == nil || n.Language != "go" {
return
}
if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod {
return
}
if strings.HasSuffix(n.Name, "Activity") || strings.HasSuffix(n.Name, "Workflow") {
idx.funcByName[n.Name] = append(idx.funcByName[n.Name], n)
}
}
for n := range g.NodesByKind(graph.KindFunction) {
indexConventionFunc(n)
}
for n := range g.NodesByKind(graph.KindMethod) {
indexConventionFunc(n)
}

// Phase 1 — Go side. Walk the pre-collected `temporal.register` edges
// and stamp the registered function's node.
Expand Down
Loading