From 9198ded9c3c128e6e2d67197d9b0eee6a9e99b00 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 01:37:11 +0300 Subject: [PATCH 01/18] feat(temporal): detect Go in-workflow query/signal/update handler declarations Surface workflow.SetQueryHandler / GetSignalChannel / SetUpdateHandler[WithOptions] calls as via=temporal.handler EdgeCalls edges carrying temporal_kind (query/signal/update) + temporal_name, originating from the enclosing workflow function. This mirrors the Java side's per-method @QueryMethod / @SignalMethod / @UpdateMethod annotation edges, giving the graph a symmetric, queryable record of the named handlers each Go workflow exposes. High-precision: only the canonical "workflow" receiver alias is matched and the handler name must be a string literal (runtime-matched names can't be pinned from a variable), consistent with the existing dispatch detector. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 34 ++++++ internal/parser/languages/go_temporal_test.go | 110 ++++++++++++++++++ internal/parser/languages/golang.go | 15 +++ internal/parser/languages/golang_temporal.go | 84 +++++++++++++ 4 files changed, 243 insertions(+) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 00f49d9d..d8cfb769 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,37 @@ func setup(w Worker) { assert.Equal(t, child.ID, stubCall.To) assert.Equal(t, "workflow", stubCall.Meta["temporal_kind"]) } + +// TestTemporalE2E_GoQueryHandler exercises in-workflow handler detection: +// a workflow.SetQueryHandler call must surface as a via=temporal.handler +// edge from the enclosing workflow carrying its kind + name. +func TestTemporalE2E_GoQueryHandler(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func StatusWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + wf := g.FindNodesByName("StatusWorkflow")[0] + var handler *graph.Edge + for _, e := range g.GetOutEdges(wf.ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.handler" { + handler = e + break + } + } + require.NotNil(t, handler, "workflow must have an outbound temporal.handler edge") + assert.Equal(t, "query", handler.Meta["temporal_kind"]) + assert.Equal(t, "status", handler.Meta["temporal_name"]) +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..54f771f8 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,113 @@ func setup(w Worker) { require.Len(t, stubs, 1) require.Len(t, registers, 2) } + +// --- In-workflow handler declarations (query / signal / update) ----- +// +// These mirror the Java SDK's @QueryMethod / @SignalMethod / +// @UpdateMethod annotations: from inside a workflow body the Go SDK +// declares the named query / signal / update channels the workflow +// serves. We surface each as a `via=temporal.handler` EdgeCalls edge +// carrying temporal_kind + temporal_name so the graph records, per +// workflow, the named handlers it exposes — symmetric with the Java +// side's per-method annotation edges. + +func TestGoTemporal_SetQueryHandler(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + e := edges[0] + assert.Equal(t, "query", e.Meta["temporal_kind"]) + assert.Equal(t, "status", e.Meta["temporal_name"]) + assert.Equal(t, "pkg/foo.go::OrderWorkflow", e.From, + "handler edge must originate from the enclosing workflow function") +} + +func TestGoTemporal_GetSignalChannel(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + ch := workflow.GetSignalChannel(ctx, "cancel") + _ = ch + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SetUpdateHandler(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetUpdateHandler(ctx, "retry", func() error { return nil }) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "update", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "retry", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SetUpdateHandlerWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetUpdateHandlerWithOptions(ctx, "retry", func() error { return nil }, workflow.UpdateHandlerOptions{}) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "update", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "retry", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_HandlerNonLiteralNameUndetected(t *testing.T) { + // Query / signal / update names are matched by string at runtime; + // a non-literal name (variable / selector) can't be pinned here, so + // no handler edge is emitted — high-precision, no guessing. + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, q string) error { + workflow.SetQueryHandler(ctx, q, func() (string, error) { return "ok", nil }) + return nil +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler"), + "non-literal handler name must not be detected") +} + +func TestGoTemporal_HandlerAliasedImportNotDetected(t *testing.T) { + // Consistent with the dispatch detector: only the canonical + // "workflow" receiver alias is recognised. + fix := runGoExtract(t, `package wf + +import wf "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx wf.Context) error { + wf.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + return nil +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler")) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..c86103c2 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -201,6 +201,12 @@ type goDeferredCall struct { tempKind string tempName string tempLocal bool + // tempHandlerKind is "query" / "signal" / "update" when this call + // is a `workflow.SetQueryHandler` / `GetSignalChannel` / + // `SetUpdateHandler` in-workflow handler declaration; tempName then + // carries the handler's string name. `via=temporal.handler` meta is + // stamped on the emitted edge in the call post-pass below. + tempHandlerKind string } type goDeferredTypeRef struct { @@ -346,6 +352,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempKind = "register_" + kind dc.tempName = name } + } else if hkind, ok := goTemporalHandlerKind(receiver, method); ok { + // Temporal in-workflow handler declaration: + // `workflow.SetQueryHandler(ctx, "name", fn)` etc. + if name := goTemporalHandlerName(expr.Node, src); name != "" { + dc.tempHandlerKind = hkind + dc.tempName = name + } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { @@ -668,6 +681,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalHandlerMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -680,6 +694,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalHandlerMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 019aeec0..056092dc 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,6 +79,68 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } +// goTemporalHandlerKind reports whether (receiver, method) names one of +// the Temporal in-workflow handler-declaration helpers and, if so, +// returns the canonical kind ("query" / "signal" / "update"). +// +// workflow.SetQueryHandler(ctx, "name", fn) +// workflow.GetSignalChannel(ctx, "name") +// workflow.SetUpdateHandler(ctx, "name", fn) +// workflow.SetUpdateHandlerWithOptions(ctx, "name", fn, opts) +// +// These mirror the Java SDK's `@QueryMethod` / `@SignalMethod` / +// `@UpdateMethod` annotations: a workflow declares, from inside its +// body, the named query / signal / update channels it serves. As with +// the dispatch helpers we require the receiver text to be exactly the +// canonical "workflow" alias. +func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) { + if receiver != "workflow" { + return "", false + } + switch method { + case "SetQueryHandler": + return "query", true + case "GetSignalChannel": + return "signal", true + case "SetUpdateHandler", "SetUpdateHandlerWithOptions": + return "update", true + } + return "", false +} + +// goTemporalHandlerName extracts the query / signal / update name from a +// handler-declaration call — the second positional argument (after the +// workflow.Context). Unlike dispatch names we accept ONLY a string +// literal: handler names are matched by string at runtime, so a +// non-literal (variable / selector) can't be pinned to a name here and +// is left undetected, keeping the detector high-precision. Returns "" +// when the second argument is missing or is not a string literal. +func goTemporalHandlerName(callNode *sitter.Node, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == 2 { + switch c.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(c, src) + } + return "" + } + } + return "" +} + // goTemporalDispatchName extracts the activity (or child-workflow) // name from a `workflow.ExecuteActivity(ctx, X, args...)` call. X is // the second positional argument and is either: @@ -168,6 +230,28 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// applyGoTemporalHandlerMeta stamps `via=temporal.handler` plus +// `temporal_kind` (query / signal / update) and `temporal_name` (the +// handler's string name) onto the EdgeCalls edge derived from a +// `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler` +// call. No-op when c.tempHandlerKind / c.tempName are unset. +// +// The edge originates from the enclosing workflow function, so the +// graph records — per workflow — the named query / signal / update +// handlers it exposes, symmetric with the Java side's per-method +// `@QueryMethod` / `@SignalMethod` / `@UpdateMethod` annotation edges. +func applyGoTemporalHandlerMeta(edge *graph.Edge, c goDeferredCall) { + if edge == nil || c.tempHandlerKind == "" || c.tempName == "" { + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["via"] = "temporal.handler" + edge.Meta["temporal_kind"] = c.tempHandlerKind + edge.Meta["temporal_name"] = c.tempName +} + // goTemporalNameFromExpr reduces a single argument expression to the // trailing identifier that names the activity / workflow. Handles // string literals (`"MyActivity"` and the Go raw-string variant), From 2fc888f407b9196674941181ebce9c2537185fa2 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 08:47:08 +0300 Subject: [PATCH 02/18] feat(temporal): resolve activity/workflow names from env-var-with-default vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a workflow names its activity/child-workflow through a local variable read from an env var with a literal fallback, resolve the dispatch to that literal default instead of leaving it unresolved: actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") workflow.ExecuteActivity(ctx, actName, id) // -> activity "ChargeCard" name := os.Getenv("K"); if name == "" { name = "ChargeCard" } The parser does a narrow, intra-procedural lookup anchored on a literal os.Getenv / os.LookupEnv read (so the value is provably env-sourced, not a general data-flow guess) and tags the stub edge temporal_name_origin= env_default. The resolver then lands the edge at the speculative tier (OriginSpeculative, confidence 0.4, MetaSpeculative=true) rather than ast_resolved — the runtime env override may name a different handler than the literal default, so the edge is present but hidden from default queries. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/indexer/temporal_e2e_test.go | 62 ++++++ internal/parser/languages/go_temporal_test.go | 97 +++++++++ internal/parser/languages/golang.go | 21 +- internal/parser/languages/golang_temporal.go | 202 ++++++++++++++++-- internal/resolver/temporal_calls.go | 24 +++ internal/resolver/temporal_calls_test.go | 39 ++++ 6 files changed, 424 insertions(+), 21 deletions(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 00f49d9d..814f2686 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,65 @@ func setup(w Worker) { assert.Equal(t, child.ID, stubCall.To) assert.Equal(t, "workflow", stubCall.Meta["temporal_kind"]) } + +// TestTemporalE2E_GoEnvDefaultActivity exercises the env-var-with-literal +// -default dispatch name: the workflow names its activity through a +// variable read from os.Getenv with a literal fallback. The pipeline must +// land the call on the default activity but at the speculative tier. +func TestTemporalE2E_GoEnvDefaultActivity(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import ( + "cmp" + "os" + + "go.temporal.io/sdk/workflow" +) + +func OrderWorkflow(ctx workflow.Context, id string) error { + actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") + return workflow.ExecuteActivity(ctx, actName, id).Get(ctx, nil) +} +`) + writeFile(t, filepath.Join(dir, "activity.go"), `package wf + +import "context" + +func ChargeCard(ctx context.Context, id string) error { + return nil +} +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setupWorker(w Worker) { + w.RegisterWorkflow(OrderWorkflow) + w.RegisterActivity(ChargeCard) +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + wf := g.FindNodesByName("OrderWorkflow")[0] + activity := g.FindNodesByName("ChargeCard")[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, "workflow must have an outbound temporal.stub edge") + assert.Equal(t, activity.ID, stubCall.To, + "env-default dispatch must land on the default activity") + assert.Equal(t, "env_default", stubCall.Meta["temporal_name_origin"]) + assert.Equal(t, graph.OriginSpeculative, stubCall.Origin, + "env-default resolution must be speculative") + assert.Equal(t, true, stubCall.Meta[graph.MetaSpeculative], + "env-default edge must be hidden-by-default") +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..77efc5c5 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,100 @@ func setup(w Worker) { require.Len(t, stubs, 1) require.Len(t, registers, 2) } + +// --- Dispatch name from an env-var-with-literal-default variable ----- +// +// When the activity / workflow name is a local variable read from an +// env var with a literal fallback, resolve to the literal default and +// flag the stub edge `temporal_name_origin=env_default` so the resolver +// lands it at the speculative tier (the runtime env override may differ +// from the default). Anchored on a literal os.Getenv / os.LookupEnv read +// so the value is provably env-sourced — no general data-flow guessing. + +func TestGoTemporal_ExecuteActivity_EnvDefault_CmpOr(t *testing.T) { + fix := runGoExtract(t, `package wf + +import ( + "cmp" + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + actName := cmp.Or(os.Getenv("CHARGE_ACTIVITY"), "ChargeCard") + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + e := edges[0] + assert.Equal(t, "unresolved::temporal::activity::ChargeCard", e.To, + "name must resolve to the literal default, not the variable identifier") + assert.Equal(t, "ChargeCard", e.Meta["temporal_name"]) + assert.Equal(t, "env_default", e.Meta["temporal_name_origin"]) +} + +func TestGoTemporal_ExecuteActivity_EnvDefault_IfEmpty(t *testing.T) { + fix := runGoExtract(t, `package wf + +import ( + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + name := os.Getenv("CHARGE_ACTIVITY") + if name == "" { + name = "ChargeCard" + } + workflow.ExecuteActivity(ctx, name, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "unresolved::temporal::activity::ChargeCard", edges[0].To) + assert.Equal(t, "ChargeCard", edges[0].Meta["temporal_name"]) + assert.Equal(t, "env_default", edges[0].Meta["temporal_name_origin"]) +} + +func TestGoTemporal_ExecuteActivity_PlainVarNotEnvDefault(t *testing.T) { + // A variable NOT sourced from an env read keeps the existing + // behaviour (trailing identifier as the name) and carries no + // env_default flag — we don't guess at arbitrary variables. + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func WF(ctx workflow.Context, picked string) { + actName := picked + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "actName", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged, "plain variable must not be flagged env_default") +} + +func TestGoTemporal_ExecuteActivity_EnvReadNoLiteralDefault(t *testing.T) { + // os.Getenv with no literal fallback can't be pinned to a name — + // keep the variable identifier, no env_default flag. + fix := runGoExtract(t, `package wf + +import ( + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + name := os.Getenv("CHARGE_ACTIVITY") + workflow.ExecuteActivity(ctx, name, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "name", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..7bca3c17 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -201,6 +201,12 @@ type goDeferredCall struct { tempKind string tempName string tempLocal bool + // tempEnvDefault is set when tempName was resolved from a bare + // variable read from an env var with a literal default (e.g. + // `cmp.Or(os.Getenv("K"), "Default")`). The stub edge is then tagged + // `temporal_name_origin=env_default` so the resolver lands it at the + // speculative tier — the runtime env value may differ from the default. + tempEnvDefault bool } type goDeferredTypeRef struct { @@ -334,10 +340,20 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // Temporal workflow → activity dispatch: // `workflow.ExecuteActivity(ctx, X, ...)` etc. if kind, local, ok := goTemporalDispatchKind(receiver, method); ok { - if name := goTemporalDispatchName(expr.Node, src); name != "" { + argNode := goTemporalDispatchArg(expr.Node) + if name := goTemporalNameFromExpr(argNode, src); name != "" { dc.tempKind = kind dc.tempName = name dc.tempLocal = local + // Env-default refinement: when the name is a bare local + // variable, try to resolve it to an env-var-with-literal + // -default so the dispatch lands on the default activity. + if argNode != nil && argNode.Type() == "identifier" { + if def, ok := goTemporalEnvDefaultName(expr.Node, name, src); ok { + dc.tempName = def + dc.tempEnvDefault = true + } + } } } else if kind, _, ok := goTemporalRegisterKind(method); ok { // Temporal worker registration: @@ -650,6 +666,9 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe if c.tempLocal { meta["temporal_local"] = true } + if c.tempEnvDefault { + meta["temporal_name_origin"] = "env_default" + } result.Edges = append(result.Edges, &graph.Edge{ From: callerID, To: target, Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 019aeec0..8bfe7e0c 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,27 +79,24 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } -// goTemporalDispatchName extracts the activity (or child-workflow) -// name from a `workflow.ExecuteActivity(ctx, X, args...)` call. X is -// the second positional argument and is either: -// -// - a string literal: "MyActivity" -// - a bare identifier: MyActivity -// - a selector expression: pkg.MyActivity, recv.Method -// -// In every case we return the trailing identifier — that's the name -// the worker registers under (Temporal Go SDK convention: the bare -// function name unless `RegisterActivityWithOptions` overrides it). -// Returns "" when the second argument is missing, an expression we -// can't reduce to a name (e.g. a function literal), or when the call -// has fewer than two positional arguments. -func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { +// goTemporalDispatchArg returns the second positional argument node of a +// dispatch call (`workflow.ExecuteActivity(ctx, X, args...)` → X), or +// nil. X is either a string literal ("MyActivity"), a bare identifier +// (MyActivity), or a selector expression (pkg.MyActivity, recv.Method); +// goTemporalNameFromExpr reduces it to the trailing identifier — the +// name the worker registers under (the bare function name unless +// `RegisterActivityWithOptions` overrides it). Returned as a node, not a +// reduced name, so the env-default refinement can inspect the argument's +// shape (a bare identifier is the only case it tries to resolve to a +// literal default). Returns nil when the call has fewer than two +// positional arguments. +func goTemporalDispatchArg(callNode *sitter.Node) *sitter.Node { if callNode == nil || callNode.Type() != "call_expression" { - return "" + return nil } args := callNode.ChildByFieldName("arguments") if args == nil { - return "" + return nil } count := 0 for i := 0; i < int(args.NamedChildCount()); i++ { @@ -109,16 +106,16 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { } count++ if count == 2 { - return goTemporalNameFromExpr(c, src) + return c } } - return "" + return nil } // goTemporalRegisterName extracts the registered function name from a // `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call — // the first positional argument, which is the function reference. -// Same expression shapes as goTemporalDispatchName. +// Same expression shapes as the dispatch-name argument. func goTemporalRegisterName(callNode *sitter.Node, src []byte) string { if callNode == nil || callNode.Type() != "call_expression" { return "" @@ -200,3 +197,168 @@ func goTemporalNameFromExpr(node *sitter.Node, src []byte) string { } return "" } + +// goTemporalEnvDefaultName attempts to resolve a bare-identifier dispatch +// name to the string-literal default of an env-var-with-default +// assignment in the enclosing function. Returns the default and true for +// one of these shapes (anchored on a literal os.Getenv / os.LookupEnv +// read so the value is provably env-sourced): +// +// name := cmp.Or(os.Getenv("KEY"), "Default") // any call mixing an +// // os.Getenv read with a +// // string-literal arg +// name := os.Getenv("KEY") +// if name == "" { name = "Default" } // (or `name, ok := os.LookupEnv(...)` +// // followed by a literal assign) +// +// Intra-procedural and literal-only: only assignments lexically before +// the dispatch call are considered, and anything that isn't an +// os.Getenv-anchored literal default returns "", false. This is a +// deliberately narrow data-flow shortcut, not general constant +// propagation — see the speculative tier the resolver lands it at. +func goTemporalEnvDefaultName(callNode *sitter.Node, name string, src []byte) (string, bool) { + body := goEnclosingFuncBody(callNode) + if body == nil { + return "", false + } + limit := callNode.StartByte() + envDeclSeen := false + var result string + var found bool + var walk func(n *sitter.Node) + walk = func(n *sitter.Node) { + if n == nil || found { + return + } + // Only consider assignments lexically before the dispatch call. + if (n.Type() == "short_var_declaration" || n.Type() == "assignment_statement") && + n.StartByte() < limit && goAssignHasTarget(n, name, src) { + if rhs := goAssignRHSExpr(n); rhs != nil { + if rhs.Type() == "call_expression" { + if goIsEnvRead(rhs, src) { + envDeclSeen = true + } else if def, ok := goCallEnvDefaultLiteral(rhs, src); ok { + result, found = def, true + return + } + } else if envDeclSeen { + if lit, ok := goStringLiteralValue(rhs, src); ok { + result, found = lit, true + return + } + } + } + } + for i := 0; i < int(n.NamedChildCount()); i++ { + walk(n.NamedChild(i)) + if found { + return + } + } + } + walk(body) + return result, found +} + +// goEnclosingFuncBody walks up from n to the nearest function-like +// ancestor and returns its body block, or nil. +func goEnclosingFuncBody(n *sitter.Node) *sitter.Node { + for cur := n; cur != nil; cur = cur.Parent() { + switch cur.Type() { + case "function_declaration", "method_declaration", "func_literal": + return cur.ChildByFieldName("body") + } + } + return nil +} + +// goAssignHasTarget reports whether `name` appears among the left-hand +// targets of a short_var_declaration / assignment_statement. +func goAssignHasTarget(assign *sitter.Node, name string, src []byte) bool { + left := assign.ChildByFieldName("left") + if left == nil { + return false + } + for i := 0; i < int(left.NamedChildCount()); i++ { + c := left.NamedChild(i) + if c != nil && c.Type() == "identifier" && c.Content(src) == name { + return true + } + } + return false +} + +// goAssignRHSExpr returns the first right-hand expression of an +// assignment (the value for a single-target assign, or the lone call for +// a multi-return `a, b := f()`), or nil. +func goAssignRHSExpr(assign *sitter.Node) *sitter.Node { + right := assign.ChildByFieldName("right") + if right == nil || right.NamedChildCount() == 0 { + return nil + } + return right.NamedChild(0) +} + +// goIsEnvRead reports whether a call_expression is `os.Getenv(...)` or +// `os.LookupEnv(...)`. +func goIsEnvRead(call *sitter.Node, src []byte) bool { + fn := call.ChildByFieldName("function") + if fn == nil || fn.Type() != "selector_expression" { + return false + } + op := fn.ChildByFieldName("operand") + field := fn.ChildByFieldName("field") + if op == nil || field == nil || op.Content(src) != "os" { + return false + } + switch field.Content(src) { + case "Getenv", "LookupEnv": + return true + } + return false +} + +// goCallEnvDefaultLiteral inspects a call's arguments for the +// env-or-default shape `f(os.Getenv("KEY"), "Default")`: at least one +// argument is an os.Getenv / os.LookupEnv read AND at least one is a +// string literal. Returns the last string-literal argument and true on a +// match. +func goCallEnvDefaultLiteral(call *sitter.Node, src []byte) (string, bool) { + args := call.ChildByFieldName("arguments") + if args == nil { + return "", false + } + hasEnvRead := false + lastLiteral := "" + haveLiteral := false + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + if c.Type() == "call_expression" && goIsEnvRead(c, src) { + hasEnvRead = true + continue + } + if lit, ok := goStringLiteralValue(c, src); ok { + lastLiteral, haveLiteral = lit, true + } + } + if hasEnvRead && haveLiteral { + return lastLiteral, true + } + return "", false +} + +// goStringLiteralValue returns the unquoted value of a Go string literal +// node, or ("", false) for any other node type. +func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) { + if n == nil { + return "", false + } + switch n.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(n, src), true + } + return "", false +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 63cac106..5264ca5f 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -12,6 +12,14 @@ import ( // (`unresolved::temporal::::`). const temporalStubPrefix = unresolvedPrefix + "temporal::" +// temporalEnvDefaultConfidence is stamped on a stub edge whose name was +// resolved through an env-var-with-literal-default variable (the parser +// tags it `temporal_name_origin=env_default`). It sits in the +// speculative band (< 0.5) so the edge lands at the AMBIGUOUS label and, +// together with MetaSpeculative, is hidden from default queries: the +// runtime env override may name a different handler than the default. +const temporalEnvDefaultConfidence = 0.4 + // Temporal annotation node IDs the Java extractor emits via // EmitAnnotationEdge. The resolver consumes these to discover // temporal-tagged interfaces and methods. @@ -127,6 +135,18 @@ func ResolveTemporalCalls(g graph.Store) int { } handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo) + // When the name came from an env-var-with-literal-default + // variable, the value is a best-guess: land the resolved edge at + // the speculative tier instead of ast_resolved. + envDefault := false + if v, _ := e.Meta["temporal_name_origin"].(string); v == "env_default" { + envDefault = true + } + if handlerID != "" && envDefault { + origin = graph.OriginSpeculative + conf = temporalEnvDefaultConfidence + } + want := handlerID if want == "" { want = temporalStubPlaceholder(s.kind, s.name) @@ -145,6 +165,9 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = conf e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf) e.Meta["temporal_resolution"] = origin + if envDefault { + e.Meta[graph.MetaSpeculative] = true + } StampSynthesized(e, SynthTemporalStub) resolved++ } else { @@ -152,6 +175,7 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = 0 e.ConfidenceLabel = "" delete(e.Meta, "temporal_resolution") + delete(e.Meta, graph.MetaSpeculative) UnstampSynthesized(e) } reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 82c7922d..0b994cd0 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -44,6 +44,17 @@ func (b *temporalTestGraph) addStubCall(callerID, kind, name, filePath string) * return e } +// addStubCallEnvDefault adds a Temporal stub-call edge whose name was +// resolved from an env-var-with-literal-default variable +// (temporal_name_origin=env_default). The resolver must still land it on +// the registered handler but at the speculative tier (the runtime env +// override may differ from the default). +func (b *temporalTestGraph) addStubCallEnvDefault(callerID, kind, name, filePath string) *graph.Edge { + e := b.addStubCall(callerID, kind, name, filePath) + e.Meta["temporal_name_origin"] = "env_default" + return e +} + // addGoRegister adds a Go `worker.RegisterActivity(F)` edge: an // EdgeCalls edge from the worker-setup function to a placeholder, // carrying the temporal.register meta the resolver consumes. @@ -152,6 +163,34 @@ func TestResolveTemporalCalls_GoActivityRegistration(t *testing.T) { require.Len(t, b.g.GetInEdges(activity.ID), 1, "activity must see the inbound call edge") } +func TestResolveTemporalCalls_EnvDefaultResolvesSpeculative(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/workflow.go::OrderWorkflow", "OrderWorkflow", "wf/workflow.go", "svc") + call := b.addStubCallEnvDefault("wf/workflow.go::OrderWorkflow", "activity", "ChargeCard", "wf/workflow.go") + activity := b.addGoFunc("wf/activity.go::ChargeCard", "ChargeCard", "wf/activity.go", "svc") + b.addGoFunc("wf/main.go::setupWorker", "setupWorker", "wf/main.go", "svc") + b.addGoRegister("wf/main.go::setupWorker", "activity", "ChargeCard", "wf/main.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 1, resolved) + assert.Equal(t, activity.ID, call.To, "env-default stub must still land on the registered activity") + assert.Equal(t, graph.OriginSpeculative, call.Origin, "env-default resolution must be speculative tier") + assert.Less(t, call.Confidence, 0.5, "speculative confidence must be below the inferred threshold") + assert.Equal(t, true, call.Meta[graph.MetaSpeculative], "env-default edge must be hidden-by-default") +} + +func TestResolveTemporalCalls_EnvDefaultUnresolvedStaysPlaceholder(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/workflow.go::WF", "WF", "wf/workflow.go", "svc") + call := b.addStubCallEnvDefault("wf/workflow.go::WF", "activity", "MissingActivity", "wf/workflow.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 0, resolved) + assert.Equal(t, temporalStubPlaceholder("activity", "MissingActivity"), call.To) + _, speculative := call.Meta[graph.MetaSpeculative] + assert.False(t, speculative, "unresolved env-default edge must not carry the speculative flag") +} + func TestResolveTemporalCalls_GoChildWorkflowRegistration(t *testing.T) { b := newTemporalTestGraph() b.addGoFunc("a/parent.go::ParentWorkflow", "ParentWorkflow", "a/parent.go", "svc") From 87c4baf9097d53170e6e4669fc30c5d861729e02 Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 11:10:58 +0300 Subject: [PATCH 03/18] feat(temporal): also detect SetQueryHandlerWithOptions / GetSignalChannelWithOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the in-workflow handler set: the detector already handled SetUpdateHandlerWithOptions but missed the WithOptions variants of the query and signal handlers. Note: the Go SDK has no SetSignalHandler — signals are received via GetSignalChannel[WithOptions]. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/parser/languages/go_temporal_test.go | 33 +++++++++++++++++++ internal/parser/languages/golang_temporal.go | 6 ++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 54f771f8..206f609e 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -279,6 +279,39 @@ func OrderWorkflow(ctx workflow.Context) error { assert.Equal(t, "retry", edges[0].Meta["temporal_name"]) } +func TestGoTemporal_SetQueryHandlerWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + workflow.SetQueryHandlerWithOptions(ctx, "status", func() (string, error) { return "ok", nil }, workflow.QueryHandlerOptions{}) + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "query", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "status", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_GetSignalChannelWithOptions(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context) error { + ch := workflow.GetSignalChannelWithOptions(ctx, "cancel", workflow.SignalChannelOptions{}) + _ = ch + return nil +} +`) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel", edges[0].Meta["temporal_name"]) +} + func TestGoTemporal_HandlerNonLiteralNameUndetected(t *testing.T) { // Query / signal / update names are matched by string at runtime; // a non-literal name (variable / selector) can't be pinned here, so diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 056092dc..894c9a6f 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -84,7 +84,9 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { // returns the canonical kind ("query" / "signal" / "update"). // // workflow.SetQueryHandler(ctx, "name", fn) +// workflow.SetQueryHandlerWithOptions(ctx, "name", fn, opts) // workflow.GetSignalChannel(ctx, "name") +// workflow.GetSignalChannelWithOptions(ctx, "name", opts) // workflow.SetUpdateHandler(ctx, "name", fn) // workflow.SetUpdateHandlerWithOptions(ctx, "name", fn, opts) // @@ -98,9 +100,9 @@ func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) { return "", false } switch method { - case "SetQueryHandler": + case "SetQueryHandler", "SetQueryHandlerWithOptions": return "query", true - case "GetSignalChannel": + case "GetSignalChannel", "GetSignalChannelWithOptions": return "signal", true case "SetUpdateHandler", "SetUpdateHandlerWithOptions": return "update", true From 82e3140a0ed3ae8f85112627cefc098ea3f828ef Mon Sep 17 00:00:00 2001 From: avfirsov Date: Fri, 12 Jun 2026 11:21:56 +0300 Subject: [PATCH 04/18] feat(temporal): detect outbound signal-send / query-call against running workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the consumer side of the Temporal signal/query namespaces: workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // workflow -> workflow client.SignalWorkflow(ctx, wid, rid, "name", arg) // service -> workflow client.QueryWorkflow(ctx, wid, rid, "name", args...) // service -> workflow Each emits an EdgeCalls edge tagged via=temporal.signal-send / temporal.query-call carrying temporal_kind + temporal_name (the signal / query name = the 4th positional argument, a string literal). These pair with the in-workflow handler edges (via=temporal.handler) as the sender/handler two sides of the signal/query contract. SignalExternalWorkflow is gated on the canonical "workflow" receiver; SignalWorkflow / QueryWorkflow live on the client and are matched by method name (like the Register* helpers), kept high-precision by the string-literal name gate. No new edge kinds — consistent with the existing EdgeCalls + via=temporal.* convention. Note on the Go SDK surface: there is no workflow.SetSignalHandler (signals are received via GetSignalChannel), no workflow.QueryWorkflow (querying is client-side), and no SignalExternalWorkflowAsync (SignalExternalWorkflow already returns a Future). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/parser/languages/go_temporal_test.go | 91 ++++++++++++++++++ internal/parser/languages/golang.go | 17 ++++ internal/parser/languages/golang_temporal.go | 93 +++++++++++++++++++ 3 files changed, 201 insertions(+) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..5493393b 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -200,3 +200,94 @@ func setup(w Worker) { require.Len(t, stubs, 1) require.Len(t, registers, 2) } + +// --- Outbound signal sends / query calls ---------------------------- +// +// A workflow (or a service holding a Temporal client) can signal or +// query an ALREADY-RUNNING workflow by name. These are the consumer +// side of the signal/query namespaces — distinct from the in-workflow +// handler declarations. We surface them as EdgeCalls edges tagged +// `via=temporal.signal-send` / `temporal.query-call` carrying the +// signal/query name (the 4th positional argument, a string literal). +// +// APIs (the name is always the 4th positional arg, after ctx + +// workflowID + runID): +// +// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // workflow -> workflow +// client.SignalWorkflow(ctx, wid, rid, "name", arg) // service -> workflow +// client.QueryWorkflow(ctx, wid, rid, "name", args...) // service -> workflow +// +// (Note: there is no workflow.QueryWorkflow — querying is a client-side +// operation; and SignalExternalWorkflow already returns a Future, so +// there is no ...Async variant.) + +func TestGoTemporal_SignalExternalWorkflow(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func Orchestrator(ctx workflow.Context) error { + return workflow.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) +} +`) + edges := temporalEdgesByVia(fix, "temporal.signal-send") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_ClientSignalWorkflow(t *testing.T) { + // Receiver is an arbitrary client variable, so detection is by + // method name (like the Register* helpers), gated on a string-literal + // name in the 4th position. + fix := runGoExtract(t, `package svc + +func Cancel(c Client) error { + return c.SignalWorkflow(ctx, "order-123", "", "cancel-request", nil) +} +`) + edges := temporalEdgesByVia(fix, "temporal.signal-send") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_ClientQueryWorkflow(t *testing.T) { + fix := runGoExtract(t, `package svc + +func Status(c Client) { + c.QueryWorkflow(ctx, "order-123", "", "get-status") +} +`) + edges := temporalEdgesByVia(fix, "temporal.query-call") + require.Len(t, edges, 1) + assert.Equal(t, "query", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "get-status", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_OutboundNonLiteralNameUndetected(t *testing.T) { + // Signal/query names are matched by string at runtime; a non-literal + // name can't be pinned, so no outbound edge is emitted. + fix := runGoExtract(t, `package svc + +func Cancel(c Client, name string) error { + return c.SignalWorkflow(ctx, "order-123", "", name, nil) +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) +} + +func TestGoTemporal_SignalExternalAliasedNotDetected(t *testing.T) { + // SignalExternalWorkflow is gated on the canonical "workflow" + // receiver (it is a workflow-package function); an aliased import + // is intentionally missed, consistent with the dispatch detector. + fix := runGoExtract(t, `package wf + +import wf "go.temporal.io/sdk/workflow" + +func Orchestrator(ctx wf.Context) error { + return wf.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index a1b18f0d..662678b2 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -201,6 +201,12 @@ type goDeferredCall struct { tempKind string tempName string tempLocal bool + // tempOutKind is "signal" / "query" when this call is an outbound + // signal-send / query-call against a running workflow + // (SignalExternalWorkflow / SignalWorkflow / QueryWorkflow); tempName + // then carries the signal/query name. `via=temporal.signal-send` / + // `temporal.query-call` meta is stamped on the emitted edge below. + tempOutKind string } type goDeferredTypeRef struct { @@ -346,6 +352,14 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempKind = "register_" + kind dc.tempName = name } + } else if okind, namePos, ok := goTemporalSignalQueryOutKind(receiver, method); ok { + // Outbound signal-send / query-call against a running + // workflow: SignalExternalWorkflow / SignalWorkflow / + // QueryWorkflow. The name is the 4th positional literal. + if name := goTemporalNthStringLiteralArg(expr.Node, namePos, src); name != "" { + dc.tempOutKind = okind + dc.tempName = name + } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { @@ -668,6 +682,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -680,6 +695,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -729,6 +745,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) + applyGoTemporalSignalQueryMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) } diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 019aeec0..30d73ddc 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -79,6 +79,36 @@ func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) { return "", false, false } +// goTemporalSignalQueryOutKind reports whether (receiver, method) names +// an OUTBOUND signal-send or query-call against an already-running +// workflow and, if so, returns the kind ("signal" / "query") plus the +// 1-based position of the signal/query-name argument. +// +// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // wf -> wf +// client.SignalWorkflow(ctx, wid, rid, "name", arg) // svc -> wf +// client.QueryWorkflow(ctx, wid, rid, "name", args...) // svc -> wf +// +// SignalExternalWorkflow is gated on the canonical "workflow" receiver +// (it is a workflow-package function). SignalWorkflow / QueryWorkflow +// live on the client and are called on an arbitrary client variable, so +// — like the Register* helpers — they are matched by method name alone; +// the string-literal name gate below keeps that high-precision. There is +// deliberately no workflow.QueryWorkflow (querying is client-side) and no +// SignalExternalWorkflowAsync (SignalExternalWorkflow returns a Future). +func goTemporalSignalQueryOutKind(receiver, method string) (kind string, namePos int, ok bool) { + switch method { + case "SignalExternalWorkflow": + if receiver == "workflow" { + return "signal", 4, true + } + case "SignalWorkflow": + return "signal", 4, true + case "QueryWorkflow": + return "query", 4, true + } + return "", 0, false +} + // goTemporalDispatchName extracts the activity (or child-workflow) // name from a `workflow.ExecuteActivity(ctx, X, args...)` call. X is // the second positional argument and is either: @@ -115,6 +145,38 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { return "" } +// goTemporalNthStringLiteralArg returns the unquoted value of the n-th +// (1-based) positional argument of a call when that argument is a string +// literal, else "". Used to extract the signal/query name from an +// outbound send/call — names are matched by string at runtime, so only a +// literal can be pinned here (a variable / constant is left undetected, +// keeping the detector high-precision). +func goTemporalNthStringLiteralArg(callNode *sitter.Node, n int, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == n { + switch c.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(c, src) + } + return "" + } + } + return "" +} + // goTemporalRegisterName extracts the registered function name from a // `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call — // the first positional argument, which is the function reference. @@ -168,6 +230,37 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// applyGoTemporalSignalQueryMeta stamps the outbound signal-send / +// query-call meta onto an EdgeCalls edge derived from +// `SignalExternalWorkflow` / `SignalWorkflow` / `QueryWorkflow`: +// `via=temporal.signal-send` or `temporal.query-call`, plus +// `temporal_kind` (signal / query) and `temporal_name` (the literal +// signal/query name). No-op when c.tempOutKind / c.tempName are unset. +// +// These are the consumer side of the signal/query namespaces; the +// provider side is the in-workflow handler (GetSignalChannel / +// SetQueryHandler), tagged via=temporal.handler. +func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) { + if edge == nil || c.tempOutKind == "" || c.tempName == "" { + return + } + var via string + switch c.tempOutKind { + case "signal": + via = "temporal.signal-send" + case "query": + via = "temporal.query-call" + default: + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["via"] = via + edge.Meta["temporal_kind"] = c.tempOutKind + edge.Meta["temporal_name"] = c.tempName +} + // goTemporalNameFromExpr reduces a single argument expression to the // trailing identifier that names the activity / workflow. Handles // string literals (`"MyActivity"` and the Go raw-string variant), From e88a864217e740969cdf829aa837f334e46a3511 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:24:36 +0200 Subject: [PATCH 05/18] fix(temporal): correct env-default name resolution data-flow goCallEnvDefaultLiteral modelled cmp.Or backwards (returned the last string literal; cmp.Or returns the FIRST non-zero argument) and accepted any callee that mixed an env read with a literal, so an arbitrary user wrapper like combine(os.Getenv("K"), "Suffix") was misread as an env-or-default. It now returns the first literal and is gated on the stdlib cmp.Or callee. goTemporalEnvDefaultName took the first matching assignment in tree order and descended into nested closures, so a later live reassignment was ignored and a shadowing same-named variable in an inner func literal could mis-flag the outer dispatch. It now replays the writes in source order (last live write wins) and never descends into nested func literals. Adds regression tests: cmp.Or multi-literal first-wins, non-cmp.Or callee rejected, env-default overwritten by a later reassignment, and a shadowing closure variable not matched. --- internal/parser/languages/go_temporal_test.go | 101 ++++++++++++++++ internal/parser/languages/golang_temporal.go | 114 +++++++++++++----- 2 files changed, 182 insertions(+), 33 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index eed0a436..721e25de 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -298,6 +298,107 @@ func WF(ctx workflow.Context) { assert.False(t, flagged) } +func TestGoTemporal_ExecuteActivity_EnvDefault_CmpOrFirstLiteral(t *testing.T) { + // cmp.Or returns the FIRST non-zero argument, so with the env unset + // the runtime value is the first literal ("First"), not the last. + fix := runGoExtract(t, `package wf + +import ( + "cmp" + "os" + "go.temporal.io/sdk/workflow" +) + +func WF(ctx workflow.Context) { + actName := cmp.Or(os.Getenv("A"), os.Getenv("B"), "First", "Second") + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "First", edges[0].Meta["temporal_name"], + "cmp.Or default must be the first literal, not the last") + assert.Equal(t, "env_default", edges[0].Meta["temporal_name_origin"]) +} + +func TestGoTemporal_ExecuteActivity_NonCmpOrCalleeNotEnvDefault(t *testing.T) { + // An arbitrary user function mixing an env read with a literal is NOT + // the cmp.Or env-or-default idiom — keep the bare identifier, no flag. + fix := runGoExtract(t, `package wf + +import ( + "os" + "go.temporal.io/sdk/workflow" +) + +func combine(a, b string) string { return a + b } + +func WF(ctx workflow.Context) { + actName := combine(os.Getenv("K"), "Suffix") + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "actName", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged, "non-cmp.Or callee must not be treated as env_default") +} + +func TestGoTemporal_ExecuteActivity_EnvDefaultOverwrittenNotFlagged(t *testing.T) { + // A later plain reassignment is the live value at the call site; the + // earlier env-default write must not win — leave it unresolved. + fix := runGoExtract(t, `package wf + +import ( + "cmp" + "os" + "go.temporal.io/sdk/workflow" +) + +func pick() string { return "Other" } + +func WF(ctx workflow.Context) { + actName := cmp.Or(os.Getenv("K"), "ChargeCard") + actName = pick() + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "actName", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged, "a later non-env reassignment must clear the env_default flag") +} + +func TestGoTemporal_ExecuteActivity_ShadowInNestedClosureNotMatched(t *testing.T) { + // A same-named variable declared in a nested closure is a different + // scope; it must not be matched for the outer dispatch's name. + fix := runGoExtract(t, `package wf + +import ( + "cmp" + "os" + "go.temporal.io/sdk/workflow" +) + +func run(f func()) { f() } + +func WF(ctx workflow.Context, actName string) { + run(func() { + actName := cmp.Or(os.Getenv("K"), "Inner") + _ = actName + }) + workflow.ExecuteActivity(ctx, actName, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "actName", edges[0].Meta["temporal_name"]) + _, flagged := edges[0].Meta["temporal_name_origin"] + assert.False(t, flagged, "a shadowing var in a nested closure must not flag the outer dispatch") +} + // --- In-workflow handler declarations (query / signal / update) ----- // // These mirror the Java SDK's @QueryMethod / @SignalMethod / diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 37a93c12..f2712670 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -401,42 +401,66 @@ func goTemporalEnvDefaultName(callNode *sitter.Node, name string, src []byte) (s return "", false } limit := callNode.StartByte() - envDeclSeen := false - var result string - var found bool + + // Collect every assignment to `name` lexically before the dispatch + // call, in source order, WITHOUT descending into nested func_literal + // bodies — a closure is a separate scope, and matching a shadowing + // same-named variable declared there would be a false positive. + var assigns []*sitter.Node var walk func(n *sitter.Node) walk = func(n *sitter.Node) { - if n == nil || found { + if n == nil { return } - // Only consider assignments lexically before the dispatch call. + if n.Type() == "func_literal" { + return // do not descend into nested closures + } if (n.Type() == "short_var_declaration" || n.Type() == "assignment_statement") && n.StartByte() < limit && goAssignHasTarget(n, name, src) { - if rhs := goAssignRHSExpr(n); rhs != nil { - if rhs.Type() == "call_expression" { - if goIsEnvRead(rhs, src) { - envDeclSeen = true - } else if def, ok := goCallEnvDefaultLiteral(rhs, src); ok { - result, found = def, true - return - } - } else if envDeclSeen { - if lit, ok := goStringLiteralValue(rhs, src); ok { - result, found = lit, true - return - } - } - } + assigns = append(assigns, n) } for i := 0; i < int(n.NamedChildCount()); i++ { walk(n.NamedChild(i)) - if found { - return - } } } walk(body) - return result, found + + // Replay the writes in order. The dispatch name is env-default-sourced + // only if, after the LAST write before the call, the variable still + // holds an env-or-default value: either a `cmp.Or(os.Getenv, "lit")` + // assignment, or a string-literal assignment that followed an + // os.Getenv / os.LookupEnv read (the `name := os.Getenv(...); if name + // == "" { name = "lit" }` shape). Any other later write — a plain + // reassignment `name = pick()` — clears the env-sourcing, and we leave + // the dispatch unresolved rather than guess. + resolved := "" + resolvedOK := false + envReadSeen := false + for _, a := range assigns { + rhs := goAssignRHSExpr(a) + switch { + case rhs == nil: + resolved, resolvedOK, envReadSeen = "", false, false + case rhs.Type() == "call_expression" && goIsEnvRead(rhs, src): + // `name := os.Getenv("K")` — default still pending. + resolved, resolvedOK, envReadSeen = "", false, true + case rhs.Type() == "call_expression": + // `name := cmp.Or(os.Getenv("K"), "lit")` — self-contained. + if def, ok := goCallEnvDefaultLiteral(rhs, src); ok { + resolved, resolvedOK, envReadSeen = def, true, false + } else { + resolved, resolvedOK, envReadSeen = "", false, false + } + default: + // `name = "lit"` — only a default when it follows an env read. + if lit, ok := goStringLiteralValue(rhs, src); ok && envReadSeen { + resolved, resolvedOK = lit, true + } else { + resolved, resolvedOK, envReadSeen = "", false, false + } + } + } + return resolved, resolvedOK } // goEnclosingFuncBody walks up from n to the nearest function-like @@ -497,18 +521,27 @@ func goIsEnvRead(call *sitter.Node, src []byte) bool { return false } -// goCallEnvDefaultLiteral inspects a call's arguments for the -// env-or-default shape `f(os.Getenv("KEY"), "Default")`: at least one -// argument is an os.Getenv / os.LookupEnv read AND at least one is a -// string literal. Returns the last string-literal argument and true on a -// match. +// goCallEnvDefaultLiteral inspects a `cmp.Or(os.Getenv("KEY"), "Default")` +// call and returns its literal default. cmp.Or returns the FIRST non-zero +// argument, so when the env read yields "" at runtime the value is the +// first string-literal argument that follows — hence we return the FIRST +// literal, not the last. Gated on the cmp.Or callee: an arbitrary user +// function mixing an env read with a literal (`combine(os.Getenv("K"), +// "Suffix")`) is deliberately NOT treated as env-or-default — only the +// stdlib cmp.Or idiom qualifies, since cmp.Or is the one combinator whose +// "first non-zero" semantics make the literal a provable default. Returns +// ("", false) when the callee is not cmp.Or, no os.Getenv / os.LookupEnv +// read is present, or there is no string-literal argument. func goCallEnvDefaultLiteral(call *sitter.Node, src []byte) (string, bool) { + if !goIsCmpOr(call, src) { + return "", false + } args := call.ChildByFieldName("arguments") if args == nil { return "", false } hasEnvRead := false - lastLiteral := "" + firstLiteral := "" haveLiteral := false for i := 0; i < int(args.NamedChildCount()); i++ { c := args.NamedChild(i) @@ -519,16 +552,31 @@ func goCallEnvDefaultLiteral(call *sitter.Node, src []byte) (string, bool) { hasEnvRead = true continue } - if lit, ok := goStringLiteralValue(c, src); ok { - lastLiteral, haveLiteral = lit, true + if lit, ok := goStringLiteralValue(c, src); ok && !haveLiteral { + firstLiteral, haveLiteral = lit, true } } if hasEnvRead && haveLiteral { - return lastLiteral, true + return firstLiteral, true } return "", false } +// goIsCmpOr reports whether a call_expression is a call to the stdlib +// `cmp.Or` — the canonical "first non-zero" combinator used for the +// env-or-default idiom. Matched by the canonical `cmp` package alias +// (consistent with the os.Getenv / "workflow" receiver gates elsewhere). +func goIsCmpOr(call *sitter.Node, src []byte) bool { + fn := call.ChildByFieldName("function") + if fn == nil || fn.Type() != "selector_expression" { + return false + } + op := fn.ChildByFieldName("operand") + field := fn.ChildByFieldName("field") + return op != nil && field != nil && + op.Content(src) == "cmp" && field.Content(src) == "Or" +} + // goStringLiteralValue returns the unquoted value of a Go string literal // node, or ("", false) for any other node type. func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) { From 770d0ac1f1e48cba2017fe1976c849acaa502321 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:25:32 +0200 Subject: [PATCH 06/18] test(temporal): add indexer e2e for outbound signal-send / query-call PR #81 shipped only parser-level tests; this exercises SignalExternalWorkflow and client.QueryWorkflow through the full indexer pipeline, asserting the via=temporal.signal-send / temporal.query-call edges originate from the sender function and carry the correct temporal_kind + temporal_name. --- internal/indexer/temporal_e2e_test.go | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 932c5f32..0696ad43 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -229,3 +229,57 @@ func StatusWorkflow(ctx workflow.Context) error { assert.Equal(t, "query", handler.Meta["temporal_kind"]) assert.Equal(t, "status", handler.Meta["temporal_name"]) } + +// TestTemporalE2E_GoOutboundSignalQuery exercises the consumer side of the +// signal/query namespaces through the real indexer: a workflow that signals +// an external workflow and a service that queries a running workflow must +// surface via=temporal.signal-send / via=temporal.query-call edges carrying +// the signal/query name (the 4th positional string literal). +func TestTemporalE2E_GoOutboundSignalQuery(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "orchestrator.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func Orchestrator(ctx workflow.Context) error { + return workflow.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) +} +`) + writeFile(t, filepath.Join(dir, "service.go"), `package wf + +type Client interface { + QueryWorkflow(ctx any, wid, rid, queryType string, args ...any) (any, error) +} + +func CheckStatus(ctx any, c Client) { + c.QueryWorkflow(ctx, "order-123", "", "get-status") +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + findOut := func(fnName, via string) *graph.Edge { + fn := g.FindNodesByName(fnName) + require.NotEmpty(t, fn, "function %s must be indexed", fnName) + for _, e := range g.GetOutEdges(fn[0].ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == via { + return e + } + } + return nil + } + + sig := findOut("Orchestrator", "temporal.signal-send") + require.NotNil(t, sig, "Orchestrator must have an outbound temporal.signal-send edge") + assert.Equal(t, "signal", sig.Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", sig.Meta["temporal_name"]) + + qry := findOut("CheckStatus", "temporal.query-call") + require.NotNil(t, qry, "CheckStatus must have an outbound temporal.query-call edge") + assert.Equal(t, "query", qry.Meta["temporal_kind"]) + assert.Equal(t, "get-status", qry.Meta["temporal_name"]) +} From 1434e2a79ae3e28007ea7545e8af1c860b4dfae3 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:27:26 +0200 Subject: [PATCH 07/18] fix(temporal): gate stub-call resolution by caller language idx.lookup keyed only on :: with no language filter, and the candidate set co-mingles Go register targets with Java annotation-tagged methods. A Go workflow.ExecuteActivity stub could therefore resolve onto a Java method node when names collided and the Java entry was the unique overall candidate (pickGoTemporalTarget gates language only on the Go register-indexing path, not the stub-resolution path). lookup now filters candidates to the caller's language; the intentional Java->Go join is a separate cross-language pass. Adds a unit test for the gate. --- internal/resolver/temporal_calls.go | 31 +++++++++++++++++++++--- internal/resolver/temporal_calls_test.go | 29 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 5264ca5f..52258256 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -130,10 +130,12 @@ func ResolveTemporalCalls(g graph.Store) int { for _, s := range stubs { e := s.edge callerRepo := "" + callerLang := "" if from := callerNodes[e.From]; from != nil { callerRepo = from.RepoPrefix + callerLang = from.Language } - handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo) + handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo, callerLang) // When the name came from an env-var-with-literal-default // variable, the value is a best-guess: land the resolved edge at @@ -200,11 +202,32 @@ type temporalIndex struct { byKindName map[string][]*graph.Node } -func (idx *temporalIndex) lookup(kind, name, callerRepo string) (id, origin string, confidence float64) { - cands := idx.byKindName[kind+"::"+name] - if len(cands) == 0 { +func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id, origin string, confidence float64) { + all := idx.byKindName[kind+"::"+name] + if len(all) == 0 { return "", "", 0 } + // Language gate: a Temporal stub call resolves only within its own + // language. The candidate set co-mingles Go register targets and Java + // annotation-tagged methods under the same "::" key with + // no language tag, so without this gate a Go workflow.ExecuteActivity + // stub could land on a Java method node when names collide and that + // Java entry is the unique overall candidate (pickGoTemporalTarget + // gates language only on the Go register-indexing path, not here). The + // intentional Java→Go cross-language join is a separate, explicitly + // cross-language pass, not this same-language stub resolver. + cands := all + if callerLang != "" { + cands = cands[:0:0] + for _, n := range all { + if n.Language == callerLang { + cands = append(cands, n) + } + } + if len(cands) == 0 { + return "", "", 0 + } + } // Prefer same-repo, then unique overall. var sameRepo []*graph.Node for _, n := range cands { diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 0b994cd0..9606379d 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -428,3 +428,32 @@ func TestResolveTemporalCalls_RoleStampingIsIdempotent(t *testing.T) { } assert.Equal(t, "activity", methods["doIt"].Meta["temporal_role"]) } + +func TestTemporalIndexLookup_LanguageGate(t *testing.T) { + goNode := &graph.Node{ID: "go/a.go::ChargeCard", Name: "ChargeCard", Language: "go", RepoPrefix: "svc"} + javaNode := &graph.Node{ID: "java/A.java::chargeCard", Name: "ChargeCard", Language: "java", RepoPrefix: "jsvc"} + + idx := &temporalIndex{byKindName: map[string][]*graph.Node{ + "activity::ChargeCard": {javaNode}, // only a Java candidate + }} + + // A Go stub must NOT resolve onto a Java handler node even when the + // Java entry is the unique overall candidate — that cross-language + // match is the job of the dedicated join pass, not the stub resolver. + id, _, _ := idx.lookup("activity", "ChargeCard", "svc", "go") + assert.Empty(t, id, "go stub must not resolve to a java handler") + + // With a Go candidate present, the Go caller resolves to it (unique + // within the caller's language). + idx.byKindName["activity::ChargeCard"] = []*graph.Node{javaNode, goNode} + id, origin, conf := idx.lookup("activity", "ChargeCard", "svc", "go") + assert.Equal(t, goNode.ID, id) + assert.Equal(t, graph.OriginASTResolved, origin) + assert.Equal(t, 0.9, conf) + + // An unknown caller language keeps the language-agnostic + // unique-overall fallback (no regression for callers with no lang). + idx.byKindName["activity::Solo"] = []*graph.Node{javaNode} + id, _, _ = idx.lookup("activity", "Solo", "", "") + assert.Equal(t, javaNode.ID, id, "unknown caller lang keeps the unique-overall fallback") +} From da00bb9ae3fb22564afda6413173c129191f640b Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:31:12 +0200 Subject: [PATCH 08/18] perf(temporal): single-scan resolve, early-out, conditional role write-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ResolveTemporalCalls runs on every incremental single-file reindex and scanned the largest edge class (EdgeCalls) three times per pass (once for register edges in buildTemporalIndex, once for the EdgeAnnotated probe, once for stub edges) plus an unconditional AddNode for every temporal-role node — a whole-workspace recompute paid even by repos with no Temporal code. - Collect temporal.register and temporal.stub edges in a single EdgeCalls sweep and pass them into buildTemporalIndex (no second scan). - Probe EdgeAnnotated once and early-out before any node fetch / index build / Java propagation when the graph has zero temporal edges. - stampTemporalRole now skips the store write-back when the node already carries the same role + name, eliminating the per-pass re-write storm. --- internal/resolver/temporal_calls.go | 110 +++++++++++++++++++--------- 1 file changed, 77 insertions(+), 33 deletions(-) diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 52258256..c2255230 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -93,34 +93,62 @@ func ResolveTemporalCalls(g graph.Store) int { mu := g.ResolveMutex() mu.Lock() defer mu.Unlock() - idx := buildTemporalIndex(g) - resolved := 0 - var reindexBatch []graph.EdgeReindex - // First sweep: collect stub edges and the From IDs we need so the - // per-edge GetNode below collapses to one batch lookup. + + // 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 + // per concern. The From IDs of stub edges are gathered so the + // per-edge caller lookup below collapses to one batch fetch. type stubEdge struct { edge *graph.Edge kind, name string } var stubs []stubEdge + var registerEdges []*graph.Edge fromIDSet := map[string]struct{}{} for e := range g.EdgesByKind(graph.EdgeCalls) { if e == nil || e.Meta == nil { continue } - if v, _ := e.Meta["via"].(string); v != "temporal.stub" { - continue + switch v, _ := e.Meta["via"].(string); v { + case "temporal.register": + registerEdges = append(registerEdges, e) + case "temporal.stub": + kind, _ := e.Meta["temporal_kind"].(string) + name, _ := e.Meta["temporal_name"].(string) + if kind == "" || name == "" { + continue + } + stubs = append(stubs, stubEdge{edge: e, kind: kind, name: name}) + if e.From != "" { + fromIDSet[e.From] = struct{}{} + } } - kind, _ := e.Meta["temporal_kind"].(string) - name, _ := e.Meta["temporal_name"].(string) - if kind == "" || name == "" { + } + + // Probe the (smaller) annotation class for Java temporal tags. + var annotatedEdges []*graph.Edge + for e := range g.EdgesByKind(graph.EdgeAnnotated) { + if e == nil { continue } - stubs = append(stubs, stubEdge{edge: e, kind: kind, name: name}) - if e.From != "" { - fromIDSet[e.From] = struct{}{} + if r, m := temporalRoleForJavaAnnotation(e.To); r == "" && m == "" { + continue } + annotatedEdges = append(annotatedEdges, e) + } + + // Early-out: a graph with no Temporal register / stub / annotation + // edges (the common case for most repos) skips all node fetches, + // index building, role stamping, and Java propagation entirely — the + // pass costs only the two EdgesByKind scans above. + if len(registerEdges) == 0 && len(stubs) == 0 && len(annotatedEdges) == 0 { + return 0 } + + idx := buildTemporalIndex(g, registerEdges, annotatedEdges) + resolved := 0 + var reindexBatch []graph.EdgeReindex fromList := make([]string, 0, len(fromIDSet)) for id := range fromIDSet { fromList = append(fromList, id) @@ -244,21 +272,25 @@ func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id, return "", "", 0 } -// buildTemporalIndex walks the graph once and (a) stamps temporal_role -// on every node identifiable as a Temporal workflow / activity via -// either Go `worker.Register*` calls or Java `@ActivityInterface` / -// `@WorkflowInterface` annotations (propagated to interface -// implementors), and (b) returns a name index the stub-call resolver -// consults. -func buildTemporalIndex(g graph.Store) *temporalIndex { +// buildTemporalIndex (a) stamps temporal_role on every node identifiable +// as a Temporal workflow / activity via either Go `worker.Register*` +// calls or Java `@ActivityInterface` / `@WorkflowInterface` annotations +// (propagated to interface implementors), and (b) returns a name index +// the stub-call resolver consults. +// +// registerEdges and annotatedEdges are the temporal.register EdgeCalls +// edges and the temporal-annotation EdgeAnnotated edges, already +// collected by the single ResolveTemporalCalls sweep — passing them in +// 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{}} - // Phase 1 — Go side. Walk `temporal.register` edges and stamp the - // registered function's node. The "via" tag lives on EdgeCalls - // edges, so narrow with EdgesByKind before the Meta filter. + // Phase 1 — Go side. Walk the pre-collected `temporal.register` edges + // and stamp the registered function's node. // - // Collect every register edge first so we can batch-fetch every - // caller node and resolve every Go target name in one pair of + // Collect every register edge's targets first so we can batch-fetch + // every caller node and resolve every Go target name in one pair of // round-trips, instead of N AllNodes scans + N GetNode calls. type goRegister struct { edge *graph.Edge @@ -267,13 +299,10 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { var goRegisters []goRegister registerCallerIDs := map[string]struct{}{} registerNames := map[string]struct{}{} - for e := range g.EdgesByKind(graph.EdgeCalls) { + for _, e := range registerEdges { if e == nil || e.Meta == nil { continue } - if v, _ := e.Meta["via"].(string); v != "temporal.register" { - continue - } kind, _ := e.Meta["temporal_kind"].(string) name, _ := e.Meta["temporal_name"].(string) if kind == "" || name == "" { @@ -309,16 +338,16 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { idx.byKindName[r.kind+"::"+r.name] = append(idx.byKindName[r.kind+"::"+r.name], target) } - // Phase 2 — Java side. Walk `EdgeAnnotated` edges to find - // temporal-tagged interfaces and methods. As with Phase 1, collect - // every annotation edge and batch the From-side GetNode calls. + // Phase 2 — Java side. Walk the pre-collected temporal-annotation + // `EdgeAnnotated` edges to find temporal-tagged interfaces and + // methods. As with Phase 1, batch the From-side GetNode calls. type javaAnno struct { fromID string ifaceRole, methodRole string } var javaAnnos []javaAnno annoFromIDs := map[string]struct{}{} - for e := range g.EdgesByKind(graph.EdgeAnnotated) { + for _, e := range annotatedEdges { if e == nil { continue } @@ -485,6 +514,21 @@ func stampTemporalRole(g graph.Store, n *graph.Node, role, name string) { if n == nil || role == "" { return } + // Skip the write-back entirely when the role + name are already what + // we would stamp. ResolveTemporalCalls is a full recompute that runs + // on every incremental edit, so without this guard every Temporal-role + // node is re-AddNode'd (a serialised single-row write on the sqlite + // backend) on every pass even when nothing changed. The common steady + // state — re-running the pass after an unrelated edit — then costs no + // node writes at all. + if cur, _ := n.Meta["temporal_role"].(string); cur == role { + if name == "" { + return + } + if curName, _ := n.Meta["temporal_name"].(string); curName == name { + return + } + } if n.Meta == nil { n.Meta = map[string]any{} } From 3a73c1d31f966fdda7706745e66cfc8f583a65e7 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:34:58 +0200 Subject: [PATCH 09/18] feat(temporal): honor RegisterActivityWithOptions Name override goTemporalRegisterName returned only the function-reference identifier, so RegisterActivityWithOptions(MyActivity, activity.RegisterOptions{Name: "Override"}) indexed the activity under "MyActivity" and a dispatch of ExecuteActivity(ctx, "Override", ...) never resolved. The parser now extracts the RegisterOptions{Name: "..."} literal into temporal_registered_name; the resolver locates the node by the func-ref name but stamps + indexes it under the registered name, so the dispatch matches. Covers RegisterActivityWithOptions and RegisterWorkflowWithOptions (by-value or &-pointer options literal). --- internal/parser/languages/go_temporal_test.go | 5 +- internal/parser/languages/golang.go | 14 ++++ internal/parser/languages/golang_temporal.go | 76 +++++++++++++++++++ internal/resolver/temporal_calls.go | 22 ++++-- internal/resolver/temporal_calls_test.go | 22 ++++++ 5 files changed, 133 insertions(+), 6 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 721e25de..ac4c78ae 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -132,7 +132,10 @@ func setup(w Worker) { edges := temporalEdgesByVia(fix, "temporal.register") require.Len(t, edges, 1) assert.Equal(t, "activity", edges[0].Meta["temporal_kind"]) - assert.Equal(t, "ChargeCard", edges[0].Meta["temporal_name"]) + assert.Equal(t, "ChargeCard", edges[0].Meta["temporal_name"], + "temporal_name keeps the function-reference identifier") + assert.Equal(t, "Charge", edges[0].Meta["temporal_registered_name"], + "RegisterOptions{Name} override is captured as the registered name") } func TestGoTemporal_RegisterWorkflow(t *testing.T) { diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index f1cc81dc..3ccbe39b 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -219,6 +219,13 @@ type goDeferredCall struct { // then carries the signal/query name. `via=temporal.signal-send` / // `temporal.query-call` meta is stamped on the emitted edge below. tempOutKind string + // tempRegisteredName is the canonical registered name when a + // RegisterActivityWithOptions / RegisterWorkflowWithOptions call + // overrides it via RegisterOptions{Name: "..."}. tempName still holds + // the function-reference identifier (used to locate the node); this + // is the name the activity/workflow is dispatched under and becomes + // the resolver's index key. Empty when no Name override is present. + tempRegisteredName string } type goDeferredTypeRef struct { @@ -373,6 +380,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe if name := goTemporalRegisterName(expr.Node, src); name != "" { dc.tempKind = "register_" + kind dc.tempName = name + // RegisterActivityWithOptions / RegisterWorkflowWithOptions + // may override the registered name via + // RegisterOptions{Name: "..."} — that is the name a + // dispatch matches against, so capture it as the index key. + if override := goTemporalRegisterNameOverride(expr.Node, src); override != "" { + dc.tempRegisteredName = override + } } } else if hkind, ok := goTemporalHandlerKind(receiver, method); ok { // Temporal in-workflow handler declaration: diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index f2712670..58419227 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -257,6 +257,82 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["via"] = "temporal.register" edge.Meta["temporal_kind"] = kind edge.Meta["temporal_name"] = c.tempName + if c.tempRegisteredName != "" { + edge.Meta["temporal_registered_name"] = c.tempRegisteredName + } +} + +// goTemporalRegisterNameOverride extracts the `Name:` string-literal +// field from the RegisterOptions composite literal passed as the second +// argument of a `RegisterActivityWithOptions` / `RegisterWorkflowWithOptions` +// call — the canonical registered name that overrides the bare function +// name (the name an `ExecuteActivity(ctx, "", …)` dispatch must +// match). Returns "" when there is no second composite-literal argument or +// no string-literal Name field. +// +// w.RegisterActivityWithOptions(MyActivity, +// activity.RegisterOptions{Name: "ChargeCard"}) +func goTemporalRegisterNameOverride(callNode *sitter.Node, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + // Second positional argument = the options struct. + var opts *sitter.Node + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == 2 { + opts = c + break + } + } + if opts == nil { + return "" + } + // Unwrap a `&RegisterOptions{...}` pointer literal. + if opts.Type() == "unary_expression" { + if op := opts.ChildByFieldName("operand"); op != nil { + opts = op + } + } + if opts.Type() != "composite_literal" { + return "" + } + body := opts.ChildByFieldName("body") + if body == nil { + return "" + } + unwrap := func(n *sitter.Node) *sitter.Node { + // A keyed-element key/value may be wrapped in a literal_element + // node depending on the grammar revision; reduce to the inner node. + if n != nil && n.Type() == "literal_element" && n.NamedChildCount() == 1 { + return n.NamedChild(0) + } + return n + } + for i := 0; i < int(body.NamedChildCount()); i++ { + kv := body.NamedChild(i) + if kv == nil || kv.Type() != "keyed_element" || kv.NamedChildCount() < 2 { + continue + } + key := unwrap(kv.NamedChild(0)) + val := unwrap(kv.NamedChild(1)) + if key == nil || val == nil || key.Content(src) != "Name" { + continue + } + if lit, ok := goStringLiteralValue(val, src); ok { + return lit + } + } + return "" } // applyGoTemporalHandlerMeta stamps `via=temporal.handler` plus diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index c2255230..58aba213 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -293,8 +293,13 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed // every caller node and resolve every Go target name in one pair of // round-trips, instead of N AllNodes scans + N GetNode calls. type goRegister struct { - edge *graph.Edge - kind, name string + edge *graph.Edge + kind string + // name is the function-reference identifier (used to locate the + // registered node); regName is the canonical registered name (the + // index key) — they differ only when RegisterActivityWithOptions + // overrides the name via RegisterOptions{Name: "..."}. + name, regName string } var goRegisters []goRegister registerCallerIDs := map[string]struct{}{} @@ -308,7 +313,11 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if kind == "" || name == "" { continue } - goRegisters = append(goRegisters, goRegister{edge: e, kind: kind, name: name}) + regName, _ := e.Meta["temporal_registered_name"].(string) + if regName == "" { + regName = name + } + goRegisters = append(goRegisters, goRegister{edge: e, kind: kind, name: name, regName: regName}) if e.From != "" { registerCallerIDs[e.From] = struct{}{} } @@ -334,8 +343,11 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if target == nil { continue } - stampTemporalRole(g, target, r.kind, r.name) - idx.byKindName[r.kind+"::"+r.name] = append(idx.byKindName[r.kind+"::"+r.name], target) + // Stamp + index under the canonical registered name (regName), + // which is the func-ref name unless a RegisterOptions{Name} + // override renamed it — that is the name a dispatch matches. + stampTemporalRole(g, target, r.kind, r.regName) + idx.byKindName[r.kind+"::"+r.regName] = append(idx.byKindName[r.kind+"::"+r.regName], target) } // Phase 2 — Java side. Walk the pre-collected temporal-annotation diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 9606379d..04d33e3f 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -457,3 +457,25 @@ func TestTemporalIndexLookup_LanguageGate(t *testing.T) { id, _, _ = idx.lookup("activity", "Solo", "", "") assert.Equal(t, javaNode.ID, id, "unknown caller lang keeps the unique-overall fallback") } + +func TestResolveTemporalCalls_RegisterNameOverride(t *testing.T) { + b := newTemporalTestGraph() + // Worker registers the impl ChargeCard under the override name "Charge" + // (RegisterActivityWithOptions{Name: "Charge"}). + b.addGoFunc("wf/main.go::setup", "setup", "wf/main.go", "svc") + reg := b.addGoRegister("wf/main.go::setup", "activity", "ChargeCard", "wf/main.go") + reg.Meta["temporal_registered_name"] = "Charge" + impl := b.addGoFunc("wf/activity.go::ChargeCard", "ChargeCard", "wf/activity.go", "svc") + + // A workflow dispatches by the OVERRIDE name, not the func name. + b.addGoFunc("wf/workflow.go::OrderWorkflow", "OrderWorkflow", "wf/workflow.go", "svc") + call := b.addStubCall("wf/workflow.go::OrderWorkflow", "activity", "Charge", "wf/workflow.go") + + resolved := ResolveTemporalCalls(b.g) + assert.Equal(t, 1, resolved) + assert.Equal(t, impl.ID, call.To, + "a dispatch by the override name must land on the registered impl") + assert.Equal(t, "Charge", impl.Meta["temporal_name"], + "the impl is known under the registered (override) name") + assert.Equal(t, "activity", impl.Meta["temporal_role"]) +} From fec4346906fcc17a6b2359a73769cccb65ef2515 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:39:29 +0200 Subject: [PATCH 10/18] feat(temporal): promote RegisterActivities struct methods to activities goTemporalRegisterKind flagged RegisterActivities as plural and its doc promised the resolver would promote each method of the struct, but the parser discarded the flag and the resolver had no plural handling, so w.RegisterActivities(&MyActivities{}) registered nothing resolvable. The parser now extracts the struct TYPE name from the &T{} / T{} argument and tags the register edge temporal_register_plural. The resolver locates the Go type node and promotes every exported method (via its EdgeMemberOf members) to an activity keyed by the method name, so a dispatch of ExecuteActivity(ctx, "ChargeCard", ...) resolves to (*Activities).ChargeCard. Unexported methods are skipped. Adds an indexer e2e test. --- internal/indexer/temporal_e2e_test.go | 56 ++++++++++ internal/parser/languages/golang.go | 19 +++- internal/parser/languages/golang_temporal.go | 56 ++++++++++ internal/resolver/temporal_calls.go | 104 ++++++++++++++++++- 4 files changed, 231 insertions(+), 4 deletions(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 0696ad43..ebd36008 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -283,3 +283,59 @@ func CheckStatus(ctx any, c Client) { assert.Equal(t, "query", qry.Meta["temporal_kind"]) assert.Equal(t, "get-status", qry.Meta["temporal_name"]) } + +// TestTemporalE2E_GoRegisterActivitiesPlural exercises struct registration: +// w.RegisterActivities(&Activities{}) must promote every exported method of +// the struct to a temporal activity, so a workflow that dispatches one of +// those methods by name resolves to the method node. +func TestTemporalE2E_GoRegisterActivitiesPlural(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "activities.go"), `package wf + +import "context" + +type Activities struct{} + +func (a *Activities) ChargeCard(ctx context.Context, id string) error { return nil } +func (a *Activities) Refund(ctx context.Context, id string) error { return nil } +func (a *Activities) internalHelper() {} +`) + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, id string) error { + return workflow.ExecuteActivity(ctx, "ChargeCard", id).Get(ctx, nil) +} +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setup(w Worker) { + w.RegisterActivities(&Activities{}) +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + wf := g.FindNodesByName("OrderWorkflow")[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, "workflow must have an outbound temporal.stub edge") + + // The stub must land on the promoted ChargeCard method, which must + // carry the activity role. + charge := g.FindNodesByName("ChargeCard") + require.NotEmpty(t, charge, "ChargeCard method must be indexed") + assert.Equal(t, charge[0].ID, stubCall.To, + "dispatch must resolve to the struct's promoted method") + assert.Equal(t, "activity", charge[0].Meta["temporal_role"]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 3ccbe39b..b35d81f1 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -226,6 +226,11 @@ type goDeferredCall struct { // is the name the activity/workflow is dispatched under and becomes // the resolver's index key. Empty when no Name override is present. tempRegisteredName string + // tempRegisterPlural marks a `w.RegisterActivities(&MyActivities{})` + // struct registration: tempName then holds the struct TYPE name and + // the resolver promotes every exported method of that struct to a + // temporal activity keyed by the method name. + tempRegisterPlural bool } type goDeferredTypeRef struct { @@ -374,10 +379,20 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } } } - } else if kind, _, ok := goTemporalRegisterKind(method); ok { + } else if kind, plural, ok := goTemporalRegisterKind(method); ok { // Temporal worker registration: // `w.RegisterActivity(F)` etc. - if name := goTemporalRegisterName(expr.Node, src); name != "" { + if plural { + // `w.RegisterActivities(&MyActivities{})` — every + // exported method of the struct becomes an activity. + // tempName carries the struct TYPE name; the resolver + // promotes the methods. + if st := goTemporalRegisterStructType(expr.Node, src); st != "" { + dc.tempKind = "register_" + kind + dc.tempName = st + dc.tempRegisterPlural = true + } + } else if name := goTemporalRegisterName(expr.Node, src); name != "" { dc.tempKind = "register_" + kind dc.tempName = name // RegisterActivityWithOptions / RegisterWorkflowWithOptions diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 58419227..797ab0b0 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -260,6 +260,62 @@ func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) { if c.tempRegisteredName != "" { edge.Meta["temporal_registered_name"] = c.tempRegisteredName } + if c.tempRegisterPlural { + edge.Meta["temporal_register_plural"] = true + } +} + +// goTemporalRegisterStructType returns the struct TYPE name from the first +// argument of a `w.RegisterActivities(&MyActivities{})` call — the struct +// whose exported methods are each registered as an activity. Handles the +// `&T{}` pointer and `T{}` value composite-literal forms and a qualified +// `pkg.T{}`. Returns "" when the argument is not a composite literal (e.g. +// a pre-built variable, which carries no static type here). +func goTemporalRegisterStructType(callNode *sitter.Node, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil || args.NamedChildCount() == 0 { + return "" + } + arg := args.NamedChild(0) + if arg == nil { + return "" + } + if arg.Type() == "unary_expression" { + if op := arg.ChildByFieldName("operand"); op != nil { + arg = op + } + } + if arg.Type() != "composite_literal" { + return "" + } + typ := arg.ChildByFieldName("type") + if typ == nil { + return "" + } + switch typ.Type() { + case "type_identifier", "identifier": + return typ.Content(src) + case "qualified_type": + if name := typ.ChildByFieldName("name"); name != nil { + return name.Content(src) + } + case "pointer_type": + // `&T` already unwrapped above, but a `*T` element type can appear. + if inner := typ.ChildByFieldName("type"); inner != nil { + switch inner.Type() { + case "type_identifier", "identifier": + return inner.Content(src) + case "qualified_type": + if name := inner.ChildByFieldName("name"); name != nil { + return name.Content(src) + } + } + } + } + return "" } // goTemporalRegisterNameOverride extracts the `Name:` string-literal diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 58aba213..32e8db01 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -2,6 +2,8 @@ package resolver import ( "strings" + "unicode" + "unicode/utf8" "github.com/zzet/gortex/internal/graph" ) @@ -298,8 +300,12 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed // name is the function-reference identifier (used to locate the // registered node); regName is the canonical registered name (the // index key) — they differ only when RegisterActivityWithOptions - // overrides the name via RegisterOptions{Name: "..."}. + // overrides the name via RegisterOptions{Name: "..."}. For a plural + // registration name is the struct TYPE name and regName is unused. name, regName string + // plural marks a RegisterActivities(&Struct{}) struct registration: + // every exported method of the struct is promoted to an activity. + plural bool } var goRegisters []goRegister registerCallerIDs := map[string]struct{}{} @@ -317,7 +323,8 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if regName == "" { regName = name } - goRegisters = append(goRegisters, goRegister{edge: e, kind: kind, name: name, regName: regName}) + plural, _ := e.Meta["temporal_register_plural"].(bool) + goRegisters = append(goRegisters, goRegister{edge: e, kind: kind, name: name, regName: regName, plural: plural}) if e.From != "" { registerCallerIDs[e.From] = struct{}{} } @@ -339,6 +346,19 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if caller == nil { continue } + if r.plural { + // RegisterActivities(&MyActivities{}): promote every exported + // method of the struct to an activity keyed by its method name. + typeNode := pickGoTypeNode(candidatesByName[r.name], caller) + if typeNode == nil { + continue + } + for _, m := range exportedGoMethodsOfType(g, typeNode) { + stampTemporalRole(g, m, r.kind, m.Name) + idx.byKindName[r.kind+"::"+m.Name] = append(idx.byKindName[r.kind+"::"+m.Name], m) + } + continue + } target := pickGoTemporalTarget(candidatesByName[r.name], caller) if target == nil { continue @@ -607,6 +627,86 @@ func pickGoTemporalTarget(candidates []*graph.Node, caller *graph.Node) *graph.N return nil } +// pickGoTypeNode selects the Go type node a `RegisterActivities(&T{})` +// struct registration refers to, from a name-matched candidate set, using +// the same same-file → same-repo → unique-overall locality tie-break as +// pickGoTemporalTarget. Returns nil when no unambiguous Go type matches. +func pickGoTypeNode(candidates []*graph.Node, caller *graph.Node) *graph.Node { + if caller == nil { + return nil + } + var sameFile, sameRepo, all []*graph.Node + for _, n := range candidates { + if n == nil || n.Language != "go" { + continue + } + if n.Kind != graph.KindType && n.Kind != graph.KindInterface { + continue + } + all = append(all, n) + if caller.RepoPrefix != "" && n.RepoPrefix == caller.RepoPrefix { + sameRepo = append(sameRepo, n) + } + if n.FilePath == caller.FilePath { + sameFile = append(sameFile, n) + } + } + if len(sameFile) == 1 { + return sameFile[0] + } + if len(sameRepo) == 1 { + return sameRepo[0] + } + if len(all) == 1 { + return all[0] + } + return nil +} + +// exportedGoMethodsOfType returns the exported Go method nodes of a type, +// found via the EdgeMemberOf in-edges the Go extractor emits from each +// method to its receiver type. Used to promote every method of a +// RegisterActivities(&Struct{}) registration to a temporal activity. +func exportedGoMethodsOfType(g graph.Store, typeNode *graph.Node) []*graph.Node { + if typeNode == nil { + return nil + } + var memberIDs []string + for _, ie := range g.GetInEdges(typeNode.ID) { + if ie == nil || ie.Kind != graph.EdgeMemberOf || ie.From == "" { + continue + } + memberIDs = append(memberIDs, ie.From) + } + if len(memberIDs) == 0 { + return nil + } + members := g.GetNodesByIDs(memberIDs) + var out []*graph.Node + for _, id := range memberIDs { + m := members[id] + if m == nil || m.Language != "go" || m.Kind != graph.KindMethod { + continue + } + if !isExportedGoName(m.Name) { + continue + } + out = append(out, m) + } + return out +} + +// isExportedGoName reports whether a Go identifier is exported (its first +// rune is an uppercase letter) — Temporal registers only exported methods +// of a struct passed to RegisterActivities. +func isExportedGoName(name string) bool { + if name == "" { + return false + } + r, _ := utf8.DecodeRuneInString(name) + return unicode.IsUpper(r) +} + // buildJavaMethodViews materialises two indexes over every Java // method node in the graph: methodsByFile groups nodes whose Meta has // NO "receiver" (interface methods, per the Java extractor's From 59086ea8ab02eea7fe8a910264e62280fcc055ff Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:43:21 +0200 Subject: [PATCH 11/18] feat(temporal): detect and resolve the service-side workflow-start family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds detection of client.ExecuteWorkflow(ctx, opts, workflow, args...) (workflow at position 3) and client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, ...) (workflow at position 6 — distinct from the 4th-position signal-name family). Each emits a via=temporal.start EdgeCalls edge carrying temporal_kind=workflow + temporal_name (the workflow func ref, selector, or string type name). ResolveTemporalCalls now consumes temporal.start edges alongside temporal.stub and rewrites them to the registered workflow node, so get_callers / impact on a Go workflow surfaces the services that start it — the 'who starts this workflow' relationship. Parser + indexer e2e tests. --- internal/indexer/temporal_e2e_test.go | 53 ++++++++++++++ internal/parser/languages/go_temporal_test.go | 32 +++++++++ internal/parser/languages/golang.go | 15 ++++ internal/parser/languages/golang_temporal.go | 69 +++++++++++++++++++ internal/resolver/temporal_calls.go | 7 +- 5 files changed, 175 insertions(+), 1 deletion(-) diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index ebd36008..1fd57128 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -339,3 +339,56 @@ func setup(w Worker) { "dispatch must resolve to the struct's promoted method") assert.Equal(t, "activity", charge[0].Meta["temporal_role"]) } + +// TestTemporalE2E_GoServiceStartsWorkflow exercises the workflow-start +// family: a service that calls client.ExecuteWorkflow(ctx, opts, WorkflowFn) +// must get a via=temporal.start edge resolved to the registered workflow — +// the "who starts this workflow" relationship. +func TestTemporalE2E_GoServiceStartsWorkflow(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, id string) error { return nil } +`) + writeFile(t, filepath.Join(dir, "service.go"), `package wf + +import "go.temporal.io/sdk/client" + +func StartOrder(ctx any, c client.Client, id string) error { + _, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{}, OrderWorkflow, id) + return err +} +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setup(w Worker) { + w.RegisterWorkflow(OrderWorkflow) +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + starter := g.FindNodesByName("StartOrder") + require.NotEmpty(t, starter) + wf := g.FindNodesByName("OrderWorkflow") + require.NotEmpty(t, wf) + + var start *graph.Edge + for _, e := range g.GetOutEdges(starter[0].ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.start" { + start = e + break + } + } + require.NotNil(t, start, "StartOrder must have an outbound temporal.start edge") + assert.Equal(t, "workflow", start.Meta["temporal_kind"]) + assert.Equal(t, "OrderWorkflow", start.Meta["temporal_name"]) + assert.Equal(t, wf[0].ID, start.To, + "the start edge must resolve to the registered workflow") +} diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index ac4c78ae..a548eb2f 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -635,3 +635,35 @@ func Orchestrator(ctx wf.Context) error { `) assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) } + +// --- Service-side workflow START (ExecuteWorkflow / SignalWithStartWorkflow) --- + +func TestGoTemporal_ExecuteWorkflowStart(t *testing.T) { + // client.ExecuteWorkflow(ctx, opts, WorkflowFn, args...) — workflow is + // the 3rd positional arg, reduced from the func reference. + fix := runGoExtract(t, `package svc + +func Start(c Client) { + c.ExecuteWorkflow(ctx, opts, OrderWorkflow, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.start") + require.Len(t, edges, 1) + assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_SignalWithStartWorkflow(t *testing.T) { + // client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, ...) + // — the workflow is the 6th positional arg. + fix := runGoExtract(t, `package svc + +func Start(c Client) { + c.SignalWithStartWorkflow(ctx, "order-1", "cancel", nil, opts, OrderWorkflow, 1) +} +`) + edges := temporalEdgesByVia(fix, "temporal.start") + require.Len(t, edges, 1) + assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index b35d81f1..983c574c 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -231,6 +231,11 @@ type goDeferredCall struct { // the resolver promotes every exported method of that struct to a // temporal activity keyed by the method name. tempRegisterPlural bool + // tempStartName is the workflow name when this call STARTS a workflow + // (client.ExecuteWorkflow / SignalWithStartWorkflow). `via=temporal.start` + // meta is stamped on the emitted edge and the resolver rewrites it to + // the registered workflow — the "who starts this workflow" edge. + tempStartName string } type goDeferredTypeRef struct { @@ -418,6 +423,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempOutKind = okind dc.tempName = name } + } else if wfPos, ok := goTemporalStartKind(method); ok { + // Service-side workflow START: client.ExecuteWorkflow / + // SignalWithStartWorkflow. The workflow is the wfPos-th + // positional arg (a func ref, selector, or string type name). + if name := goTemporalNthArgName(expr.Node, wfPos, src); name != "" { + dc.tempStartName = name + } } calls = append(calls, dc) if name, ok := detectGoLogEvent(expr.Node, method, src); ok { @@ -745,6 +757,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe applyGoTemporalRegisterMeta(edge, c) applyGoTemporalHandlerMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) + applyGoTemporalStartMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -759,6 +772,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe applyGoTemporalRegisterMeta(edge, c) applyGoTemporalHandlerMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) + applyGoTemporalStartMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) continue @@ -809,6 +823,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe applyGoGRPCRegisterMeta(edge, c, src, tenv) applyGoTemporalRegisterMeta(edge, c) applyGoTemporalSignalQueryMeta(edge, c) + applyGoTemporalStartMeta(edge, c) result.Edges = append(result.Edges, edge) emitGoSpawnEdge(c, callerID, target, filePath, result) } diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 797ab0b0..19d11b43 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -444,6 +444,75 @@ func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// goTemporalStartKind reports whether a method name is one of the +// service-side workflow-START helpers and, if so, returns the 1-based +// positional index of the workflow argument. +// +// client.ExecuteWorkflow(ctx, opts, workflow, args...) // workflow @ 3 +// client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, args...) // workflow @ 6 +// +// Both are client methods invoked on an arbitrary client variable, so — +// like SignalWorkflow / QueryWorkflow and the Register* helpers — they are +// matched by method name alone; ExecuteWorkflow / SignalWithStartWorkflow +// are distinctive enough across the SDK surface for that to be precise. +func goTemporalStartKind(method string) (wfPos int, ok bool) { + switch method { + case "ExecuteWorkflow": + return 3, true + case "SignalWithStartWorkflow": + return 6, true + } + return 0, false +} + +// goTemporalNthArgName reduces the n-th (1-based) positional argument of a +// call to the trailing identifier that names a workflow — handling a func +// reference (OrderWorkflow), a selector (pkg.OrderWorkflow), or a string +// type name ("OrderWorkflow"), via goTemporalNameFromExpr. Returns "" when +// the call has fewer than n positional arguments or the argument is not a +// reducible name. Unlike goTemporalNthStringLiteralArg this accepts a +// non-literal, because a workflow START usually passes the workflow +// function value, whose name is the registered type. +func goTemporalNthArgName(callNode *sitter.Node, n int, src []byte) string { + if callNode == nil || callNode.Type() != "call_expression" { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == n { + return goTemporalNameFromExpr(c, src) + } + } + return "" +} + +// applyGoTemporalStartMeta stamps `via=temporal.start` plus +// `temporal_kind=workflow` and `temporal_name` (the started workflow's +// name) onto the EdgeCalls edge derived from a client.ExecuteWorkflow / +// SignalWithStartWorkflow call. No-op when c.tempStartName is unset. The +// resolver rewrites this edge to the registered workflow node, so +// get_callers on a Go workflow surfaces the services that start it. +func applyGoTemporalStartMeta(edge *graph.Edge, c goDeferredCall) { + if edge == nil || c.tempStartName == "" { + return + } + if edge.Meta == nil { + edge.Meta = map[string]any{} + } + edge.Meta["via"] = "temporal.start" + edge.Meta["temporal_kind"] = "workflow" + edge.Meta["temporal_name"] = c.tempStartName +} + // goTemporalNthStringLiteralArg returns the unquoted value of the n-th // (1-based) positional argument of a call when that argument is a string // literal, else "". Used to extract the signal/query name from an diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 32e8db01..4fde77e8 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -115,7 +115,12 @@ func ResolveTemporalCalls(g graph.Store) int { switch v, _ := e.Meta["via"].(string); v { case "temporal.register": registerEdges = append(registerEdges, e) - case "temporal.stub": + case "temporal.stub", "temporal.start": + // temporal.stub is a workflow→activity / workflow→child-workflow + // dispatch; temporal.start is a service→workflow start + // (client.ExecuteWorkflow / SignalWithStartWorkflow). Both + // resolve the same way — rewrite to the registered handler / + // workflow found by ::. kind, _ := e.Meta["temporal_kind"].(string) name, _ := e.Meta["temporal_name"].(string) if kind == "" || name == "" { From f063d9062236f51915b7fdd21e9cb66dfa1aed2f Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:47:23 +0200 Subject: [PATCH 12/18] feat(temporal): recognise an aliased workflow-package import The dispatch / handler / SignalExternalWorkflow detectors gated on the receiver text being literally "workflow", so a file using import wf "go.temporal.io/sdk/workflow" lost dispatch AND handler AND signal-send detection at once. The extractor now resolves the workflow package's local alias from the file's import table and canonicalises a matching receiver to "workflow" before the receiver-gated detectors run. A non-workflow receiver that merely shares a name is unaffected. The three *AliasedNotDetected tests become *Detected, plus a negative test that a non-workflow receiver is still ignored when workflow is aliased. --- internal/parser/languages/go_temporal_test.go | 51 ++++++++++----- internal/parser/languages/golang.go | 16 +++-- internal/parser/languages/golang_temporal.go | 62 +++++++++++++++++++ 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index a548eb2f..d9f46443 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -167,10 +167,10 @@ func WF(ctx workflow.Context) { "only ExecuteActivity / ExecuteLocalActivity / ExecuteChildWorkflow should be stub-tagged") } -func TestGoTemporal_AliasedImportNotDetected(t *testing.T) { - // We require the receiver text to be exactly "workflow" — aliased - // imports (intentionally) miss; this test pins that contract so a - // future relaxation is a conscious decision. +func TestGoTemporal_AliasedImportDetected(t *testing.T) { + // An aliased `import wf "go.temporal.io/sdk/workflow"` is resolved from + // the file's import table and canonicalised to the "workflow" receiver, + // so dispatch through the alias is detected. fix := runGoExtract(t, `package wf import wf "go.temporal.io/sdk/workflow" @@ -179,7 +179,25 @@ func WF(ctx wf.Context) { wf.ExecuteActivity(ctx, Charge, 1) } `) - assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub")) + edges := temporalEdgesByVia(fix, "temporal.stub") + require.Len(t, edges, 1) + assert.Equal(t, "Charge", edges[0].Meta["temporal_name"]) +} + +func TestGoTemporal_NonWorkflowReceiverStillIgnored(t *testing.T) { + // A same-named receiver that is NOT the workflow package alias must + // not be misread as a dispatch — the alias gate only canonicalises the + // actual workflow import. + fix := runGoExtract(t, `package wf + +import wf "go.temporal.io/sdk/workflow" + +func WF(ctx wf.Context, other Helper) { + other.ExecuteActivity(ctx, Charge, 1) +} +`) + assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub"), + "a non-workflow receiver must not be detected even when workflow is aliased") } func TestGoTemporal_StubAndRegisterCoexistInSameFile(t *testing.T) { @@ -530,9 +548,9 @@ func OrderWorkflow(ctx workflow.Context, q string) error { "non-literal handler name must not be detected") } -func TestGoTemporal_HandlerAliasedImportNotDetected(t *testing.T) { - // Consistent with the dispatch detector: only the canonical - // "workflow" receiver alias is recognised. +func TestGoTemporal_HandlerAliasedImportDetected(t *testing.T) { + // Consistent with the dispatch detector: an aliased workflow import is + // canonicalised and the handler declaration is detected. fix := runGoExtract(t, `package wf import wf "go.temporal.io/sdk/workflow" @@ -542,7 +560,10 @@ func OrderWorkflow(ctx wf.Context) error { return nil } `) - assert.Empty(t, temporalEdgesByVia(fix, "temporal.handler")) + edges := temporalEdgesByVia(fix, "temporal.handler") + require.Len(t, edges, 1) + assert.Equal(t, "query", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "status", edges[0].Meta["temporal_name"]) } // --- Outbound signal sends / query calls ---------------------------- @@ -621,10 +642,9 @@ func Cancel(c Client, name string) error { assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) } -func TestGoTemporal_SignalExternalAliasedNotDetected(t *testing.T) { - // SignalExternalWorkflow is gated on the canonical "workflow" - // receiver (it is a workflow-package function); an aliased import - // is intentionally missed, consistent with the dispatch detector. +func TestGoTemporal_SignalExternalAliasedDetected(t *testing.T) { + // SignalExternalWorkflow is a workflow-package function; an aliased + // import is canonicalised to the "workflow" receiver and detected. fix := runGoExtract(t, `package wf import wf "go.temporal.io/sdk/workflow" @@ -633,7 +653,10 @@ func Orchestrator(ctx wf.Context) error { return wf.SignalExternalWorkflow(ctx, "order-123", "", "cancel-request", nil).Get(ctx, nil) } `) - assert.Empty(t, temporalEdgesByVia(fix, "temporal.signal-send")) + edges := temporalEdgesByVia(fix, "temporal.signal-send") + require.Len(t, edges, 1) + assert.Equal(t, "signal", edges[0].Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edges[0].Meta["temporal_name"]) } // --- Service-side workflow START (ExecuteWorkflow / SignalWithStartWorkflow) --- diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 983c574c..19ba6b11 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -285,6 +285,11 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe result.Nodes = append(result.Nodes, fileNode) imports := map[string]string{} // alias → importPath + // Local alias the Temporal workflow package is imported under in this + // file (default "workflow"; "" when not imported). The receiver-gated + // temporal detectors canonicalise a matching receiver to "workflow" so + // an aliased `import wf "go.temporal.io/sdk/workflow"` is recognised. + wfAlias := goWorkflowReceiverAlias(root, src) tenv := make(typeEnv) // paramsByFunc: enclosing-function ID → (param/receiver name → type). // Function parameters and method receivers shadow file-wide tenv at @@ -367,8 +372,11 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.grpcRegService, dc.grpcRegArgNode = svc, argNode } // Temporal workflow → activity dispatch: - // `workflow.ExecuteActivity(ctx, X, ...)` etc. - if kind, local, ok := goTemporalDispatchKind(receiver, method); ok { + // `workflow.ExecuteActivity(ctx, X, ...)` etc. Canonicalise an + // aliased workflow-package receiver (e.g. `wf`) to "workflow" + // so the receiver-gated detectors recognise it. + tempRecv := goCanonicalWorkflowReceiver(receiver, wfAlias) + if kind, local, ok := goTemporalDispatchKind(tempRecv, method); ok { argNode := goTemporalDispatchArg(expr.Node) if name := goTemporalNameFromExpr(argNode, src); name != "" { dc.tempKind = kind @@ -408,14 +416,14 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe dc.tempRegisteredName = override } } - } else if hkind, ok := goTemporalHandlerKind(receiver, method); ok { + } else if hkind, ok := goTemporalHandlerKind(tempRecv, method); ok { // Temporal in-workflow handler declaration: // `workflow.SetQueryHandler(ctx, "name", fn)` etc. if name := goTemporalHandlerName(expr.Node, src); name != "" { dc.tempHandlerKind = hkind dc.tempName = name } - } else if okind, namePos, ok := goTemporalSignalQueryOutKind(receiver, method); ok { + } else if okind, namePos, ok := goTemporalSignalQueryOutKind(tempRecv, method); ok { // Outbound signal-send / query-call against a running // workflow: SignalExternalWorkflow / SignalWorkflow / // QueryWorkflow. The name is the 4th positional literal. diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 19d11b43..8b7564cd 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -29,10 +29,72 @@ package languages import ( + "strings" + "github.com/zzet/gortex/internal/graph" sitter "github.com/zzet/gortex/internal/parser/tsitter" ) +// goWorkflowPkgPath is the canonical import path of the Temporal Go SDK +// workflow package whose helpers (ExecuteActivity, SetQueryHandler, +// SignalExternalWorkflow, …) the detectors gate on. +const goWorkflowPkgPath = "go.temporal.io/sdk/workflow" + +// goWorkflowReceiverAlias returns the local name the workflow package is +// imported under in this file — the explicit alias for +// `import wf "go.temporal.io/sdk/workflow"`, or "workflow" for a plain +// import. Returns "" when the file does not import the workflow package. +// The detectors canonicalise a matching receiver to "workflow" so an +// aliased import (`wf.ExecuteActivity(...)`) is still recognised. +func goWorkflowReceiverAlias(root *sitter.Node, src []byte) string { + if root == nil { + return "" + } + var found string + var walk func(n *sitter.Node) + walk = func(n *sitter.Node) { + if n == nil || found != "" { + return + } + if n.Type() == "import_spec" { + pathNode := n.ChildByFieldName("path") + if pathNode != nil { + p := pathNode.Content(src) + if len(p) >= 2 { + p = p[1 : len(p)-1] // strip the surrounding quotes + } + if p == goWorkflowPkgPath { + if nameNode := n.ChildByFieldName("name"); nameNode != nil { + found = nameNode.Content(src) + } else if i := strings.LastIndex(goWorkflowPkgPath, "/"); i >= 0 { + found = goWorkflowPkgPath[i+1:] + } + return + } + } + } + for i := 0; i < int(n.NamedChildCount()); i++ { + walk(n.NamedChild(i)) + if found != "" { + return + } + } + } + walk(root) + return found +} + +// goCanonicalWorkflowReceiver maps a call receiver to "workflow" when it +// matches the file's workflow-package alias, so the receiver-gated +// detectors recognise an aliased import. Other receivers pass through +// unchanged. wfAlias == "" (package not imported) is a no-op. +func goCanonicalWorkflowReceiver(receiver, wfAlias string) string { + if wfAlias != "" && receiver == wfAlias { + return "workflow" + } + return receiver +} + // goTemporalDispatchKind reports whether (receiver, method) names one // of the Temporal workflow dispatch helpers and, if so, returns the // canonical kind ("activity" or "workflow") plus whether the call is From 25d13f7f9621e8d952bdde515dedb6804450a5b0 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 16:49:14 +0200 Subject: [PATCH 13/18] docs(temporal): document the via=temporal.* edge taxonomy The new provider/consumer edge tags (temporal.handler / signal-send / query-call / start) and the register variants had no discoverable documentation. Adds a Temporal edge-taxonomy table to docs/contracts.md (via, direction, source call, temporal_kind, resolved?) plus the extra Meta (temporal_registered_name, temporal_register_plural, temporal_name_origin) and roles, and lists the temporal via values + the temporal_role Meta field in the gortex://schema MCP resource. --- docs/contracts.md | 17 ++++++++++++++++- internal/mcp/resources.go | 10 ++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/docs/contracts.md b/docs/contracts.md index 4a288735..c46a7d43 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -20,6 +20,21 @@ contracts {action: "check"} # find mismatches and orphans | **WebSocket** | Event emit/listen patterns | `emit()` | `on()` | | **Env vars** | `os.Getenv`, `process.env`, `.env` files | `Setenv` / `.env` | `Getenv` / `process.env` | | **OpenAPI** | Swagger/OpenAPI spec files | Spec paths | (linked to HTTP routes) | -| **Temporal workflows** | Go SDK `worker.RegisterActivity` / Java `@ActivityInterface` / `@WorkflowInterface` annotations | Activity / workflow function (carries `temporal_role` Meta) | `workflow.ExecuteActivity` / `ExecuteChildWorkflow` / `newActivityStub` calls | +| **Temporal workflows** | Go SDK `worker.RegisterActivity(WithOptions)` / `RegisterActivities` / Java `@ActivityInterface` / `@WorkflowInterface` annotations | Activity / workflow function (carries `temporal_role` Meta) | `workflow.ExecuteActivity` / `ExecuteChildWorkflow` / `client.ExecuteWorkflow` / handler & signal/query calls | Contracts are normalized to canonical IDs (e.g., `http::GET::/api/users/{id}`) and matched across repos to detect orphan providers/consumers and mismatches. + +## Temporal edge taxonomy + +The Go and Java extractors tag Temporal call sites with a `via` Meta value on the `EdgeCalls` edge (plus `temporal_kind` and `temporal_name`); `ResolveTemporalCalls` rewrites the resolvable ones (`temporal.stub` / `temporal.start`) to the registered handler / workflow node. Because they are ordinary `EdgeCalls`, `find_usages` / `get_callers` / `explain_change_impact` traverse them with no temporal-specific code. + +| `via` | Direction | Emitted from | `temporal_kind` | Resolved? | +|-------|-----------|--------------|-----------------|-----------| +| `temporal.register` | provider tag | `worker.RegisterActivity(WithOptions)` / `RegisterWorkflow(WithOptions)` / `RegisterActivities` | `activity` / `workflow` | indexed, not rewritten | +| `temporal.stub` | workflow → activity / child-workflow | `workflow.ExecuteActivity` / `ExecuteLocalActivity` / `ExecuteChildWorkflow` | `activity` / `workflow` | yes → registered handler | +| `temporal.start` | service → workflow | `client.ExecuteWorkflow` / `SignalWithStartWorkflow` | `workflow` | yes → registered workflow | +| `temporal.handler` | workflow exposes | `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler` (+`WithOptions`) | `query` / `signal` / `update` | provider edge | +| `temporal.signal-send` | sender → running workflow | `workflow.SignalExternalWorkflow` / `client.SignalWorkflow` | `signal` | consumer edge | +| `temporal.query-call` | caller → running workflow | `client.QueryWorkflow` | `query` | consumer edge | + +Extra Meta on these edges: `temporal_registered_name` (the `RegisterOptions{Name}` override that is the actual dispatch key), `temporal_register_plural` (a `RegisterActivities(&Struct{})` registration whose exported methods are each promoted), and `temporal_name_origin=env_default` (a dispatch name resolved from an env-var-with-literal-default, landed at the speculative tier). Node roles are stamped as `temporal_role` (`activity` / `workflow` / `activity_interface` / `workflow_interface` / `signal` / `query` / `update`). Aliased `import wf "go.temporal.io/sdk/workflow"` receivers are canonicalised before detection. diff --git a/internal/mcp/resources.go b/internal/mcp/resources.go index 68afdc86..1abe1f8a 100644 --- a/internal/mcp/resources.go +++ b/internal/mcp/resources.go @@ -209,6 +209,16 @@ func (s *Server) handleResourceSchema(_ context.Context, req mcp.ReadResourceReq - proto_type — protobuf: "message", "enum" - sql_type — SQL: "table", "view", "index", "trigger" - visibility — "private" for unexported symbols +- temporal_role — Temporal node role: activity / workflow / activity_interface / workflow_interface / signal / query / update + +## Edge Meta: via +Synthesized framework-dispatch edges carry a "via" tag on the calls edge. +Temporal (with temporal_kind + temporal_name): +- temporal.register — worker registration (provider; activity/workflow) +- temporal.stub — workflow→activity/child-workflow dispatch (resolved) +- temporal.start — service→workflow start, ExecuteWorkflow/SignalWithStartWorkflow (resolved) +- temporal.handler — workflow exposes query/signal/update handler (provider) +- temporal.signal-send / temporal.query-call — sender→running workflow (consumer) ` return []mcp.ResourceContents{ mcp.TextResourceContents{ From 89e7aa48b099f38013d56d692e3450071fa73c16 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 17:04:28 +0200 Subject: [PATCH 14/18] feat(temporal): retain constant values + dereference const-named dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real codebases name activities almost exclusively through string consts (const ChargeCardActivity = "ChargeCard"; ExecuteActivity(ctx, ChargeCardActivity, ...)), but emitConst dropped the literal so the dispatch resolved against the identifier and never matched the registered activity. Adds a queryable constant_values sidecar (node_id, repo_prefix, file_path, value) — an optional ConstantValueWriter/Reader capability implemented by both the in-memory and sqlite backends, mirroring the clone_shingles / ref_facts pattern. Kept out of the gob Meta blob (unindexable, decoded on every node load) per the design steer. The Go extractor records each KindConstant's string/numeric literal; the indexer persists them per file (repo-prefix-aware, file-scoped eviction on reindex). ResolveTemporalCalls builds a name→value deref map from the sidecar and, when a dispatch name doesn't resolve directly, retries under the const's literal value, recording temporal_const_deref on the edge. Ambiguous const names (same name, different values) are dropped so a deref is never a wrong guess. Cross-file e2e + sqlite capability unit tests. --- internal/graph/graph.go | 80 ++++++++++ internal/graph/store.go | 33 ++++ internal/graph/store_sqlite/schema.go | 17 +++ .../graph/store_sqlite/store_constvalues.go | 144 ++++++++++++++++++ .../store_sqlite/store_constvalues_test.go | 73 +++++++++ internal/indexer/const_values.go | 49 ++++++ internal/indexer/indexer.go | 2 + internal/indexer/temporal_e2e_test.go | 59 +++++++ internal/parser/extractor.go | 14 ++ internal/parser/languages/golang.go | 57 ++++++- internal/resolver/temporal_calls.go | 87 +++++++++++ 11 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 internal/graph/store_sqlite/store_constvalues.go create mode 100644 internal/graph/store_sqlite/store_constvalues_test.go create mode 100644 internal/indexer/const_values.go diff --git a/internal/graph/graph.go b/internal/graph/graph.go index d20ed78e..e8ed35df 100644 --- a/internal/graph/graph.go +++ b/internal/graph/graph.go @@ -494,6 +494,13 @@ type Graph struct { // blameEnrich is the in-memory blame-enrichment sidecar. blameEnrichMu sync.Mutex blameEnrich map[string]BlameEnrichment + + // constValues is the in-memory implementation of the ConstantValue* + // capability: a KindConstant node's literal value keyed by node id, + // alongside its owning file (for file-scoped eviction) and repo + // prefix. Guarded by constValuesMu. + constValuesMu sync.Mutex + constValues map[string]constValueEntry } // cloneShingleEntry is one in-memory clone_shingles row: the owning @@ -503,6 +510,14 @@ type cloneShingleEntry struct { shingles []uint64 } +// constValueEntry is one in-memory constant_values row: the owning repo +// prefix and file (for file-scoped eviction) plus the literal value. +type constValueEntry struct { + repoPrefix string + filePath string + value string +} + // Compile-time assertions that the in-memory *Graph satisfies the // optional per-symbol clone-shingle persistence capabilities, so the // conformance suite exercises the same code path against both backends. @@ -519,6 +534,8 @@ var ( _ BlameEnrichmentReader = (*Graph)(nil) _ ReleaseEnrichmentWriter = (*Graph)(nil) _ ReleaseEnrichmentReader = (*Graph)(nil) + _ ConstantValueWriter = (*Graph)(nil) + _ ConstantValueReader = (*Graph)(nil) ) // New creates an empty graph. @@ -611,6 +628,69 @@ func (g *Graph) LoadCloneShingles(repoPrefix string) (map[string][]uint64, error return out, nil } +// BulkSetConstantValues is the in-memory ConstantValueWriter. It records +// every (nodeID -> value) row for one repo prefix, replacing any prior +// value in place. Empty input is a no-op. +func (g *Graph) BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error { + if len(rows) == 0 { + return nil + } + g.constValuesMu.Lock() + defer g.constValuesMu.Unlock() + if g.constValues == nil { + g.constValues = make(map[string]constValueEntry, len(rows)) + } + for _, r := range rows { + if r.NodeID == "" { + continue + } + g.constValues[r.NodeID] = constValueEntry{repoPrefix: repoPrefix, filePath: r.FilePath, value: r.Value} + } + return nil +} + +// DeleteConstantValuesByFiles is the in-memory ConstantValueWriter delete +// side: it drops every row whose file is in the supplied set for the given +// repo prefix, so a reindex of those files replaces their values cleanly. +func (g *Graph) DeleteConstantValuesByFiles(repoPrefix string, files []string) error { + if len(files) == 0 { + return nil + } + fileSet := make(map[string]struct{}, len(files)) + for _, f := range files { + fileSet[f] = struct{}{} + } + g.constValuesMu.Lock() + defer g.constValuesMu.Unlock() + for id, entry := range g.constValues { + if entry.repoPrefix != repoPrefix { + continue + } + if _, ok := fileSet[entry.filePath]; ok { + delete(g.constValues, id) + } + } + return nil +} + +// ConstantValuesByNodeIDs is the in-memory ConstantValueReader. It returns +// the recorded values for the supplied node ids (omitting ids with no +// recorded value). Always returns a non-nil map and never an error. +func (g *Graph) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) { + out := make(map[string]string, len(nodeIDs)) + if len(nodeIDs) == 0 { + return out, nil + } + g.constValuesMu.Lock() + defer g.constValuesMu.Unlock() + for _, id := range nodeIDs { + if entry, ok := g.constValues[id]; ok { + out[id] = entry.value + } + } + return out, nil +} + // BulkSetChurn is the in-memory ChurnEnrichmentWriter. ChurnEnrichment // is a flat value type, so a map store needs no deep copy. func (g *Graph) BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error { diff --git a/internal/graph/store.go b/internal/graph/store.go index 7f6d787d..3f6d3d78 100644 --- a/internal/graph/store.go +++ b/internal/graph/store.go @@ -986,6 +986,39 @@ type CloneShingleReader interface { LoadCloneShingles(repoPrefix string) (map[string][]uint64, error) } +// ConstantValueWriter is an optional capability backends MAY implement +// to persist a KindConstant node's literal value (string / numeric) +// keyed by node id, in a queryable sidecar rather than the gob-encoded +// Meta blob (which is unindexable and decoded on every node load). The +// resolver reads these to dereference a const-identifier dispatch name +// (e.g. `const ChargeCardActivity = "ChargeCard"`) to its value across +// files. It is the const-value sibling of CloneShingleWriter. +// +// rows is keyed on the const node id; the value is the literal text. +// Empty input is a no-op. DeleteConstantValuesByFiles drops the rows +// for a set of re-indexed / evicted files so the snapshot stays in step +// with the live graph. +type ConstantValueWriter interface { + BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error + DeleteConstantValuesByFiles(repoPrefix string, files []string) error +} + +// ConstantValueReader is the read side of ConstantValueWriter. Returns +// the recorded constant values for the supplied node ids as a fresh map +// (node id → value); ids with no recorded value are omitted. A nil / +// empty ids slice returns an empty map. +type ConstantValueReader interface { + ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) +} + +// ConstantValueRow is one persisted constant value: the const node id, +// its owning file (for file-scoped eviction), and the literal value. +type ConstantValueRow struct { + NodeID string + FilePath string + Value string +} + // RefFact is one durable resolved-reference fact: a reference edge from // FromID resolved TO ToID with the provenance tier that resolved it. Persisted // per source file so a reference's resolution is an auditable, diffable record diff --git a/internal/graph/store_sqlite/schema.go b/internal/graph/store_sqlite/schema.go index 59a9721d..9a816d34 100644 --- a/internal/graph/store_sqlite/schema.go +++ b/internal/graph/store_sqlite/schema.go @@ -101,6 +101,23 @@ CREATE TABLE IF NOT EXISTS clone_shingles ( shingles BLOB ) WITHOUT ROWID; +-- constant_values is the per-KindConstant literal-value sidecar: one row +-- per constant whose RHS is a string / numeric literal, keyed by node_id +-- (the join key back to nodes.id). Lifting the value out of the gob Meta +-- blob keeps it queryable (and out of the every-node-load decode path) so +-- the resolver can dereference a const-identifier dispatch name to its +-- value across files. file_path scopes per-file eviction on reindex; +-- repo_prefix scopes per-repo wipes. WITHOUT ROWID — the PK index IS the +-- table, like file_mtimes / clone_shingles. +CREATE TABLE IF NOT EXISTS constant_values ( + node_id TEXT PRIMARY KEY, + repo_prefix TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + value TEXT NOT NULL DEFAULT '' +) WITHOUT ROWID; + +CREATE INDEX IF NOT EXISTS constant_values_by_file ON constant_values(repo_prefix, file_path); + -- ref_facts is the resolved-reference sidecar: one row per reference edge -- that resolved to a concrete target, recording the target + the provenance -- tier that resolved it. Denormalized file_path + lang make "all reference diff --git a/internal/graph/store_sqlite/store_constvalues.go b/internal/graph/store_sqlite/store_constvalues.go new file mode 100644 index 00000000..c1b33d20 --- /dev/null +++ b/internal/graph/store_sqlite/store_constvalues.go @@ -0,0 +1,144 @@ +package store_sqlite + +import ( + "github.com/zzet/gortex/internal/graph" +) + +// Compile-time assertions that the SQLite Store satisfies the optional +// constant-value persistence capability. A KindConstant node's literal +// value lives in this queryable sidecar (not the gob-encoded Meta blob) +// so the resolver can dereference a const-identifier dispatch name across +// files without an unindexable per-node blob decode. +var ( + _ graph.ConstantValueWriter = (*Store)(nil) + _ graph.ConstantValueReader = (*Store)(nil) +) + +// constValueChunk bounds rows per multi-row INSERT (4 params/row; 80 rows +// = 320 host params, well under SQLite's 999 default). +const constValueChunk = 80 + +// BulkSetConstantValues persists constant values for one repo prefix in a +// single transaction, chunked under the host-parameter limit. Idempotent +// on the node_id primary key. Empty input is a no-op. +func (s *Store) BulkSetConstantValues(repoPrefix string, rows []graph.ConstantValueRow) error { + if len(rows) == 0 { + return nil + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op + + for start := 0; start < len(rows); start += constValueChunk { + end := start + constValueChunk + if end > len(rows) { + end = len(rows) + } + batch := rows[start:end] + args := make([]any, 0, len(batch)*4) + stmt := make([]byte, 0, 96+len(batch)*16) + stmt = append(stmt, "INSERT OR REPLACE INTO constant_values (node_id, repo_prefix, file_path, value) VALUES "...) + for i, r := range batch { + if i > 0 { + stmt = append(stmt, ',') + } + stmt = append(stmt, "(?, ?, ?, ?)"...) + args = append(args, r.NodeID, repoPrefix, r.FilePath, r.Value) + } + if _, err := tx.Exec(string(stmt), args...); err != nil { + return err + } + } + return tx.Commit() +} + +// DeleteConstantValuesByFiles drops all constant values sourced in the +// supplied files for one repo prefix, chunked into `file_path IN (…)` +// DELETEs. Empty input is a no-op. +func (s *Store) DeleteConstantValuesByFiles(repoPrefix string, files []string) error { + if len(files) == 0 { + return nil + } + s.writeMu.Lock() + defer s.writeMu.Unlock() + + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op + + for start := 0; start < len(files); start += constValueChunk { + end := start + constValueChunk + if end > len(files) { + end = len(files) + } + chunk := files[start:end] + args := make([]any, 0, len(chunk)+1) + args = append(args, repoPrefix) + stmt := make([]byte, 0, 64+len(chunk)*2) + stmt = append(stmt, "DELETE FROM constant_values WHERE repo_prefix = ? AND file_path IN ("...) + for i, f := range chunk { + if i > 0 { + stmt = append(stmt, ',') + } + stmt = append(stmt, '?') + args = append(args, f) + } + stmt = append(stmt, ')') + if _, err := tx.Exec(string(stmt), args...); err != nil { + return err + } + } + return tx.Commit() +} + +// ConstantValuesByNodeIDs returns the persisted values for the supplied +// node ids (omitting ids with no recorded value). Always non-nil. +func (s *Store) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) { + out := make(map[string]string, len(nodeIDs)) + if len(nodeIDs) == 0 { + return out, nil + } + for start := 0; start < len(nodeIDs); start += constValueChunk { + end := start + constValueChunk + if end > len(nodeIDs) { + end = len(nodeIDs) + } + chunk := nodeIDs[start:end] + args := make([]any, 0, len(chunk)) + stmt := make([]byte, 0, 64+len(chunk)*2) + stmt = append(stmt, "SELECT node_id, value FROM constant_values WHERE node_id IN ("...) + for i, id := range chunk { + if i > 0 { + stmt = append(stmt, ',') + } + stmt = append(stmt, '?') + args = append(args, id) + } + stmt = append(stmt, ')') + rows, err := s.db.Query(string(stmt), args...) + if err != nil { + return nil, err + } + for rows.Next() { + var id, val string + if err := rows.Scan(&id, &val); err != nil { + _ = rows.Close() + return nil, err + } + out[id] = val + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + _ = rows.Close() + } + return out, nil +} diff --git a/internal/graph/store_sqlite/store_constvalues_test.go b/internal/graph/store_sqlite/store_constvalues_test.go new file mode 100644 index 00000000..62c88275 --- /dev/null +++ b/internal/graph/store_sqlite/store_constvalues_test.go @@ -0,0 +1,73 @@ +package store_sqlite_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/graph/store_sqlite" +) + +func openConstValStore(t *testing.T) *store_sqlite.Store { + t.Helper() + s, err := store_sqlite.Open(filepath.Join(t.TempDir(), "cv.sqlite")) + require.NoError(t, err) + t.Cleanup(func() { _ = s.Close() }) + return s +} + +func TestConstantValues_Roundtrip(t *testing.T) { + s := openConstValStore(t) + rows := []graph.ConstantValueRow{ + {NodeID: "a.go::ChargeCardActivity", FilePath: "a.go", Value: "ChargeCard"}, + {NodeID: "a.go::RefundActivity", FilePath: "a.go", Value: "Refund"}, + } + require.NoError(t, s.BulkSetConstantValues("repo", rows)) + + got, err := s.ConstantValuesByNodeIDs([]string{"a.go::ChargeCardActivity", "a.go::RefundActivity", "missing"}) + require.NoError(t, err) + assert.Equal(t, "ChargeCard", got["a.go::ChargeCardActivity"]) + assert.Equal(t, "Refund", got["a.go::RefundActivity"]) + _, ok := got["missing"] + assert.False(t, ok) +} + +func TestConstantValues_DeleteByFile(t *testing.T) { + s := openConstValStore(t) + require.NoError(t, s.BulkSetConstantValues("repo", []graph.ConstantValueRow{ + {NodeID: "a.go::X", FilePath: "a.go", Value: "vx"}, + {NodeID: "b.go::Y", FilePath: "b.go", Value: "vy"}, + })) + require.NoError(t, s.DeleteConstantValuesByFiles("repo", []string{"a.go"})) + + got, err := s.ConstantValuesByNodeIDs([]string{"a.go::X", "b.go::Y"}) + require.NoError(t, err) + _, gone := got["a.go::X"] + assert.False(t, gone, "a.go's value must be deleted") + assert.Equal(t, "vy", got["b.go::Y"], "b.go's value must remain") +} + +func TestConstantValues_Replace(t *testing.T) { + s := openConstValStore(t) + require.NoError(t, s.BulkSetConstantValues("repo", []graph.ConstantValueRow{ + {NodeID: "a.go::X", FilePath: "a.go", Value: "old"}, + })) + require.NoError(t, s.BulkSetConstantValues("repo", []graph.ConstantValueRow{ + {NodeID: "a.go::X", FilePath: "a.go", Value: "new"}, + })) + got, err := s.ConstantValuesByNodeIDs([]string{"a.go::X"}) + require.NoError(t, err) + assert.Equal(t, "new", got["a.go::X"], "INSERT OR REPLACE must update by node_id PK") +} + +func TestConstantValues_EmptyNoop(t *testing.T) { + s := openConstValStore(t) + require.NoError(t, s.BulkSetConstantValues("repo", nil)) + require.NoError(t, s.DeleteConstantValuesByFiles("repo", nil)) + got, err := s.ConstantValuesByNodeIDs(nil) + require.NoError(t, err) + assert.Empty(t, got) +} diff --git a/internal/indexer/const_values.go b/internal/indexer/const_values.go new file mode 100644 index 00000000..a9fb30b2 --- /dev/null +++ b/internal/indexer/const_values.go @@ -0,0 +1,49 @@ +package indexer + +import ( + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" +) + +// persistConstValues writes a file's extracted constant literal values to +// the backend's constant_values sidecar (when it implements +// graph.ConstantValueWriter — the on-disk backend and the in-memory +// store both do). The resolver reads these to dereference a +// const-identifier Temporal dispatch name to its literal value across +// files. +// +// ExtractionResult.ConstValues carries pre-repo-prefix node ids / file +// paths (they are stamped at extraction time, before applyRepoPrefix +// rewrites the node ids). This helper replicates that same prefix +// transform so the persisted node_id matches the final graph node id the +// resolver looks up by, independent of when applyRepoPrefix ran. Each +// file's prior rows are deleted first so a reindex replaces them cleanly. +func (idx *Indexer) persistConstValues(result *parser.ExtractionResult) { + if result == nil || len(result.ConstValues) == 0 { + return + } + cw, ok := idx.graph.(graph.ConstantValueWriter) + if !ok { + return + } + prefix := "" + if idx.repoPrefix != "" { + prefix = idx.repoPrefix + "/" + } + rows := make([]graph.ConstantValueRow, 0, len(result.ConstValues)) + fileSet := map[string]struct{}{} + for _, cv := range result.ConstValues { + rows = append(rows, graph.ConstantValueRow{ + NodeID: prefix + cv.NodeID, + FilePath: prefix + cv.FilePath, + Value: cv.Value, + }) + fileSet[prefix+cv.FilePath] = struct{}{} + } + files := make([]string, 0, len(fileSet)) + for f := range fileSet { + files = append(files, f) + } + _ = cw.DeleteConstantValuesByFiles(idx.repoPrefix, files) + _ = cw.BulkSetConstantValues(idx.repoPrefix, rows) +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 615b9bbd..f99f274b 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -2219,6 +2219,7 @@ func (idx *Indexer) IndexCtx(ctx context.Context, root string) (result *IndexRes // of 102 workers blocked on lockTwoWrite under the // per-edge path during cold-start warmup. idx.graph.AddBatch(result.Nodes, result.Edges) + idx.persistConstValues(result) if !skipped && fileGraphPath != "" { exts := contractExtractorsByLang[lang] @@ -2746,6 +2747,7 @@ func (idx *Indexer) indexFile(filePath string, resolve bool) error { idx.applyRepoPrefix(result.Nodes, result.Edges) idx.graph.AddBatch(result.Nodes, result.Edges) + idx.persistConstValues(result) // Add new symbols to search index. shouldIndexForSearch enforces // the same SkipSearch filter used by the bulk and upgrade paths. diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 1fd57128..5d6630dd 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -392,3 +392,62 @@ func setup(w Worker) { assert.Equal(t, wf[0].ID, start.To, "the start edge must resolve to the registered workflow") } + +// TestTemporalE2E_GoConstNamedActivity exercises cross-file const-value +// retention + dereference: the activity name is a string const declared in +// a separate file (the dominant real-world shape), and the dispatch names +// it through the const identifier. The pipeline must persist the const +// value and dereference it so the stub resolves to the activity. +func TestTemporalE2E_GoConstNamedActivity(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "constants.go"), `package wf + +const ChargeCardActivity = "ChargeCard" +`) + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, id string) error { + return workflow.ExecuteActivity(ctx, ChargeCardActivity, id).Get(ctx, nil) +} +`) + writeFile(t, filepath.Join(dir, "activity.go"), `package wf + +import "context" + +func ChargeCard(ctx context.Context, id string) error { return nil } +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setup(w Worker) { + w.RegisterWorkflow(OrderWorkflow) + w.RegisterActivityWithOptions(ChargeCard, RegisterOptions{Name: "ChargeCard"}) +} +`) + + g := graph.New() + idx := newTestIndexer(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + wf := g.FindNodesByName("OrderWorkflow")[0] + activity := g.FindNodesByName("ChargeCard") + require.NotEmpty(t, activity) + + 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, "workflow must have an outbound temporal.stub edge") + assert.Equal(t, "ChargeCardActivity", stubCall.Meta["temporal_name"], + "the stub keeps the const identifier as its name") + assert.Equal(t, activity[0].ID, stubCall.To, + "the const-named dispatch must dereference to the activity") + assert.Equal(t, "ChargeCard", stubCall.Meta["temporal_const_deref"], + "the dereferenced literal value is recorded on the edge") +} diff --git a/internal/parser/extractor.go b/internal/parser/extractor.go index 02f44521..4ebecabc 100644 --- a/internal/parser/extractor.go +++ b/internal/parser/extractor.go @@ -22,4 +22,18 @@ type ExtractionResult struct { Nodes []*graph.Node Edges []*graph.Edge Tree *ParseTree + // ConstValues carries the literal value of each KindConstant node + // whose RHS is a string / numeric literal, for the indexer to persist + // in the queryable constant_values sidecar (kept out of the gob Meta + // blob). Keyed by the const node id. Empty for languages / files with + // no literal constants. + ConstValues []ConstValue +} + +// ConstValue is one constant's persisted literal value: the const node id, +// its file (for file-scoped eviction), and the literal text. +type ConstValue struct { + NodeID string + FilePath string + Value string } diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 19ba6b11..9c58ee20 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -512,7 +512,7 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe e.emitVar(m, filePath, fileID, result, tenv) case m.Captures["const.def"] != nil: - e.emitConst(m, filePath, fileID, result) + e.emitConst(m, filePath, fileID, src, result) case m.Captures["svar.def"] != nil: e.recordShortVarType(m, src, tenv) @@ -1710,7 +1710,7 @@ func (e *GoExtractor) emitVar(m parser.QueryResult, filePath, fileID string, res } } -func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, result *parser.ExtractionResult) { +func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult) { nameCap := m.Captures["const.name"] def := m.Captures["const.def"] if nameCap == nil || nameCap.Text == "" || nameCap.Text == "_" { @@ -1739,6 +1739,59 @@ func (e *GoExtractor) emitConst(m parser.QueryResult, filePath, fileID string, r result.Edges = append(result.Edges, &graph.Edge{ From: fileID, To: id, Kind: graph.EdgeDefines, FilePath: filePath, Line: def.StartLine + 1, }) + // Retain the literal value (string / numeric) for the queryable + // constant_values sidecar, so the resolver can dereference a + // 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 { + result.ConstValues = append(result.ConstValues, parser.ConstValue{ + NodeID: id, FilePath: filePath, Value: v, + }) + } + } +} + +// 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) { + 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 + count := 0 + for i := 0; i < int(spec.NamedChildCount()); i++ { + c := spec.NamedChild(i) + if c != nil && c.Type() == "const_spec" { + found = c + count++ + } + } + if count != 1 || found == nil { + return "", false + } + spec = found + } + valueList := spec.ChildByFieldName("value") + if valueList == nil || valueList.NamedChildCount() != 1 { + return "", false + } + v := valueList.NamedChild(0) + if v == nil { + return "", false + } + switch v.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(v, src), true + case "int_literal", "float_literal": + return v.Content(src), true + } + return "", false } // containsGoIotaBlock reports whether a const_declaration's source diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 4fde77e8..01f8c15b 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -162,6 +162,17 @@ func ResolveTemporalCalls(g graph.Store) int { } callerNodes := g.GetNodesByIDs(fromList) + // Const-dereference map: a dispatch named through a string const + // (`const ChargeCardActivity = "ChargeCard"`) reaches the resolver as + // the identifier "ChargeCardActivity"; map it to the literal value so + // the lookup keys on the registered name. Built once from the + // queryable constant_values sidecar. + stubNames := make([]string, 0, len(stubs)) + for _, s := range stubs { + stubNames = append(stubNames, s.name) + } + derefByName := buildConstDerefMap(g, stubNames) + for _, s := range stubs { e := s.edge callerRepo := "" @@ -171,6 +182,17 @@ func ResolveTemporalCalls(g graph.Store) int { callerLang = from.Language } handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo, callerLang) + // When the direct name didn't resolve, try dereferencing it as a + // string constant and re-looking-up under the literal value. + constDeref := "" + if handlerID == "" { + if v, ok := derefByName[s.name]; ok && v != "" { + if hID, o, c := idx.lookup(s.kind, v, callerRepo, callerLang); hID != "" { + handlerID, origin, conf = hID, o, c + constDeref = v + } + } + } // When the name came from an env-var-with-literal-default // variable, the value is a best-guess: land the resolved edge at @@ -205,6 +227,11 @@ func ResolveTemporalCalls(g graph.Store) int { if envDefault { e.Meta[graph.MetaSpeculative] = true } + if constDeref != "" { + e.Meta["temporal_const_deref"] = constDeref + } else { + delete(e.Meta, "temporal_const_deref") + } StampSynthesized(e, SynthTemporalStub) resolved++ } else { @@ -213,6 +240,7 @@ func ResolveTemporalCalls(g graph.Store) int { e.ConfidenceLabel = "" delete(e.Meta, "temporal_resolution") delete(e.Meta, graph.MetaSpeculative) + delete(e.Meta, "temporal_const_deref") UnstampSynthesized(e) } reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) @@ -712,6 +740,65 @@ func isExportedGoName(name string) bool { return unicode.IsUpper(r) } +// buildConstDerefMap resolves the names of string constants used as +// Temporal dispatch identifiers to their literal values, read from the +// queryable constant_values sidecar. Returns name → value for every name +// that is a string const with a single unambiguous value across the +// workspace; a name with conflicting values in different files (e.g. the +// same const name defined twice with different literals) is dropped so a +// dereference is never a wrong guess. Returns nil when the backend does +// not implement ConstantValueReader. +func buildConstDerefMap(g graph.Store, names []string) map[string]string { + reader, ok := g.(graph.ConstantValueReader) + if !ok || len(names) == 0 { + return nil + } + nameSet := make(map[string]struct{}, len(names)) + for _, n := range names { + nameSet[n] = struct{}{} + } + uniq := make([]string, 0, len(nameSet)) + for n := range nameSet { + uniq = append(uniq, n) + } + candByName := g.FindNodesByNames(uniq) + idToName := map[string]string{} + var constIDs []string + for name, cands := range candByName { + for _, n := range cands { + if n == nil || n.Kind != graph.KindConstant { + continue + } + constIDs = append(constIDs, n.ID) + idToName[n.ID] = name + } + } + if len(constIDs) == 0 { + return nil + } + vals, err := reader.ConstantValuesByNodeIDs(constIDs) + if err != nil || len(vals) == 0 { + return nil + } + out := make(map[string]string, len(vals)) + ambiguous := map[string]struct{}{} + for id, v := range vals { + name := idToName[id] + if name == "" || v == "" { + continue + } + if existing, seen := out[name]; seen && existing != v { + ambiguous[name] = struct{}{} + continue + } + out[name] = v + } + for name := range ambiguous { + delete(out, name) + } + return out +} + // buildJavaMethodViews materialises two indexes over every Java // method node in the graph: methodsByFile groups nodes whose Meta has // NO "receiver" (interface methods, per the Java extractor's From c32c9c7e2c60f83ea28e937527cb9fea4168cc4a Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 17:11:05 +0200 Subject: [PATCH 15/18] feat(temporal): compute Java canonical Temporal names (G2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Java side indexed methods under their bare identifier, while the Go side registers under the function / type name and the real Temporal wire name follows the SDK defaults — so the two never lined up for a cross-language join. The resolver now computes each Java method's canonical Temporal name and indexes / stamps it under that string: an explicit @XxxMethod(name=) wins (parsed from the EdgeAnnotated args); an activity defaults to its method name capitalized, prefixed by @ActivityInterface(namePrefix=); a workflow's type is the interface simple name; signal/query/update keep the method name. This is the prerequisite that makes a name-based Java<->Go match correct. Adds helper unit tests and canonical-name assertions to the Java propagation tests. --- internal/resolver/temporal_calls.go | 115 ++++++++++++++++++++--- internal/resolver/temporal_calls_test.go | 30 ++++++ 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 01f8c15b..c77038a9 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -409,6 +409,7 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed type javaAnno struct { fromID string ifaceRole, methodRole string + args string // raw annotation inner-parens text } var javaAnnos []javaAnno annoFromIDs := map[string]struct{}{} @@ -420,7 +421,8 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if role == "" && methodRole == "" { continue } - javaAnnos = append(javaAnnos, javaAnno{fromID: e.From, ifaceRole: role, methodRole: methodRole}) + args, _ := e.Meta["args"].(string) + javaAnnos = append(javaAnnos, javaAnno{fromID: e.From, ifaceRole: role, methodRole: methodRole, args: args}) if e.From != "" { annoFromIDs[e.From] = struct{}{} } @@ -432,8 +434,9 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed annoFromNodes := g.GetNodesByIDs(annoFromList) type javaIfaceTag struct { - ifaceID string - role string // "activity_interface" / "workflow_interface" + ifaceID string + role string // "activity_interface" / "workflow_interface" + namePrefix string // @ActivityInterface(namePrefix = "...") } var javaIfaces []javaIfaceTag for _, a := range javaAnnos { @@ -441,17 +444,25 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if from == nil { continue } - // Method-level annotation: stamp directly. + // Method-level annotation: stamp + index under the canonical + // Temporal name (explicit @XxxMethod(name=) > activity Capitalize > + // bare method name) so it keys off the same string a matching Go + // registration uses. if a.methodRole != "" && (from.Kind == graph.KindMethod || from.Kind == graph.KindFunction) { - stampTemporalRole(g, from, a.methodRole, from.Name) - idx.byKindName[normaliseTemporalKind(a.methodRole)+"::"+from.Name] = append( - idx.byKindName[normaliseTemporalKind(a.methodRole)+"::"+from.Name], from) + canonical := javaMethodCanonicalName(a.methodRole, from.Name, a.args) + stampTemporalRole(g, from, a.methodRole, canonical) + key := normaliseTemporalKind(a.methodRole) + "::" + canonical + idx.byKindName[key] = append(idx.byKindName[key], from) continue } // Interface-level annotation: queue for the propagation pass. if a.ifaceRole != "" && from.Kind == graph.KindInterface { stampTemporalRole(g, from, a.ifaceRole, from.Name) - javaIfaces = append(javaIfaces, javaIfaceTag{ifaceID: from.ID, role: a.ifaceRole}) + javaIfaces = append(javaIfaces, javaIfaceTag{ + ifaceID: from.ID, + role: a.ifaceRole, + namePrefix: javaAnnotationStringArg(a.args, "namePrefix"), + }) } } @@ -507,10 +518,22 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if iface == nil { continue } + // Canonical Temporal name for a method of this interface: a + // workflow's type is the interface simple name; an activity's type + // is its method name capitalized, with the @ActivityInterface + // namePrefix prepended. Keyed the same for interface and impl + // methods (same method name) so a dispatch lands on either. + canonicalFor := func(m *graph.Node) string { + if t.role == "workflow_interface" { + return iface.Name + } + return t.namePrefix + capitalizeASCII(m.Name) + } ifaceMethods := collectJavaInterfaceMethodsFromIndex(iface, javaMethodsByFile) for _, m := range ifaceMethods { - stampTemporalRole(g, m, methodRole, m.Name) - idx.byKindName[methodRole+"::"+m.Name] = append(idx.byKindName[methodRole+"::"+m.Name], m) + canonical := canonicalFor(m) + stampTemporalRole(g, m, methodRole, canonical) + idx.byKindName[methodRole+"::"+canonical] = append(idx.byKindName[methodRole+"::"+canonical], m) } // Propagate to implementing classes' methods. implMethodNames := map[string]struct{}{} @@ -526,8 +549,9 @@ func buildTemporalIndex(g graph.Store, registerEdges, annotatedEdges []*graph.Ed if _, ok := implMethodNames[m.Name]; !ok { continue } - stampTemporalRole(g, m, methodRole, m.Name) - idx.byKindName[methodRole+"::"+m.Name] = append(idx.byKindName[methodRole+"::"+m.Name], m) + canonical := canonicalFor(m) + stampTemporalRole(g, m, methodRole, canonical) + idx.byKindName[methodRole+"::"+canonical] = append(idx.byKindName[methodRole+"::"+canonical], m) } } } @@ -558,6 +582,73 @@ func temporalRoleForJavaAnnotation(annoID string) (ifaceRole, methodRole string) return "", "" } +// javaAnnotationStringArg extracts the value of a `key = "value"` argument +// from an annotation's raw inner-parens text (the EdgeAnnotated Meta +// "args"), e.g. javaAnnotationStringArg(`name = "ChargeCard"`, "name") == +// "ChargeCard". Matched on a word boundary so a "name" lookup does not +// match "namePrefix". Returns "" when the key is absent or unquoted. +func javaAnnotationStringArg(args, key string) string { + for i := 0; i+len(key) <= len(args); i++ { + if args[i:i+len(key)] != key { + continue + } + if i > 0 { + if b := args[i-1]; b != ' ' && b != ',' && b != '(' { + continue + } + } + j := i + len(key) + for j < len(args) && args[j] == ' ' { + j++ + } + if j >= len(args) || args[j] != '=' { + continue + } + rest := args[j+1:] + q := strings.IndexByte(rest, '"') + if q < 0 { + return "" + } + rest = rest[q+1:] + end := strings.IndexByte(rest, '"') + if end < 0 { + return "" + } + return rest[:end] + } + return "" +} + +// capitalizeASCII upper-cases the first rune of s (Temporal's Java SDK +// derives an activity's default type from the method name with the first +// letter capitalized). +func capitalizeASCII(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[size:] +} + +// javaMethodCanonicalName computes the canonical Temporal name a Java +// method-level annotation registers under, so the resolver keys it off the +// same string a matching Go registration would use: +// - an explicit @XxxMethod(name = "...") always wins; +// - an activity method defaults to its name with the first letter +// capitalized (the Java SDK default activity type); +// - signal / query / update / workflow methods default to the bare +// method name (signal/query/update names match by string at runtime; +// a workflow's type is usually the interface name, handled in Phase 3). +func javaMethodCanonicalName(role, methodName, args string) string { + if explicit := javaAnnotationStringArg(args, "name"); explicit != "" { + return explicit + } + if role == "activity" { + return capitalizeASCII(methodName) + } + return methodName +} + // normaliseTemporalKind collapses the seven role tags down to the two // kinds that drive stub-call lookup ("activity" / "workflow"). Signal // / query / update handlers are workflow methods, not separate kinds. diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 04d33e3f..d1ff0496 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -329,6 +329,12 @@ func TestResolveTemporalCalls_JavaActivityInterfacePropagation(t *testing.T) { assert.Equal(t, "activity", ifaceMethods["shipOrder"].Meta["temporal_role"]) assert.Equal(t, "activity", implMethods["chargeCard"].Meta["temporal_role"], "impl methods tagged via interface chain") assert.Equal(t, "activity", implMethods["shipOrder"].Meta["temporal_role"]) + // G2: an activity's canonical Temporal type is its method name with the + // first letter capitalized (the Java SDK default), keyed off the same + // string a Go RegisterActivity would use. + assert.Equal(t, "ChargeCard", ifaceMethods["chargeCard"].Meta["temporal_name"], + "activity canonical name is the capitalized method name") + assert.Equal(t, "ChargeCard", implMethods["chargeCard"].Meta["temporal_name"]) } func TestResolveTemporalCalls_JavaWorkflowInterfacePropagation(t *testing.T) { @@ -347,6 +353,30 @@ func TestResolveTemporalCalls_JavaWorkflowInterfacePropagation(t *testing.T) { assert.Equal(t, "workflow_interface", iface.Meta["temporal_role"]) assert.Equal(t, "workflow", ifaceMethods["processOrder"].Meta["temporal_role"]) assert.Equal(t, "workflow", implMethods["processOrder"].Meta["temporal_role"]) + // G2: a workflow's canonical Temporal type is the interface simple + // name (not the method name), so a Go service that starts this + // workflow by type matches it. + assert.Equal(t, "OrderWorkflow", ifaceMethods["processOrder"].Meta["temporal_name"], + "workflow canonical name is the interface simple name") + assert.Equal(t, "OrderWorkflow", implMethods["processOrder"].Meta["temporal_name"]) +} + +func TestJavaTemporalCanonicalNameHelpers(t *testing.T) { + assert.Equal(t, "ChargeCard", javaAnnotationStringArg(`name = "ChargeCard"`, "name")) + assert.Equal(t, "Foo_", javaAnnotationStringArg(`namePrefix = "Foo_"`, "namePrefix")) + // "name" lookup must not match the "namePrefix" key. + assert.Equal(t, "", javaAnnotationStringArg(`namePrefix = "Foo_"`, "name")) + assert.Equal(t, "", javaAnnotationStringArg(``, "name")) + + assert.Equal(t, "ChargeCard", capitalizeASCII("chargeCard")) + assert.Equal(t, "X", capitalizeASCII("x")) + assert.Equal(t, "", capitalizeASCII("")) + + // explicit name wins; activity defaults to capitalized; others to bare. + assert.Equal(t, "Override", javaMethodCanonicalName("activity", "chargeCard", `name = "Override"`)) + assert.Equal(t, "ChargeCard", javaMethodCanonicalName("activity", "chargeCard", "")) + assert.Equal(t, "status", javaMethodCanonicalName("query", "status", "")) + assert.Equal(t, "getStatus", javaMethodCanonicalName("query", "getStatus", "")) } func TestResolveTemporalCalls_JavaSignalAndQueryMethods(t *testing.T) { From 7e451a14e7c1aefb0b1ef24a46c60c7d87a0637d Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 17:18:45 +0200 Subject: [PATCH 16/18] feat(temporal): cross-language Java->Go workflow join (G1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the polyglot gap: a Java service that starts a workflow implemented in a Go repo is now linked to the Go workflow node. Java side: the extractor detects client.newWorkflowStub(OrderWorkflow.class, ...) / newUntypedWorkflowStub("OrderWorkflow") and emits a via=temporal.start consumer edge keyed by the workflow's canonical type name (the class literal's simple name, matching the Java SDK default and the name a Go RegisterWorkflow uses). Resolver side: when a consumer (temporal.start / stub) has no same-language handler, lookupCrossLang matches a unique candidate in a DIFFERENT language by canonical name and lands the edge at the speculative tier (OriginSpeculative, conf 0.4, MetaSpeculative + temporal_cross_lang) — a by-name match across a type-system boundary, hidden from default queries. Builds on the G2 canonical names so the strings line up. get_callers / impact on a Go workflow now surface the Java services that start it. Parser, resolver, and Go+Java e2e tests. --- internal/indexer/indexer_test.go | 11 ++++ internal/indexer/temporal_e2e_test.go | 58 ++++++++++++++++ internal/parser/languages/java.go | 34 +++++++++- internal/parser/languages/java_temporal.go | 66 +++++++++++++++++++ .../parser/languages/java_temporal_test.go | 61 +++++++++++++++++ internal/resolver/temporal_calls.go | 57 +++++++++++++++- internal/resolver/temporal_calls_test.go | 33 ++++++++++ 7 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 internal/parser/languages/java_temporal.go diff --git a/internal/indexer/indexer_test.go b/internal/indexer/indexer_test.go index 1b12e725..6152f579 100644 --- a/internal/indexer/indexer_test.go +++ b/internal/indexer/indexer_test.go @@ -72,6 +72,17 @@ func newTestIndexer(g graph.Store) *Indexer { return New(g, reg, cfg, zap.NewNop()) } +// newTestIndexerGoJava registers both the Go and Java extractors — used by +// the cross-language Temporal join tests. +func newTestIndexerGoJava(g graph.Store) *Indexer { + reg := parser.NewRegistry() + reg.Register(languages.NewGoExtractor()) + reg.Register(languages.NewJavaExtractor()) + cfg := config.Default().Index + cfg.Workers = 2 + return New(g, reg, cfg, zap.NewNop()) +} + func TestIndex_SingleGoFile(t *testing.T) { dir := t.TempDir() writeFile(t, filepath.Join(dir, "main.go"), `package main diff --git a/internal/indexer/temporal_e2e_test.go b/internal/indexer/temporal_e2e_test.go index 5d6630dd..c8424573 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -451,3 +451,61 @@ func setup(w Worker) { assert.Equal(t, "ChargeCard", stubCall.Meta["temporal_const_deref"], "the dereferenced literal value is recorded on the edge") } + +// TestTemporalE2E_CrossLangJavaStartsGoWorkflow exercises the full +// cross-language join: a Java service that creates a workflow stub for a +// workflow implemented (and registered) in Go must get a via=temporal.start +// edge that resolves to the Go workflow node, at the speculative tier. +func TestTemporalE2E_CrossLangJavaStartsGoWorkflow(t *testing.T) { + dir := t.TempDir() + + writeFile(t, filepath.Join(dir, "workflow.go"), `package wf + +import "go.temporal.io/sdk/workflow" + +func OrderWorkflow(ctx workflow.Context, id string) error { return nil } +`) + writeFile(t, filepath.Join(dir, "main.go"), `package wf + +func setup(w Worker) { + w.RegisterWorkflow(OrderWorkflow) +} +`) + writeFile(t, filepath.Join(dir, "OrderService.java"), `public class OrderService { + public void start(WorkflowClient client) { + OrderWorkflow wf = client.newWorkflowStub(OrderWorkflow.class, options); + wf.processOrder("id"); + } +} +`) + + g := graph.New() + idx := newTestIndexerGoJava(g) + _, err := idx.Index(dir) + require.NoError(t, err) + + javaStart := g.FindNodesByName("start") + require.NotEmpty(t, javaStart, "Java start method must be indexed") + goWf := g.FindNodesByName("OrderWorkflow") + require.NotEmpty(t, goWf) + var goWfID string + for _, n := range goWf { + if n.Language == "go" { + goWfID = n.ID + } + } + require.NotEmpty(t, goWfID) + + var start *graph.Edge + for _, e := range g.GetOutEdges(javaStart[0].ID) { + if e != nil && e.Meta != nil && e.Meta["via"] == "temporal.start" { + start = e + break + } + } + require.NotNil(t, start, "Java service must have an outbound temporal.start edge") + assert.Equal(t, goWfID, start.To, + "the Java start must cross-resolve to the Go workflow") + assert.Equal(t, true, start.Meta["temporal_cross_lang"]) + assert.Equal(t, graph.OriginSpeculative, start.Origin) +} diff --git a/internal/parser/languages/java.go b/internal/parser/languages/java.go index 0105118b..e41be942 100644 --- a/internal/parser/languages/java.go +++ b/internal/parser/languages/java.go @@ -84,6 +84,13 @@ type javaDeferredCall struct { receiver string // selector receiver text (empty for plain call) line int // 1-based call_expression start line isSelector bool + // tempStartWorkflow is the workflow type name when this call starts a + // Temporal workflow (`client.newWorkflowStub(OrderWorkflow.class, …)` + // or `newUntypedWorkflowStub("OrderWorkflow")`). A via=temporal.start + // edge keyed by this name is emitted in the post-pass, and the + // resolver cross-resolves it to the workflow's implementation (which + // may live in a Go repo). + tempStartWorkflow string } // javaDeferredVar buffers a variable declaration for the post-pass @@ -170,12 +177,17 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction case m.Captures["callm.expr"] != nil: expr := m.Captures["callm.expr"] - calls = append(calls, javaDeferredCall{ - name: m.Captures["callm.method"].Text, + method := m.Captures["callm.method"].Text + dc := javaDeferredCall{ + name: method, receiver: m.Captures["callm.receiver"].Text, line: expr.StartLine + 1, isSelector: true, - }) + } + if wf := javaTemporalStartWorkflowName(expr.Node, method, src); wf != "" { + dc.tempStartWorkflow = wf + } + calls = append(calls, dc) case m.Captures["call.expr"] != nil: // Plain-call pattern fires for `bar()` AND for the inner @@ -267,6 +279,22 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction } } result.Edges = append(result.Edges, edge) + + // Temporal workflow START (consumer side): emit a via=temporal.start + // edge keyed by the workflow type name. The resolver cross-resolves + // it to the registered workflow — which may be implemented in a Go + // repo — so get_callers on that workflow surfaces this Java service. + if c.tempStartWorkflow != "" { + result.Edges = append(result.Edges, &graph.Edge{ + From: callerID, To: "unresolved::temporal::workflow::" + c.tempStartWorkflow, + Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, + Meta: map[string]any{ + "via": "temporal.start", + "temporal_kind": "workflow", + "temporal_name": c.tempStartWorkflow, + }, + }) + } } // React Native Fabric / Paper view managers: a class with @ReactProp diff --git a/internal/parser/languages/java_temporal.go b/internal/parser/languages/java_temporal.go new file mode 100644 index 00000000..8810edf7 --- /dev/null +++ b/internal/parser/languages/java_temporal.go @@ -0,0 +1,66 @@ +package languages + +import ( + "strings" + + sitter "github.com/zzet/gortex/internal/parser/tsitter" +) + +// javaTemporalStartWorkflowName returns the workflow TYPE name a Temporal +// Java workflow-stub creation starts, or "". It recognises the two stub +// factory shapes: +// +// client.newWorkflowStub(OrderWorkflow.class, options) // typed → "OrderWorkflow" +// client.newUntypedWorkflowStub("OrderWorkflow") // untyped → "OrderWorkflow" +// +// The stub's @WorkflowMethod call actually triggers the start, but the +// type (the class literal / string) is the canonical workflow name, which +// the resolver cross-resolves to the registered workflow — whose +// implementation may live in a Go repo. A `Foo.class` argument is reduced +// to its simple name ("Foo"), matching the Java SDK's default workflow +// type and the name a Go RegisterWorkflow would use. +func javaTemporalStartWorkflowName(callNode *sitter.Node, method string, src []byte) string { + switch method { + case "newWorkflowStub", "newUntypedWorkflowStub": + default: + return "" + } + if callNode == nil { + return "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "" + } + var first *sitter.Node + for i := 0; i < int(args.NamedChildCount()); i++ { + if c := args.NamedChild(i); c != nil { + first = c + break + } + } + if first == nil { + return "" + } + text := first.Content(src) + // `OrderWorkflow.class` / `com.example.OrderWorkflow.class` — robust to + // the grammar representing the class literal as a class_literal or a + // field_access by matching the trailing `.class`. + if strings.HasSuffix(text, ".class") { + return javaSimpleTypeName(strings.TrimSuffix(text, ".class")) + } + // `"OrderWorkflow"` — an untyped stub names the workflow by string. + if first.Type() == "string_literal" { + return strings.Trim(text, `"`) + } + return "" +} + +// javaSimpleTypeName returns the trailing identifier of a possibly +// qualified Java type name (`com.example.Foo` → `Foo`). +func javaSimpleTypeName(name string) string { + if i := strings.LastIndex(name, "."); i >= 0 { + return name[i+1:] + } + return name +} diff --git a/internal/parser/languages/java_temporal_test.go b/internal/parser/languages/java_temporal_test.go index 3be50fa2..d1b78842 100644 --- a/internal/parser/languages/java_temporal_test.go +++ b/internal/parser/languages/java_temporal_test.go @@ -117,3 +117,64 @@ public interface OrderActivities { assert.True(t, hasAnnotationEdge(t, result.Edges, method.ID, "ActivityMethod"), "method-level @ActivityMethod must emit its own EdgeAnnotated edge") } + +// temporalStartEdge returns the via=temporal.start edge originating at +// fromID, or nil. +func temporalStartEdge(edges []*graph.Edge, fromID string) *graph.Edge { + for _, e := range edges { + if e.From == fromID && e.Meta != nil && e.Meta["via"] == "temporal.start" { + return e + } + } + return nil +} + +func TestJavaTemporal_NewWorkflowStubStart(t *testing.T) { + src := []byte(`public class OrderService { + public void start(WorkflowClient client) { + OrderWorkflow wf = client.newWorkflowStub(OrderWorkflow.class, options); + wf.processOrder("id"); + } +} +`) + e := NewJavaExtractor() + result, err := e.Extract("OrderService.java", src) + require.NoError(t, err) + + var startMethod string + for _, n := range result.Nodes { + if n.Name == "start" { + startMethod = n.ID + } + } + require.NotEmpty(t, startMethod, "start method must be indexed") + + edge := temporalStartEdge(result.Edges, startMethod) + require.NotNil(t, edge, "newWorkflowStub must emit a via=temporal.start edge") + assert.Equal(t, "workflow", edge.Meta["temporal_kind"]) + assert.Equal(t, "OrderWorkflow", edge.Meta["temporal_name"], + "the class literal's simple name is the canonical workflow type") +} + +func TestJavaTemporal_NewUntypedWorkflowStubStart(t *testing.T) { + src := []byte(`public class OrderService { + public void start(WorkflowClient client) { + client.newUntypedWorkflowStub("OrderWorkflow"); + } +} +`) + e := NewJavaExtractor() + result, err := e.Extract("OrderService.java", src) + require.NoError(t, err) + + var startMethod string + for _, n := range result.Nodes { + if n.Name == "start" { + startMethod = n.ID + } + } + require.NotEmpty(t, startMethod) + edge := temporalStartEdge(result.Edges, startMethod) + require.NotNil(t, edge) + assert.Equal(t, "OrderWorkflow", edge.Meta["temporal_name"]) +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index c77038a9..7bdf003f 100644 --- a/internal/resolver/temporal_calls.go +++ b/internal/resolver/temporal_calls.go @@ -22,6 +22,13 @@ const temporalStubPrefix = unresolvedPrefix + "temporal::" // runtime env override may name a different handler than the default. const temporalEnvDefaultConfidence = 0.4 +// temporalCrossLangConfidence is stamped on a cross-language Temporal link +// (e.g. a Java service that starts a Go workflow, matched by canonical +// name across a type-system boundary with no compiler guarantee the names +// line up). It sits in the speculative band so the edge is hidden from +// default queries, consistent with the env-default tier. +const temporalCrossLangConfidence = 0.4 + // Temporal annotation node IDs the Java extractor emits via // EmitAnnotationEdge. The resolver consumes these to discover // temporal-tagged interfaces and methods. @@ -193,6 +200,23 @@ func ResolveTemporalCalls(g graph.Store) int { } } } + // 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 + // canonical name, at the speculative tier. + crossLang := false + if handlerID == "" { + matchName := s.name + if constDeref != "" { + matchName = constDeref + } + if hID, ok := idx.lookupCrossLang(s.kind, matchName, callerLang); ok { + handlerID = hID + origin = graph.OriginSpeculative + conf = temporalCrossLangConfidence + crossLang = true + } + } // When the name came from an env-var-with-literal-default // variable, the value is a best-guess: land the resolved edge at @@ -224,9 +248,14 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = conf e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf) e.Meta["temporal_resolution"] = origin - if envDefault { + if envDefault || crossLang { e.Meta[graph.MetaSpeculative] = true } + if crossLang { + e.Meta["temporal_cross_lang"] = true + } else { + delete(e.Meta, "temporal_cross_lang") + } if constDeref != "" { e.Meta["temporal_const_deref"] = constDeref } else { @@ -241,6 +270,7 @@ func ResolveTemporalCalls(g graph.Store) int { delete(e.Meta, "temporal_resolution") delete(e.Meta, graph.MetaSpeculative) delete(e.Meta, "temporal_const_deref") + delete(e.Meta, "temporal_cross_lang") UnstampSynthesized(e) } reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) @@ -307,6 +337,31 @@ func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id, return "", "", 0 } +// 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 +// Go workflow, or vice-versa). The match is a by-string name across a +// type-system boundary with no compiler guarantee, so it resolves only +// when there is exactly ONE other-language candidate for the name — and +// the caller lands it at the speculative tier. Returns ("", false) when +// the join is absent or ambiguous. +func (idx *temporalIndex) lookupCrossLang(kind, name, callerLang string) (id string, ok bool) { + all := idx.byKindName[kind+"::"+name] + if len(all) == 0 || callerLang == "" { + return "", false + } + var other []*graph.Node + for _, n := range all { + if n != nil && n.Language != callerLang { + other = append(other, n) + } + } + if len(other) == 1 { + return other[0].ID, true + } + return "", false +} + // buildTemporalIndex (a) stamps temporal_role on every node identifiable // as a Temporal workflow / activity via either Go `worker.Register*` // calls or Java `@ActivityInterface` / `@WorkflowInterface` annotations diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index d1ff0496..520d6add 100644 --- a/internal/resolver/temporal_calls_test.go +++ b/internal/resolver/temporal_calls_test.go @@ -488,6 +488,39 @@ func TestTemporalIndexLookup_LanguageGate(t *testing.T) { assert.Equal(t, javaNode.ID, id, "unknown caller lang keeps the unique-overall fallback") } +func TestResolveTemporalCalls_CrossLangJavaStartsGoWorkflow(t *testing.T) { + b := newTemporalTestGraph() + // Go side: a workflow registered under the canonical name OrderWorkflow. + b.addGoFunc("go/main.go::setup", "setup", "go/main.go", "gosvc") + b.addGoRegister("go/main.go::setup", "workflow", "OrderWorkflow", "go/main.go") + goWf := b.addGoFunc("go/wf.go::OrderWorkflow", "OrderWorkflow", "go/wf.go", "gosvc") + + // Java side (a DIFFERENT repo): a service that starts the workflow by + // its canonical type name via a via=temporal.start consumer edge. + javaCaller := &graph.Node{ + ID: "java/Svc.java::startOrder", Kind: graph.KindMethod, Name: "startOrder", + FilePath: "java/Svc.java", RepoPrefix: "jsvc", Language: "java", + } + b.g.AddNode(javaCaller) + startEdge := &graph.Edge{ + From: javaCaller.ID, To: temporalStubPlaceholder("workflow", "OrderWorkflow"), + Kind: graph.EdgeCalls, FilePath: "java/Svc.java", Line: 10, + Meta: map[string]any{ + "via": "temporal.start", "temporal_kind": "workflow", "temporal_name": "OrderWorkflow", + }, + } + b.g.AddEdge(startEdge) + + ResolveTemporalCalls(b.g) + + assert.Equal(t, goWf.ID, startEdge.To, + "a Java start must cross-resolve to the Go workflow of the same canonical name") + assert.Equal(t, graph.OriginSpeculative, startEdge.Origin, + "a cross-language join lands at the speculative tier") + assert.Equal(t, true, startEdge.Meta["temporal_cross_lang"]) + assert.Equal(t, true, startEdge.Meta[graph.MetaSpeculative], "cross-language edge is hidden by default") +} + func TestResolveTemporalCalls_RegisterNameOverride(t *testing.T) { b := newTemporalTestGraph() // Worker registers the impl ChargeCard under the override name "Charge" From 4e2837276a3cc964a97ad8f060d12743c7019840 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 17:20:52 +0200 Subject: [PATCH 17/18] feat(temporal): Java consumer-side signal-send / query-call edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Go outbound signal/query detection (#81) on the Java side so the polyglot estate is symmetric: an outbound stub.signal("name", …) / stub.query("name", …) on an untyped WorkflowStub emits a via=temporal.signal-send / temporal.query-call edge carrying the signal/query name (the first string-literal argument). Because "signal" / "query" are ordinary method names, detection is gated on the receiver's inferred type being WorkflowStub (via the extractor's type environment), so it never false-matches arbitrary code. Parser tests cover the WorkflowStub case and a negative for a non-stub receiver. --- internal/parser/languages/java.go | 29 ++++++++++ internal/parser/languages/java_temporal.go | 41 +++++++++++++ .../parser/languages/java_temporal_test.go | 58 +++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/internal/parser/languages/java.go b/internal/parser/languages/java.go index e41be942..1d1d711a 100644 --- a/internal/parser/languages/java.go +++ b/internal/parser/languages/java.go @@ -91,6 +91,13 @@ type javaDeferredCall struct { // resolver cross-resolves it to the workflow's implementation (which // may live in a Go repo). tempStartWorkflow string + // tempSignalKind / tempSignalName carry an outbound signal-send / + // query-call on an untyped WorkflowStub (stub.signal("name", …) / + // stub.query("name", …)). Emitted in the post-pass only when the + // receiver's inferred type is WorkflowStub, to keep the common + // "signal"/"query" method names from false-matching. + tempSignalKind string + tempSignalName string } // javaDeferredVar buffers a variable declaration for the post-pass @@ -187,6 +194,9 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction if wf := javaTemporalStartWorkflowName(expr.Node, method, src); wf != "" { dc.tempStartWorkflow = wf } + if sk, sn := javaTemporalSignalQuery(expr.Node, method, src); sk != "" { + dc.tempSignalKind, dc.tempSignalName = sk, sn + } calls = append(calls, dc) case m.Captures["call.expr"] != nil: @@ -295,6 +305,25 @@ func (e *JavaExtractor) Extract(filePath string, src []byte) (*parser.Extraction }, }) } + // Outbound signal-send / query-call on an untyped WorkflowStub, + // symmetric with the Go side (#81). Gated on the receiver's inferred + // type being WorkflowStub so the common "signal"/"query" method + // names don't false-match arbitrary code. + if c.tempSignalKind != "" && tenv[c.receiver] == "WorkflowStub" { + via := "temporal.signal-send" + if c.tempSignalKind == "query" { + via = "temporal.query-call" + } + result.Edges = append(result.Edges, &graph.Edge{ + From: callerID, To: "unresolved::*." + c.name, + Kind: graph.EdgeCalls, FilePath: filePath, Line: c.line, + Meta: map[string]any{ + "via": via, + "temporal_kind": c.tempSignalKind, + "temporal_name": c.tempSignalName, + }, + }) + } } // React Native Fabric / Paper view managers: a class with @ReactProp diff --git a/internal/parser/languages/java_temporal.go b/internal/parser/languages/java_temporal.go index 8810edf7..bd7ec127 100644 --- a/internal/parser/languages/java_temporal.go +++ b/internal/parser/languages/java_temporal.go @@ -64,3 +64,44 @@ func javaSimpleTypeName(name string) string { } return name } + +// javaTemporalSignalQuery recognises an outbound signal-send / query-call +// on an untyped Temporal WorkflowStub and returns its kind ("signal" / +// "query") and the signal/query name (the first positional argument, a +// string literal). The call shapes are: +// +// stub.signal("signalName", arg) // WorkflowStub.signal +// stub.query("queryType", ResultClass, arg) // WorkflowStub.query +// +// "signal" / "query" are ordinary method names, so the caller gates the +// match on the receiver's inferred type being WorkflowStub to stay +// precise. Returns ("", "") when the method is not signal/query or the +// name is not a string literal. +func javaTemporalSignalQuery(callNode *sitter.Node, method string, src []byte) (kind, name string) { + switch method { + case "signal": + kind = "signal" + case "query": + kind = "query" + default: + return "", "" + } + if callNode == nil { + return "", "" + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return "", "" + } + var first *sitter.Node + for i := 0; i < int(args.NamedChildCount()); i++ { + if c := args.NamedChild(i); c != nil { + first = c + break + } + } + if first == nil || first.Type() != "string_literal" { + return "", "" + } + return kind, strings.Trim(first.Content(src), `"`) +} diff --git a/internal/parser/languages/java_temporal_test.go b/internal/parser/languages/java_temporal_test.go index d1b78842..32c57293 100644 --- a/internal/parser/languages/java_temporal_test.go +++ b/internal/parser/languages/java_temporal_test.go @@ -178,3 +178,61 @@ func TestJavaTemporal_NewUntypedWorkflowStubStart(t *testing.T) { require.NotNil(t, edge) assert.Equal(t, "OrderWorkflow", edge.Meta["temporal_name"]) } + +func temporalEdgeByViaFrom(edges []*graph.Edge, fromID, via string) *graph.Edge { + for _, e := range edges { + if e.From == fromID && e.Meta != nil && e.Meta["via"] == via { + return e + } + } + return nil +} + +func TestJavaTemporal_UntypedStubSignalSend(t *testing.T) { + src := []byte(`public class Canceller { + public void cancel(WorkflowClient client) { + WorkflowStub stub = client.newUntypedWorkflowStub("OrderWorkflow"); + stub.signal("cancel-request", null); + } +} +`) + e := NewJavaExtractor() + result, err := e.Extract("Canceller.java", src) + require.NoError(t, err) + + var fromID string + for _, n := range result.Nodes { + if n.Name == "cancel" { + fromID = n.ID + } + } + require.NotEmpty(t, fromID) + edge := temporalEdgeByViaFrom(result.Edges, fromID, "temporal.signal-send") + require.NotNil(t, edge, "stub.signal on a WorkflowStub must emit a signal-send edge") + assert.Equal(t, "signal", edge.Meta["temporal_kind"]) + assert.Equal(t, "cancel-request", edge.Meta["temporal_name"]) +} + +func TestJavaTemporal_SignalOnNonStubIgnored(t *testing.T) { + // `signal` on a receiver that is NOT a WorkflowStub must not be + // detected — the type gate keeps the common method name precise. + src := []byte(`public class Light { + public void flip(Lamp lamp) { + lamp.signal("on"); + } +} +`) + e := NewJavaExtractor() + result, err := e.Extract("Light.java", src) + require.NoError(t, err) + + var fromID string + for _, n := range result.Nodes { + if n.Name == "flip" { + fromID = n.ID + } + } + require.NotEmpty(t, fromID) + assert.Nil(t, temporalEdgeByViaFrom(result.Edges, fromID, "temporal.signal-send"), + "signal on a non-WorkflowStub receiver must not be detected") +} From 83ef21e271b0d5285ffd5cb9d13994607e3c1052 Mon Sep 17 00:00:00 2001 From: Andrey Kumanyaev Date: Fri, 12 Jun 2026 17:31:52 +0200 Subject: [PATCH 18/18] feat(temporal): detect dispatch wrappers, suppress parameter-named stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A function that forwards one of its parameters as the dispatch name — executeActivity(ctx, ao, name, …) { workflow.ExecuteActivity(ctx, name, …) } — is a dispatch wrapper. The parameter is not a real activity name, so the stub it produced could never resolve and was pure noise. The extractor now recognises this shape (the dispatch name is one of the enclosing function's parameters), suppresses the unresolvable stub, and stamps temporal_wrapper_kind / temporal_wrapper_param on the function node so a future interprocedural pass can propagate the caller's argument. Needed a full param-name set per function (paramsByFunc keeps only non-builtin-typed params), threaded as paramNamesByFunc. NOTE: cross-file wrapper-FOLLOWING — propagating a caller's literal/const through the wrapper to the real handler — requires call-argument flow and remains a documented blind spot (the const-deref already covers the const-naming half of the problem). Adds a wrapper-suppression test. --- internal/parser/languages/go_temporal_test.go | 36 +++++++++- internal/parser/languages/golang.go | 71 +++++++++++++++++-- internal/parser/languages/golang_temporal.go | 26 +++++++ 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/internal/parser/languages/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index d9f46443..fb358201 100644 --- a/internal/parser/languages/go_temporal_test.go +++ b/internal/parser/languages/go_temporal_test.go @@ -405,7 +405,8 @@ import ( func run(f func()) { f() } -func WF(ctx workflow.Context, actName string) { +func WF(ctx workflow.Context, picked string) { + actName := picked run(func() { actName := cmp.Or(os.Getenv("K"), "Inner") _ = actName @@ -690,3 +691,36 @@ func Start(c Client) { assert.Equal(t, "workflow", edges[0].Meta["temporal_kind"]) assert.Equal(t, "OrderWorkflow", edges[0].Meta["temporal_name"]) } + +// --- Dispatch wrapper detection (issue #80 Q2) ---------------------- +// +// A function that forwards one of its parameters as the dispatch name is +// a wrapper; the parameter is not a real activity name, so emitting a stub +// for it is noise. We suppress the stub and mark the function as a wrapper +// (temporal_wrapper_*) for a future interprocedural follower. Propagating +// the caller's argument through the wrapper (cross-file) is not yet done. + +func TestGoTemporal_WrapperParamDispatchSuppressed(t *testing.T) { + fix := runGoExtract(t, `package wf + +import "go.temporal.io/sdk/workflow" + +func executeActivity(ctx workflow.Context, name string, args ...any) error { + return workflow.ExecuteActivity(ctx, name, args...).Get(ctx, nil) +} +`) + // The parameter-named dispatch must NOT emit a (never-resolvable) stub. + assert.Empty(t, temporalEdgesByVia(fix, "temporal.stub"), + "a parameter-forwarded dispatch must not emit a junk stub") + + // The wrapper function is marked for a future interprocedural follower. + var wrapper *graph.Node + for _, n := range fix.nodesByKind[graph.KindFunction] { + if n.Name == "executeActivity" { + wrapper = n + } + } + require.NotNil(t, wrapper) + assert.Equal(t, "activity", wrapper.Meta["temporal_wrapper_kind"]) + assert.Equal(t, "name", wrapper.Meta["temporal_wrapper_param"]) +} diff --git a/internal/parser/languages/golang.go b/internal/parser/languages/golang.go index 9c58ee20..a19020a9 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -295,6 +295,11 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // Function parameters and method receivers shadow file-wide tenv at // call resolution time so each function's locals stay sandboxed. paramsByFunc := map[string]typeEnv{} + // paramNamesByFunc: function/method ID → set of ALL its parameter + // names (paramsByFunc only keeps params with non-builtin types). Used + // by the Temporal wrapper detector to recognise a dispatch whose name + // is a forwarded parameter. + paramNamesByFunc := map[string]map[string]bool{} seenTypeName := map[string]bool{} // dedup when alias + typedef match same name var calls []goDeferredCall @@ -330,10 +335,10 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // No-op (the package name is not currently surfaced as a node). case m.Captures["func.def"] != nil: - e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, imports) + e.emitFunction(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports) case m.Captures["method.def"] != nil: - e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, imports) + e.emitMethod(m, filePath, fileID, src, result, paramsByFunc, paramNamesByFunc, imports) case m.Captures["typedef.def"] != nil: e.emitTypeDecl(m, filePath, fileID, src, result, seenTypeName) @@ -733,6 +738,23 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // doesn't resolve onto the SDK's generic `ExecuteActivity` // helper instead of the actual activity body. if c.tempKind == "activity" || c.tempKind == "workflow" { + // Wrapper detection: when the dispatch name is a PARAMETER of + // the enclosing function, this function is a dispatch wrapper + // (e.g. executeActivity(ctx, ao, name, …) { + // workflow.ExecuteActivity(ctx, name, …) }). The parameter is + // not a real activity name, so the stub could never resolve — + // suppress the noise and mark the wrapper instead. Propagating + // a caller's literal/const argument into the dispatch + // (cross-file wrapper-FOLLOWING) needs call-argument flow and + // is a deliberate, documented blind spot for now. + if !c.tempEnvDefault { + if names, ok := paramNamesByFunc[callerID]; ok { + if names[c.tempName] { + markGoTemporalWrapper(result, callerID, c.tempKind, c.tempName) + continue + } + } + } target := "unresolved::temporal::" + c.tempKind + "::" + c.tempName meta := map[string]any{ "via": "temporal.stub", @@ -996,10 +1018,15 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe // --- Per-match emit helpers ----------------------------------------- -func (e *GoExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, imports map[string]string) { +func (e *GoExtractor) emitFunction(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, paramNamesByFunc map[string]map[string]bool, imports map[string]string) { name := m.Captures["func.name"].Text def := m.Captures["func.def"] id := filePath + "::" + name + if pc, ok := m.Captures["func.params"]; ok && pc != nil { + if names := extractGoParamNames(pc.Node, src); len(names) > 0 { + paramNamesByFunc[id] = names + } + } node := &graph.Node{ ID: id, Kind: graph.KindFunction, @@ -1077,13 +1104,18 @@ func ownReceiverField(receiver, recvName string) (string, bool) { return rest, true } -func (e *GoExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, imports map[string]string) { +func (e *GoExtractor) emitMethod(m parser.QueryResult, filePath, fileID string, src []byte, result *parser.ExtractionResult, paramsByFunc map[string]typeEnv, paramNamesByFunc map[string]map[string]bool, imports map[string]string) { name := m.Captures["method.name"].Text def := m.Captures["method.def"] receiverText := m.Captures["method.receiver"].Text receiverType := extractReceiverType(receiverText) id := filePath + "::" + receiverType + "." + name + if paramsCap, ok := m.Captures["method.params"]; ok && paramsCap != nil { + if names := extractGoParamNames(paramsCap.Node, src); len(names) > 0 { + paramNamesByFunc[id] = names + } + } scope := typeEnv{} if recvName := extractReceiverName(receiverText); recvName != "" && receiverType != "" { scope[recvName] = receiverType @@ -2084,6 +2116,37 @@ func extractReceiverName(receiver string) string { // parameters, blank identifiers, and types that normalizeGoTypeName // drops (primitives, map/chan/func) are skipped — callers only care // about names that point at receiver types we could resolve methods on. +// extractGoParamNames returns the set of ALL parameter names declared in a +// parameter_list, regardless of type (unlike extractGoParamTypes, which +// keeps only params with a non-builtin type for receiver resolution). Used +// by the Temporal wrapper detector to recognise a forwarded parameter. +func extractGoParamNames(paramList *sitter.Node, src []byte) map[string]bool { + if paramList == nil { + return nil + } + out := map[string]bool{} + for i := 0; i < int(paramList.NamedChildCount()); i++ { + decl := paramList.NamedChild(i) + if decl == nil { + continue + } + if t := decl.Type(); t != "parameter_declaration" && t != "variadic_parameter_declaration" { + continue + } + typeNode := decl.ChildByFieldName("type") + for j := 0; j < int(decl.NamedChildCount()); j++ { + c := decl.NamedChild(j) + if c == nil || c == typeNode { + continue + } + if c.Type() == "identifier" { + out[c.Content(src)] = true + } + } + } + return out +} + func extractGoParamTypes(paramList *sitter.Node, src []byte) typeEnv { if paramList == nil { return nil diff --git a/internal/parser/languages/golang_temporal.go b/internal/parser/languages/golang_temporal.go index 8b7564cd..18be4216 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -32,6 +32,7 @@ import ( "strings" "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" sitter "github.com/zzet/gortex/internal/parser/tsitter" ) @@ -506,6 +507,31 @@ func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) { edge.Meta["temporal_name"] = c.tempName } +// markGoTemporalWrapper stamps a dispatch-wrapper marker on the enclosing +// function node: a function that calls workflow.ExecuteActivity / +// ExecuteChildWorkflow with one of its own parameters as the dispatch +// name. temporal_wrapper_kind records the kind (activity / workflow) and +// temporal_wrapper_param the forwarded parameter name. The marker lets a +// future interprocedural pass propagate a caller's literal/const argument +// through the wrapper to the real handler; today it documents the wrapper +// so the unresolvable parameter-named stub is suppressed rather than +// emitted as noise. +func markGoTemporalWrapper(result *parser.ExtractionResult, callerID, kind, param string) { + if result == nil || callerID == "" { + return + } + for _, n := range result.Nodes { + if n.ID == callerID { + if n.Meta == nil { + n.Meta = map[string]any{} + } + n.Meta["temporal_wrapper_kind"] = kind + n.Meta["temporal_wrapper_param"] = param + return + } + } +} + // goTemporalStartKind reports whether a method name is one of the // service-side workflow-START helpers and, if so, returns the 1-based // positional index of the workflow argument.