From fef01ac808d760349a00dfa26ee061e0d1cbb323 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 15:24:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(temporal):=20P2=20=E2=80=94=20resolve=20un?= =?UTF-8?q?registered=20activities=20by=20func-name=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Activity repos hold the functions but register them from a separate worker-runner, so the register-based index never sees them. Add a convention fallback: index Go functions/methods named like activities/ workflows (suffix Activity/Workflow) and, when no registered handler matches a dispatch name (directly or via its const value), resolve to the unambiguous convention-named function — at the inferred tier (temporal_resolution_via=convention), since it's a naming convention not a register-confirmed binding. This is Pattern 2's two-part naming (ActivityFuncName const → the func) and Stage 1.2 generally (func names match dispatch strings 1:1). e2e: workflow dispatches via ActivityFuncName const -> unregistered GetProductOfferingActivity function. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 57 ++++++++++++++ internal/parser/languages/golang.go | 42 +++++++--- internal/resolver/temporal_calls.go | 107 +++++++++++++++++++++++++- 3 files changed, 193 insertions(+), 13 deletions(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 17be32fae..f8aa6690c 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -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") +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index c8908d620..13dfd948e 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -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, }) @@ -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 { diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 51f267701..f2791b81c 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -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 @@ -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 { @@ -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}) @@ -485,6 +516,15 @@ func temporalStubPlaceholder(kind, name string) string { type temporalIndex struct { // byKindName maps "::" → 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) { @@ -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 @@ -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.