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/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/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 00f49d9d..c8424573 100644 --- a/internal/indexer/temporal_e2e_test.go +++ b/internal/indexer/temporal_e2e_test.go @@ -133,3 +133,379 @@ 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") +} + +// 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"]) +} + +// 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"]) +} + +// 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"]) +} + +// 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") +} + +// 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") +} + +// 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/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{ 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/go_temporal_test.go b/internal/parser/languages/go_temporal_test.go index 94a86269..fb358201 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) { @@ -164,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" @@ -176,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) { @@ -200,3 +221,506 @@ 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) +} + +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, picked string) { + actName := picked + 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 / +// @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_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 + // 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_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" + +func OrderWorkflow(ctx wf.Context) error { + wf.SetQueryHandler(ctx, "status", func() (string, error) { return "ok", nil }) + 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"]) +} + +// --- 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_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" + +func Orchestrator(ctx wf.Context) error { + return wf.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"]) +} + +// --- 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"]) +} + +// --- 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 a1b18f0d..a19020a9 100644 --- a/internal/parser/languages/golang.go +++ b/internal/parser/languages/golang.go @@ -201,6 +201,41 @@ 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 + // 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 + // 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 + // 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 + // 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 + // 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 { @@ -250,11 +285,21 @@ 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 // 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 @@ -290,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) @@ -332,19 +377,71 @@ 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 { - if name := goTemporalDispatchName(expr.Node, src); name != "" { + // `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 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 { + } 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 + // 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(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(tempRecv, 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 + } + } 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) @@ -420,7 +517,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) @@ -641,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", @@ -650,6 +764,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, @@ -668,6 +785,9 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) 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 @@ -680,6 +800,9 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe } applyGoGRPCRegisterMeta(edge, c, src, tenv) 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 @@ -729,6 +852,8 @@ 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) } @@ -893,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, @@ -974,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 @@ -1607,7 +1742,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 == "_" { @@ -1636,6 +1771,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 @@ -1928,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 019aeec0..18be4216 100644 --- a/internal/parser/languages/golang_temporal.go +++ b/internal/parser/languages/golang_temporal.go @@ -29,10 +29,73 @@ package languages import ( + "strings" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/parser" 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 @@ -79,21 +142,75 @@ 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: +// 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 +} + +// 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"). // -// - a string literal: "MyActivity" -// - a bare identifier: MyActivity -// - a selector expression: pkg.MyActivity, recv.Method +// 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) // -// 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 { +// 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", "SetQueryHandlerWithOptions": + return "query", true + case "GetSignalChannel", "GetSignalChannelWithOptions": + 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 "" } @@ -109,16 +226,53 @@ func goTemporalDispatchName(callNode *sitter.Node, src []byte) string { } count++ if count == 2 { - return goTemporalNameFromExpr(c, src) + switch c.Type() { + case "interpreted_string_literal", "raw_string_literal": + return goTemporalNameFromExpr(c, src) + } + return "" } } return "" } +// 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 nil + } + args := callNode.ChildByFieldName("arguments") + if args == nil { + return nil + } + count := 0 + for i := 0; i < int(args.NamedChildCount()); i++ { + c := args.NamedChild(i) + if c == nil { + continue + } + count++ + if count == 2 { + return c + } + } + 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 "" @@ -166,6 +320,317 @@ 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 + } + 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 +// 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 +// `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 +} + +// 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 +} + +// 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. +// +// 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 +// 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 "" } // goTemporalNameFromExpr reduces a single argument expression to the @@ -200,3 +665,216 @@ 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() + + // 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 { + return + } + 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) { + assigns = append(assigns, n) + } + for i := 0; i < int(n.NamedChildCount()); i++ { + walk(n.NamedChild(i)) + } + } + walk(body) + + // 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 +// 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 `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 + firstLiteral := "" + 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 && !haveLiteral { + firstLiteral, haveLiteral = lit, true + } + } + if hasEnvRead && haveLiteral { + 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) { + 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/parser/languages/java.go b/internal/parser/languages/java.go index 0105118b..1d1d711a 100644 --- a/internal/parser/languages/java.go +++ b/internal/parser/languages/java.go @@ -84,6 +84,20 @@ 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 + // 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 @@ -170,12 +184,20 @@ 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 + } + 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: // Plain-call pattern fires for `bar()` AND for the inner @@ -267,6 +289,41 @@ 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, + }, + }) + } + // 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 new file mode 100644 index 00000000..bd7ec127 --- /dev/null +++ b/internal/parser/languages/java_temporal.go @@ -0,0 +1,107 @@ +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 +} + +// 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 3be50fa2..32c57293 100644 --- a/internal/parser/languages/java_temporal_test.go +++ b/internal/parser/languages/java_temporal_test.go @@ -117,3 +117,122 @@ 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"]) +} + +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") +} diff --git a/internal/resolver/temporal_calls.go b/internal/resolver/temporal_calls.go index 63cac106..7bdf003f 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" ) @@ -12,6 +14,21 @@ 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 + +// 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. @@ -85,47 +102,133 @@ 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", "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 == "" { + 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) } 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 := "" + callerLang := "" if from := callerNodes[e.From]; from != nil { callerRepo = from.RepoPrefix + 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 + } + } + } + // 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 + // 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 } - handlerID, origin, conf := idx.lookup(s.kind, s.name, callerRepo) want := handlerID if want == "" { @@ -145,6 +248,19 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = conf e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf) e.Meta["temporal_resolution"] = origin + 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 { + delete(e.Meta, "temporal_const_deref") + } StampSynthesized(e, SynthTemporalStub) resolved++ } else { @@ -152,6 +268,9 @@ func ResolveTemporalCalls(g graph.Store) int { e.Confidence = 0 e.ConfidenceLabel = "" 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}) @@ -176,11 +295,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 { @@ -197,42 +337,82 @@ func (idx *temporalIndex) lookup(kind, name, callerRepo string) (id, origin stri 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 { +// 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 +// (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 - 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: "..."}. 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{}{} 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 == "" { continue } - goRegisters = append(goRegisters, goRegister{edge: e, kind: kind, name: name}) + regName, _ := e.Meta["temporal_registered_name"].(string) + if regName == "" { + regName = name + } + 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{}{} } @@ -254,24 +434,41 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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 } - 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 `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 + args string // raw annotation inner-parens text } var javaAnnos []javaAnno annoFromIDs := map[string]struct{}{} - for e := range g.EdgesByKind(graph.EdgeAnnotated) { + for _, e := range annotatedEdges { if e == nil { continue } @@ -279,7 +476,8 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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{}{} } @@ -291,8 +489,9 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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 { @@ -300,17 +499,25 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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"), + }) } } @@ -366,10 +573,22 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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{}{} @@ -385,8 +604,9 @@ func buildTemporalIndex(g graph.Store) *temporalIndex { 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) } } } @@ -417,6 +637,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. @@ -438,6 +725,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{} } @@ -504,6 +806,145 @@ 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) +} + +// 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 diff --git a/internal/resolver/temporal_calls_test.go b/internal/resolver/temporal_calls_test.go index 82c7922d..520d6add 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") @@ -290,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) { @@ -308,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) { @@ -389,3 +458,87 @@ 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") +} + +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" + // (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"]) +}