package resolver import ( "strconv" "strings" "unicode" "unicode/utf8" "github.com/zzet/gortex/internal/graph" ) // temporalStubPrefix is the placeholder namespace the Go extractor // emits for a Temporal workflow → activity (or workflow → child // workflow) dispatch it can't land locally // (`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 // temporalExactSigConfidence is stamped on an exact-name signature-gated // resolution (Cat 4): a PascalCase dispatch landed on a same-named, // unregistered, non-suffixed function whose signature matches the dispatch // kind. The name match is exact and the signature is a strong positive // gate, but the target is not register-confirmed — so it sits in the // speculative band (hidden, AMBIGUOUS) like the other best-guess tiers. const temporalExactSigConfidence = 0.45 // temporalEnvDefaultInferredConfidence is stamped instead when the env-default // was recognised with high confidence — a provable os.Getenv read or a helper // name in the configured allow-list (`temporal_env_source` = "os_getenv" / // "allowlist"). It sits in the inferred band (≥ 0.5, visible by default): we // trust that the dispatch DOES default to this name, leaving the residual // "runtime may override" risk to the optional LLM cleaning pass. const temporalEnvDefaultInferredConfidence = 0.6 // Temporal annotation node IDs the Java extractor emits via // EmitAnnotationEdge. The resolver consumes these to discover // temporal-tagged interfaces and methods. const ( javaActivityIfaceAnnoID = "annotation::java::ActivityInterface" javaWorkflowIfaceAnnoID = "annotation::java::WorkflowInterface" javaActivityMethodID = "annotation::java::ActivityMethod" javaWorkflowMethodID = "annotation::java::WorkflowMethod" javaSignalMethodID = "annotation::java::SignalMethod" javaQueryMethodID = "annotation::java::QueryMethod" javaUpdateMethodID = "annotation::java::UpdateMethod" ) // ResolveTemporalCalls is the graph-wide materialisation pass for the // Temporal workflow → activity dispatch layer (N35). It performs two // complementary jobs: // // 1. Role tagging. Stamps `temporal_role` (one of "workflow" / // "activity" / "activity_interface" / "workflow_interface" / // "signal" / "query" / "update") on every node the SDK treats as // a workflow / activity. Discovery uses two signals: (a) Go // `worker.RegisterActivity(F)` / `RegisterWorkflow(F)` calls, // emitted by the Go extractor as EdgeCalls edges carrying // `Meta["via"]="temporal.register"` and `Meta["temporal_name"]=`; // (b) Java `@ActivityInterface` / `@WorkflowInterface` / // `@SignalMethod` / `@QueryMethod` / `@UpdateMethod` annotations, // emitted by the Java extractor as EdgeAnnotated edges to a // well-known synthetic annotation node. For Java interface // annotations the role is propagated to every implementor's // matching method via EdgeImplements + name match — that gives // queries a flat view of "every activity method in this codebase" // without re-walking the interface chain. // // 2. Stub-call resolution. Every Go `workflow.ExecuteActivity(ctx, F, // ...)` call is emitted as an EdgeCalls edge to a // `unresolved::temporal::::` placeholder carrying // `Meta["via"]="temporal.stub"`. This pass rewrites each such edge // to point at the function the worker registered under that name. // The Java side is already resolved by normal interface dispatch // (`stub.someMethod()` is a call on a `@ActivityInterface` type; // the existing AST resolver lands it on the interface method, and // EdgeImplements connects to the impl); the role tag in step 1 is // the only extra surface Java needs. // // The pass is a full recompute and idempotent: every temporal.stub // edge's target is recomputed from its own `temporal_name` meta on // every call, so it is incremental-safe — a reindex of either the // workflow or the activity file leaves the meta intact and the next // pass re-lands (or un-lands) the edge. graph.ReindexEdge keeps the // out/in buckets consistent. An edge whose target is no longer in the // graph is reset back to the placeholder and loses its // resolution-tier metadata. // // Runs at every resolver settle point that already runs InferImplements // (so the Java interface → impl chain has its EdgeImplements edges) // and after ResolveGRPCStubCalls (so the two SDK passes share the // same post-condition). // // Returns the number of temporal.stub edges pointing at a resolved // handler after the pass. // argNameAt reads the positional arg name recorded on a call edge. // // PURPOSE — read the positional arg name recorded on a call edge by the extractor // RATIONALE — arg_names can be []string (most paths) or []any (json-round-tripped) // KEYWORDS — arg_names, wrapper-following, position func argNameAt(e *graph.Edge, pos int) string { if e == nil || e.Meta == nil || pos < 0 { return "" } switch a := e.Meta["arg_names"].(type) { case []string: if pos < len(a) { return a[pos] } case []any: if pos < len(a) { if s, ok := a[pos].(string); ok { return s } } } return "" } // metaIntValue coerces an int-ish meta value to an int. // // PURPOSE — coerce various numeric representations of a position to int // RATIONALE — meta values can be stored as int, int64, float64, or string depending on serialization // KEYWORDS — position, coercion, param, wrapper-following func metaIntValue(v any) (int, bool) { switch x := v.(type) { case int: return x, true case int64: return int(x), true case float64: return int(x), true case string: if n, err := strconv.Atoi(x); err == nil { return n, true } } return 0, false } // temporalWrapperStubExists is the idempotence guard for the wrapper pass. // // PURPOSE — prevent duplicate wrapper-synthesized stub edges on repeated resolver runs // RATIONALE — resolveTemporalWrapperCalls runs on every settle; the guard is O(out-edges of caller) // KEYWORDS — idempotence, temporal.stub, wrapper func temporalWrapperStubExists(g graph.Store, from, kind, name string) bool { for _, e := range g.GetOutEdges(from) { if e == nil || e.Meta == nil { continue } if v, _ := e.Meta["via"].(string); v != "temporal.stub" { continue } if k, _ := e.Meta["temporal_kind"].(string); k != kind { continue } if n, _ := e.Meta["temporal_name"].(string); n == name { return true } } return false } // resolveTemporalWrapperCalls synthesises temporal.stub edges at callers of // dispatch wrappers, propagating the caller's literal arg value through the // wrapper's forwarded parameter. // // PURPOSE — synthesize temporal.stub edges at callers of wrapper functions that forward // // a parameter as the dispatch name, propagating the caller's literal arg value // // RATIONALE — single-level pass: find edges WITH temporal_name_param, find their callers, // // extract the arg at the wrapper's param position, emit a new stub // // KEYWORDS — wrapper-following, temporal.stub, arg_names, single-level // resolveTemporalWrapperCalls returns the number of fresh wrapper-stub edges // it synthesised, so the caller can iterate the pass to a fixpoint and follow // multi-hop (depth>1) wrapper chains. func resolveTemporalWrapperCalls(g graph.Store) int { type wrapper struct { id, kind, name string pos int } byID := map[string]wrapper{} byName := map[string][]wrapper{} for e := range g.EdgesByKind(graph.EdgeCalls) { if e == nil || e.Meta == nil || e.From == "" { continue } if v, _ := e.Meta["via"].(string); v != "temporal.stub" { continue } param, _ := e.Meta["temporal_name_param"].(string) kind, _ := e.Meta["temporal_kind"].(string) if param == "" || kind == "" { continue } if _, seen := byID[e.From]; seen { continue } pn := g.GetNode(e.From + "#param:" + param) if pn == nil { continue } pos, ok := metaIntValue(pn.Meta["position"]) if !ok { continue } wname := "" if wnode := g.GetNode(e.From); wnode != nil { wname = wnode.Name } w := wrapper{id: e.From, kind: kind, name: wname, pos: pos} byID[e.From] = w if wname != "" { byName[wname] = append(byName[wname], w) } } if len(byID) == 0 { return 0 } type pending struct { from, file, kind, name, wrapperName string line int // fwdParam, when non-empty, marks this emitted stub as itself a // name-forwarding wrapper: the caller passed its OWN parameter // (named fwdParam) into the inner wrapper's name position, so the // caller is a transitive wrapper the NEXT iteration must discover. // The stub then carries temporal_name_param=fwdParam (the depth>1 // hook), enabling iterative resolution. fwdParam string } var out []pending emit := func(w wrapper, ce *graph.Edge) { if ce.From == w.id { return } name := argNameAt(ce, w.pos) if name == "" { return } // Depth>1 propagation: if the forwarded argument is itself a // parameter of the caller (a `#param:` node with a // position exists), the caller merely passes a name THROUGH — it is // a transitive wrapper. Emit a temporal_name_param stub so the next // iteration discovers the caller as a wrapper and reaches its own // callers. Otherwise the argument is a literal / const NAME the main // resolver lands directly, and no further hop is needed. fwd := "" if pn := g.GetNode(ce.From + "#param:" + name); pn != nil && pn.Kind == graph.KindParam { if _, ok := metaIntValue(pn.Meta["position"]); ok { fwd = name } } out = append(out, pending{from: ce.From, file: ce.FilePath, line: ce.Line, kind: w.kind, name: name, wrapperName: w.name, fwdParam: fwd}) } for ce := range g.EdgesByKind(graph.EdgeCalls) { if ce == nil || ce.From == "" || ce.Meta == nil { continue } if _, ok := ce.Meta["arg_names"]; !ok { continue } if w, ok := byID[ce.To]; ok { emit(w, ce) continue } callee, _ := ce.Meta["callee"].(string) if callee == "" { continue } for _, w := range byName[callee] { emit(w, ce) } } added := 0 for _, p := range out { if temporalWrapperStubExists(g, p.from, p.kind, p.name) { continue } meta := map[string]any{ "via": "temporal.stub", "temporal_kind": p.kind, "temporal_name": p.name, "temporal_via_wrapper": p.wrapperName, } // Transitive wrapper: stamp temporal_name_param so the next // iteration discovers p.from as a wrapper and propagates through // to its own callers (depth > 1). if p.fwdParam != "" { meta["temporal_name_param"] = p.fwdParam } g.AddEdge(&graph.Edge{ From: p.from, To: temporalStubPlaceholder(p.kind, p.name), Kind: graph.EdgeCalls, FilePath: p.file, Line: p.line, Meta: meta, }) added++ } return added } func ResolveTemporalCalls(g graph.Store) int { if g == nil { return 0 } // Serialise against other graph-wide passes that mutate Node.Meta // (markTestSymbolsAndEmitEdges, detectClonesAndEmitEdges, // reach.BuildIndex). stampTemporalRole below writes n.Meta on // existing graph nodes; without this lock a concurrent reader // (e.g. clone detection invoked from indexFile) trips the runtime's // "concurrent map read and map write" check. mu := g.ResolveMutex() mu.Lock() defer mu.Unlock() // Wrapper-following pre-pass: synthesise temporal.stub edges at callers of // wrapper functions that forward a parameter as the Temporal dispatch name. // Must run before the stub-collection sweep so the freshly synthesised stubs // are picked up and resolved by the existing loop below. Iterate to a // fixpoint: each pass may turn a caller into a newly-discovered transitive // wrapper (a stub stamped temporal_name_param), which the next pass follows // up another hop — depth>1 wrapper chains resolve this way. The pass is // idempotent (temporalWrapperStubExists guards re-emission), so it // terminates once no fresh stub is added; the bound caps pathological // chains. for i := 0; i < 16; i++ { if resolveTemporalWrapperCalls(g) == 0 { break } } // Executor-field pre-pass: rewrite struct-field dispatch stubs to the // literal name supplied at the executor's construction site. Also runs // before the sweep so the rewritten stubs resolve below. resolveTemporalExecutorFields(g) // 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 } 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{}{} } } } // Probe the (smaller) annotation class for Java temporal tags. var annotatedEdges []*graph.Edge for e := range g.EdgesByKind(graph.EdgeAnnotated) { if e == nil { continue } 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) // Env-default const reference: the helper's default argument was a // constant NAME (`GetEnvOrDefault(KEY, config.ACTIVITY_NAME_DEFAULT)`), // recorded as `temporal_default_const`. Include it so the deref map // resolves it to its literal value alongside the dispatch names. if cn, _ := s.edge.Meta["temporal_default_const"].(string); cn != "" { stubNames = append(stubNames, cn) } } 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. The // deref value is computed up front so it also feeds the // cross-language join below (a Java const-ref dispatch through // `Constants.X` derefs X to the literal, then matches the Go // workflow of that name across the type-system boundary). constDeref := "" derefVal := "" if v, ok := derefByName[s.name]; ok && v != "" { derefVal = v } if handlerID == "" && derefVal != "" { if hID, o, c := idx.lookup(s.kind, derefVal, callerRepo, callerLang); hID != "" { handlerID, origin, conf = hID, o, c constDeref = derefVal } } // Env-default const reference: the env-helper's default argument was a // constant reference (`GetEnvOrDefault(KEY, config.ACTIVITY_NAME_DEFAULT)`), // recorded as `temporal_default_const`. Substitute the constant's literal // VALUE through the deref map (register-confirmed candidates), then look // it up. The env-default tier override below keeps the edge at the // const_ref tier (inferred, visible) regardless of how it resolved. if handlerID == "" { if cn, _ := e.Meta["temporal_default_const"].(string); cn != "" { if v, ok := derefByName[cn]; ok && v != "" { if id, o, c := idx.lookup(s.kind, v, callerRepo, callerLang); id != "" { handlerID, origin, conf = id, o, c e.Meta["temporal_const_value"] = v } } } } // Convention fallback: dispatch to an activity/workflow FUNCTION // by name when the worker registers it elsewhere (unregistered // here) — Pattern 2 / Stage 1.2. Try the dispatch name, then its // const-deref value. Landed at the inferred tier (name convention, // not a register-confirmed binding). Tried before the cross-language // join: a same-language convention match is a stronger signal than a // speculative by-string match across a type-system boundary. convention := false if handlerID == "" { candNames := []string{s.name} if v, ok := derefByName[s.name]; ok && v != "" && v != s.name { candNames = append(candNames, v) } for _, nm := range candNames { if id := idx.lookupConvention(s.kind, nm, callerRepo, callerLang); id != "" { handlerID, origin, conf = id, graph.OriginASTInferred, 0.6 convention = true if nm != s.name { constDeref = nm } break } } } // Cross-language join: a consumer (typically a temporal.start, e.g. // a Java service starting a Go workflow) with no same-language // handler is matched to a unique other-language candidate by // canonical name, at the speculative tier. crossLang := false if handlerID == "" { matchName := s.name if derefVal != "" { matchName = derefVal } if hID, ok := idx.lookupCrossLang(s.kind, matchName, callerLang); ok { handlerID = hID origin = graph.OriginSpeculative conf = temporalCrossLangConfidence crossLang = true if derefVal != "" { constDeref = derefVal } } } // Exact-name signature-gated fallback (Cat 4): a PascalCase dispatch // whose target is a same-named, unregistered, non-suffixed function. // Gated on exact name + a kind-matching Temporal signature + a unique // candidate; landed speculative + hidden. Last resort, so it runs // only after register / const-deref / cross-language all abstained. exactSig := false if handlerID == "" { matchName := s.name if constDeref != "" { matchName = constDeref } if id := idx.lookupExactSig(s.kind, matchName, callerRepo); id != "" { handlerID = id origin = graph.OriginSpeculative conf = temporalExactSigConfidence exactSig = true } } // When the dispatch name came from an env-var-with-literal-default // variable (env_default) or from tracing a bare local dispatch // variable to its last assignment (var_trace), the value is a // best-guess. HOW it was recognised decides the tier: // - "allowlist" / "os_getenv" / "const_ref": confident it IS an // env-with-default — inferred tier (0.6, visible). // - "heuristic" / var_trace / unknown: a guess — hidden speculative // tier (0.4), where the optional LLM cleaning pass can prune it. envDefault := false envSource := "" switch v, _ := e.Meta["temporal_name_origin"].(string); v { case "env_default", "var_trace": envDefault = true envSource, _ = e.Meta["temporal_env_source"].(string) } envSpeculative := envDefault && envSource != "allowlist" && envSource != "os_getenv" && envSource != "const_ref" if handlerID != "" && envDefault { if envSpeculative { origin = graph.OriginSpeculative conf = temporalEnvDefaultConfidence } else { origin = graph.OriginASTInferred conf = temporalEnvDefaultInferredConfidence } } want := handlerID if want == "" { want = temporalStubPlaceholder(s.kind, s.name) } if e.To == want { if handlerID != "" { resolved++ } continue } oldTo := e.To e.To = want if handlerID != "" { e.Origin = origin e.Confidence = conf e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, conf) e.Meta["temporal_resolution"] = origin if envSpeculative || crossLang || exactSig { e.Meta[graph.MetaSpeculative] = true } if crossLang { e.Meta["temporal_cross_lang"] = true } else { delete(e.Meta, "temporal_cross_lang") } if exactSig { e.Meta["temporal_resolution_via"] = "exact_sig" } else if convention { e.Meta["temporal_resolution_via"] = "convention" } else { delete(e.Meta, "temporal_resolution_via") } if constDeref != "" { e.Meta["temporal_const_deref"] = constDeref } else { delete(e.Meta, "temporal_const_deref") } StampSynthesized(e, SynthTemporalStub) resolved++ } else { e.Origin = "" 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") delete(e.Meta, "temporal_resolution_via") UnstampSynthesized(e) } reindexBatch = append(reindexBatch, graph.EdgeReindex{Edge: e, OldTo: oldTo}) } if len(reindexBatch) > 0 { g.ReindexEdges(reindexBatch) } // Link Java consumers (workflow starts / signals / queries) to the Go // workflows and handlers they target, by shared canonical name. Runs // last: it reads temporal_role / temporal_name meta stamped by the // sweep above and the via=temporal.handler edges emitted by the Go // extractor. Additive — graph.AddEdge dedupes. resolveTemporalCrossLanguage(g) return resolved } // temporalStubPlaceholder is the canonical placeholder target for an // unresolved Temporal stub call. func temporalStubPlaceholder(kind, name string) string { return temporalStubPrefix + kind + "::" + name } // resolveTemporalCrossLanguage links Java consumers to the Go workflows / // handlers they target, by shared canonical name: // // - a Java `@WorkflowInterface` method (role "workflow", canonical name // from `@WorkflowMethod(name=…)`) → the Go workflow registered / // named the same → via=temporal.start-workflow. // - a Java `@SignalMethod(name="cancel")` → a Go workflow that serves // signal "cancel" (via=temporal.handler kind=signal) → // via=temporal.signal-link. // - a Java `@QueryMethod(name="status")` → a Go query handler → // via=temporal.query-link. // // All edges carry cross_language=true and are emitted at the inferred // tier (the link is by string name across the type-system boundary). // graph.AddEdge dedupes → idempotent. func resolveTemporalCrossLanguage(g graph.Store) { // Go provider indexes, by name. goWorkflow := map[string][]string{} goSignalWf := map[string][]string{} goQueryWf := map[string][]string{} addGoWorkflow := func(n *graph.Node) { if n == nil || n.Language != "go" { return } if r, _ := n.Meta["temporal_role"].(string); r != "workflow" { return } name := n.Name if tn, _ := n.Meta["temporal_name"].(string); tn != "" { name = tn } goWorkflow[name] = append(goWorkflow[name], n.ID) if n.Name != name { goWorkflow[n.Name] = append(goWorkflow[n.Name], n.ID) } } for n := range g.NodesByKind(graph.KindFunction) { addGoWorkflow(n) } for n := range g.NodesByKind(graph.KindMethod) { addGoWorkflow(n) } for e := range g.EdgesByKind(graph.EdgeCalls) { if e == nil || e.Meta == nil || e.From == "" { continue } if v, _ := e.Meta["via"].(string); v != "temporal.handler" { continue } from := g.GetNode(e.From) if from == nil || from.Language != "go" { continue } kind, _ := e.Meta["temporal_kind"].(string) name, _ := e.Meta["temporal_name"].(string) if name == "" { continue } switch kind { case "signal": goSignalWf[name] = append(goSignalWf[name], e.From) case "query": goQueryWf[name] = append(goQueryWf[name], e.From) } } if len(goWorkflow) == 0 && len(goSignalWf) == 0 && len(goQueryWf) == 0 { return } type link struct { from, to, via string } var out []link consume := func(n *graph.Node) { if n == nil || n.Language != "java" { return } role, _ := n.Meta["temporal_role"].(string) name, _ := n.Meta["temporal_name"].(string) if name == "" { name = n.Name } var targets []string var via string switch role { case "workflow": targets, via = goWorkflow[name], "temporal.start-workflow" case "signal": targets, via = goSignalWf[name], "temporal.signal-link" case "query": targets, via = goQueryWf[name], "temporal.query-link" default: return } for _, to := range targets { if to != n.ID { out = append(out, link{from: n.ID, to: to, via: via}) } } } for n := range g.NodesByKind(graph.KindMethod) { consume(n) } for n := range g.NodesByKind(graph.KindFunction) { consume(n) } for _, l := range out { g.AddEdge(&graph.Edge{ From: l.from, To: l.to, Kind: graph.EdgeCalls, Origin: graph.OriginASTInferred, Meta: map[string]any{ "via": l.via, "cross_language": true, }, }) } } // temporalIndex maps (kind, name) to candidate handler nodes plus the // origin / confidence tier the resolver should stamp on the rewritten // edge. type temporalIndex struct { // byKindName maps "::" → handler candidate nodes. byKindName map[string][]*graph.Node // funcExact indexes EXPORTED Go functions / methods by their exact // (bare) name — including those WITHOUT the Activity/Workflow suffix and // those never passed to worker.Register*. Used only by lookupExactSig, // the precision-gated last resort for a PascalCase dispatch whose target // is a same-named, unregistered function (the "PascalCase, target in // another package/repo" category). Resolution there is gated on an exact // name match, a Temporal-shaped signature for the kind, and uniqueness. funcExact map[string][]*graph.Node // funcByName indexes Go functions / methods whose name follows the // activity / workflow naming convention (suffix "Activity" / // "Workflow"), keyed by bare name. Used as a last-resort, lower- // confidence resolution for dispatch to an UNREGISTERED activity — // the common case where activity repos hold the functions but the // `worker.Register*` calls live in a separate worker-runner (so the // register-based byKindName index never sees them). Pattern 2's // two-part name resolves here once F1/F2 reduce it to the func name. funcByName map[string][]*graph.Node } // isCrossRepoTestStub reports whether candidate n is a `*_test.go` node in a // DIFFERENT repo than the dispatching caller. Such a node is, in practice, a // test mock / fixture of the activity / workflow (a `workflow_test.go` stub), // not the real cross-repo implementation; matching a dispatch to it mints a // spurious edge — the one confirmed false positive in the L1 corpus audit (a // service repo's dispatch resolving to a `*_test.go` stub in an unrelated // workflow repo). Same-repo test files stay eligible: the overwhelmingly // common test-workflow → test-activity edge within one package is correct. // An empty callerRepo or candidate RepoPrefix can't establish the cross-repo // relation, so the node is left eligible (precision over recall in reverse — // we only suppress when we are sure both repos are known and differ). func isCrossRepoTestStub(n *graph.Node, callerRepo string) bool { if n == nil || callerRepo == "" || n.RepoPrefix == "" || n.RepoPrefix == callerRepo { return false } return strings.HasSuffix(n.FilePath, "_test.go") } // eligibleTemporalCandidates drops cross-repo `*_test.go` stub candidates (see // isCrossRepoTestStub) from a candidate list, returning the input unchanged // when nothing is suppressed. func eligibleTemporalCandidates(cands []*graph.Node, callerRepo string) []*graph.Node { if callerRepo == "" { return cands } var out []*graph.Node suppressed := false for _, n := range cands { if isCrossRepoTestStub(n, callerRepo) { suppressed = true continue } out = append(out, n) } if !suppressed { return cands } return out } func (idx *temporalIndex) lookup(kind, name, callerRepo, callerLang string) (id, origin string, confidence float64) { all := eligibleTemporalCandidates(idx.byKindName[kind+"::"+name], callerRepo) 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 { if callerRepo != "" && n.RepoPrefix == callerRepo { sameRepo = append(sameRepo, n) } } if len(sameRepo) == 1 { return sameRepo[0].ID, graph.OriginASTResolved, 0.9 } if len(sameRepo) == 0 && len(cands) == 1 { return cands[0].ID, graph.OriginASTResolved, 0.9 } return "", "", 0 } // lookupConvention resolves a dispatch name to a convention-named Go // function (suffix "Activity" / "Workflow" matching the kind) when no // registered handler matched — the unregistered-activity case (Pattern 2 // / Stage 1.2). Returns "" when there's no unambiguous candidate. The // caller stamps this at a lower (inferred) confidence than a // register-confirmed match. // // callerLang mirrors the same-language gate idx.lookup applies: a Go // workflow.ExecuteActivity dispatch resolves only to a Go function, never // to a like-named symbol in another language. func (idx *temporalIndex) lookupConvention(kind, name, callerRepo, callerLang string) string { // Drop cross-repo *_test.go stubs (test mocks of the activity/workflow) // the same way the register path does, so a convention match never lands // on a cross-repo test fixture. cands := eligibleTemporalCandidates(idx.funcByName[name], callerRepo) if len(cands) == 0 { return "" } suffix := "Activity" if kind == "workflow" { suffix = "Workflow" } var filtered, sameRepo []*graph.Node for _, n := range cands { if !strings.HasSuffix(n.Name, suffix) { continue } if callerLang != "" && n.Language != callerLang { continue } filtered = append(filtered, n) if callerRepo != "" && n.RepoPrefix == callerRepo { sameRepo = append(sameRepo, n) } } if len(sameRepo) == 1 { return sameRepo[0].ID } if len(filtered) == 1 { return filtered[0].ID } return "" } // lookupCrossLang is the cross-language fallback for a Temporal consumer // whose same-language lookup found no handler: it matches a candidate in a // DIFFERENT language by canonical name (e.g. a Java service that starts a // 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 } // signatureMatchesKind reports whether a function's first-parameter type // POSITIVELY matches the dispatch kind in the Temporal Go SDK convention: // activities take context.Context, workflows take workflow.Context. It // requires the expected type to be PRESENT in the signature AND the other // absent, so it is a strong, precision-positive gate for the exact-name // fallback (a kind mismatch — an activity dispatch onto a workflow.Context // function — abstains). func signatureMatchesKind(n *graph.Node, kind string) bool { if n == nil { return false } sig, _ := n.Meta["signature"].(string) if sig == "" { return false } want, other := "context.Context", "workflow.Context" if kind == "workflow" { want, other = "workflow.Context", "context.Context" } return strings.Contains(sig, want) && !strings.Contains(sig, other) } // lookupExactSig is the precision-gated last resort for a PascalCase dispatch // whose target is a same-named function that is neither register-confirmed // nor convention-suffixed (the "PascalCase, target in another package/repo" // category). It resolves ONLY when the dispatch name exactly equals an // exported Go func/method name, that candidate's signature matches the // dispatch kind (activity → context.Context / workflow → workflow.Context), // and the match is unique (same-repo preferred, else unique workspace-wide). // Cross-repo `*_test.go` candidates are dropped. The caller lands the result // at the speculative, hidden tier. Returns "" otherwise. func (idx *temporalIndex) lookupExactSig(kind, name, callerRepo string) string { cands := idx.funcExact[name] if len(cands) == 0 { return "" } var filtered, sameRepo []*graph.Node for _, n := range cands { if n == nil { continue } // Drop a cross-repo test stub: a different-repo candidate living in a // _test.go file is almost never the production handler. if callerRepo != "" && n.RepoPrefix != "" && n.RepoPrefix != callerRepo && strings.HasSuffix(n.FilePath, "_test.go") { continue } if !signatureMatchesKind(n, kind) { continue } filtered = append(filtered, n) if callerRepo != "" && n.RepoPrefix == callerRepo { sameRepo = append(sameRepo, n) } } if len(sameRepo) == 1 { return sameRepo[0].ID } if len(filtered) == 1 { return filtered[0].ID } return "" } // 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{}, funcExact: map[string][]*graph.Node{}, funcByName: map[string][]*graph.Node{}, } // funcExact index (Cat 4): every EXPORTED Go function / method by exact // bare name, for the signature-gated exact-name last resort. Bounded to // exported names carrying a signature so the map stays small; consulted // only when register / const-deref / cross-language all abstain. indexExactFunc := func(n *graph.Node) { if n == nil || n.Language != "go" || n.Name == "" { return } if c := n.Name[0]; c < 'A' || c > 'Z' { return } if sig, _ := n.Meta["signature"].(string); sig == "" { return } idx.funcExact[n.Name] = append(idx.funcExact[n.Name], n) } // Convention index: Go functions / methods named like activities or // workflows (suffix "Activity" / "Workflow"), for resolving dispatch // to functions the worker-runner registers elsewhere (unregistered // here). Bounded to the convention-named set to keep it small. Consumed // by lookupConvention as a last-resort fallback after the register- and // const-deref-based byKindName lookups miss. indexConventionFunc := func(n *graph.Node) { if n == nil || n.Language != "go" { return } if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod { return } if strings.HasSuffix(n.Name, "Activity") || strings.HasSuffix(n.Name, "Workflow") { idx.funcByName[n.Name] = append(idx.funcByName[n.Name], n) } } // Single sweep over functions + methods populates both indexes. indexFuncNode := func(n *graph.Node) { indexExactFunc(n) indexConventionFunc(n) } for n := range g.NodesByKind(graph.KindFunction) { indexFuncNode(n) } for n := range g.NodesByKind(graph.KindMethod) { indexFuncNode(n) } // Phase 1 — Go side. Walk the pre-collected `temporal.register` edges // and stamp the registered function's node. // // 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 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 registerEdges { if e == nil || e.Meta == nil { continue } kind, _ := e.Meta["temporal_kind"].(string) name, _ := e.Meta["temporal_name"].(string) if kind == "" || name == "" { continue } 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{}{} } registerNames[name] = struct{}{} } callerList := make([]string, 0, len(registerCallerIDs)) for id := range registerCallerIDs { callerList = append(callerList, id) } registerCallers := g.GetNodesByIDs(callerList) nameList := make([]string, 0, len(registerNames)) for n := range registerNames { nameList = append(nameList, n) } candidatesByName := g.FindNodesByNames(nameList) for _, r := range goRegisters { caller := registerCallers[r.edge.From] 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 } // Stamp + index under the canonical registered name (regName), // which is the func-ref name unless a RegisterOptions{Name} // override renamed it — that is the name a dispatch matches. stampTemporalRole(g, target, r.kind, r.regName) idx.byKindName[r.kind+"::"+r.regName] = append(idx.byKindName[r.kind+"::"+r.regName], target) } // Phase 2 — Java side. Walk the pre-collected temporal-annotation // `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 annotatedEdges { if e == nil { continue } role, methodRole := temporalRoleForJavaAnnotation(e.To) if role == "" && methodRole == "" { continue } 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{}{} } } annoFromList := make([]string, 0, len(annoFromIDs)) for id := range annoFromIDs { annoFromList = append(annoFromList, id) } annoFromNodes := g.GetNodesByIDs(annoFromList) type javaIfaceTag struct { ifaceID string role string // "activity_interface" / "workflow_interface" namePrefix string // @ActivityInterface(namePrefix = "...") } var javaIfaces []javaIfaceTag for _, a := range javaAnnos { from := annoFromNodes[a.fromID] if from == nil { continue } // 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) { 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, namePrefix: javaAnnotationStringArg(a.args, "namePrefix"), }) } } // Phase 3 — Java propagation. For each tagged interface, find its // methods (flat nodes living in the same file, within the // interface's line range) and stamp them. Then walk EdgeImplements // from each implementor and tag its same-named methods. // // Build a single Java method index up front via NodesByKind, then // project it into the two views the propagation needs: // - methodsByFile: file path → []*method (used for interface // methods, which the Java extractor emits as flat // :: nodes whose StartLine sits inside the // interface's line range). // - methodsByReceiver: receiver class name → []*method (used for // impl-class methods, which carry Meta["receiver"]). // One pass beats AllNodes() per interface. javaMethodsByFile, javaMethodsByReceiver := buildJavaMethodViews(g, len(javaIfaces)) // Prefetch the interface nodes + the implementing-type nodes for // the entire iface set so the propagation loop never issues an // inline GetNode. ifaceIDs := make([]string, 0, len(javaIfaces)) for _, t := range javaIfaces { ifaceIDs = append(ifaceIDs, t.ifaceID) } ifaceNodes := g.GetNodesByIDs(ifaceIDs) implTypeIDSet := map[string]struct{}{} implIDsByIface := map[string][]string{} for _, t := range javaIfaces { for _, ie := range g.GetInEdges(t.ifaceID) { if ie == nil || ie.Kind != graph.EdgeImplements { continue } implIDsByIface[t.ifaceID] = append(implIDsByIface[t.ifaceID], ie.From) if ie.From != "" { implTypeIDSet[ie.From] = struct{}{} } } } implTypeIDList := make([]string, 0, len(implTypeIDSet)) for id := range implTypeIDSet { implTypeIDList = append(implTypeIDList, id) } implTypeNodes := g.GetNodesByIDs(implTypeIDList) for _, t := range javaIfaces { methodRole := "activity" if t.role == "workflow_interface" { methodRole = "workflow" } iface := ifaceNodes[t.ifaceID] 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 { // A method carrying its own @WorkflowMethod / @SignalMethod / // @QueryMethod / @UpdateMethod annotation was already stamped // (with its name= override) in Phase 2 — don't let the // interface-level role clobber a more specific method role. if r, _ := m.Meta["temporal_role"].(string); r != "" { continue } 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{}{} for _, m := range ifaceMethods { implMethodNames[m.Name] = struct{}{} } for _, implTypeID := range implIDsByIface[t.ifaceID] { implType := implTypeNodes[implTypeID] if implType == nil { continue } for _, m := range methodsOfJavaTypeFromIndex(implType, javaMethodsByReceiver) { if _, ok := implMethodNames[m.Name]; !ok { continue } canonical := canonicalFor(m) stampTemporalRole(g, m, methodRole, canonical) idx.byKindName[methodRole+"::"+canonical] = append(idx.byKindName[methodRole+"::"+canonical], m) } } } return idx } // temporalRoleForJavaAnnotation maps a Java annotation node ID to a // (interface-role, method-role) pair. Only one is non-empty per // annotation; the caller uses whichever fits the annotated node kind. func temporalRoleForJavaAnnotation(annoID string) (ifaceRole, methodRole string) { switch annoID { case javaActivityIfaceAnnoID: return "activity_interface", "" case javaWorkflowIfaceAnnoID: return "workflow_interface", "" case javaActivityMethodID: return "", "activity" case javaWorkflowMethodID: return "", "workflow" case javaSignalMethodID: return "", "signal" case javaQueryMethodID: return "", "query" case javaUpdateMethodID: return "", "update" } 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. func normaliseTemporalKind(role string) string { switch role { case "workflow", "signal", "query", "update": return "workflow" default: return "activity" } } // stampTemporalRole writes `temporal_role` and `temporal_name` into a // node's Meta. Idempotent: re-stamping the same role is a no-op. When // a previously-stamped node is re-stamped with a different role the // new role wins (the resolver runs as a full recompute, so this lets // the latest registration take precedence). 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{} } n.Meta["temporal_role"] = role if name != "" { n.Meta["temporal_name"] = name } // Round-trip the stamp back through the store. On the in-memory // backend n is canonical so this is an idempotent re-insert; on disk // backends n is a per-call GetNode/AllNodes reconstruction, // so without the write-back temporal_role/temporal_name would be // discarded the moment this pass returns. ResolveTemporalCalls runs // from RunGlobalGraphPasses, which can execute after the bulk-load // buffer is flushed, so the in-place mutation is not otherwise // captured. Matches reach / coverage / blame / releases / churn. g.AddNode(n) } // pickGoTemporalTarget selects the Go function or method that a // `worker.Register*(F)` call refers to from a name-matched candidate // set. The register call lives at `caller`; the function `F` is // either declared in the same file or imported. The search order is: // // 1. Same-file function whose name matches. // 2. Same-repo function whose name matches. // 3. Unique workspace-wide function whose name matches. // // Returns nil when no unambiguous match exists. The candidate list // MUST be pre-filtered to Name == registered name (FindNodesByNames // already does that); this helper applies the Go-kind and language // gates plus the locality tie-break. func pickGoTemporalTarget(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 { continue } if n.Language != "go" { continue } if n.Kind != graph.KindFunction && n.Kind != graph.KindMethod { 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 } // 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. // // Const-to-const aliases (Cat 3, `const ALIAS = RealName`) are followed: a // requested name whose const node carries no literal but a Meta["const_ref"] // is chased to the referenced const's literal value by a bounded fixpoint, // so an ALL_CAPS dispatch name that is itself an alias (ALIAS = REAL = // "lit") still dereferences. Cycles never progress and are dropped at the cap. func buildConstDerefMap(g graph.Store, names []string) map[string]string { if len(names) == 0 { return nil } reader, _ := g.(graph.ConstantValueReader) nameSet := make(map[string]struct{}, len(names)) for _, n := range names { nameSet[n] = struct{}{} } // Expand the lookup set with every const_ref hop reachable from the // requested names so an alias chain's terminal literal is fetched too. // Bounded passes keep a malicious / cyclic graph from looping. The same // sweep collects Java string-constant fields (see javaFieldVals). idToName := map[string]string{} aliasRef := map[string]string{} // name → referenced const name // javaFieldVals collects Java string constants (`static final String`), // which the Java extractor emits as KindField nodes carrying the literal // on Meta["value"] rather than through the queryable constant sidecar. // Ingesting them lets a Java invoker const-ref dispatch // (`invoker.invokeAsync(Constants.X, …)`) deref X cross-language to the // registered Go workflow / activity. javaFieldVals := map[string]string{} resolveNames := func(want map[string]struct{}) { uniq := make([]string, 0, len(want)) for n := range want { uniq = append(uniq, n) } for name, cands := range g.FindNodesByNames(uniq) { for _, n := range cands { if n == nil { continue } switch { case n.Kind == graph.KindConstant || n.Kind == graph.KindFunction || n.Kind == graph.KindMethod: idToName[n.ID] = name if ref, ok := n.Meta["const_ref"].(string); ok && ref != "" { aliasRef[name] = ref } // A Java `static final String` is classified KindConstant // but carries its literal on Meta["value"] (the Java // extractor stamps it there, not in the queryable constant // sidecar). Ingest it so a Java const-ref Temporal dispatch // derefs cross-language, just like the KindField case below. if n.Language == "java" { if v, ok := n.Meta["value"].(string); ok && v != "" { javaFieldVals[name] = v } } case n.Kind == graph.KindField && n.Language == "java": if v, ok := n.Meta["value"].(string); ok && v != "" { javaFieldVals[name] = v } } } } } resolveNames(nameSet) for pass := 0; pass < 8; pass++ { pending := map[string]struct{}{} for _, ref := range aliasRef { if _, known := nameSet[ref]; !known { pending[ref] = struct{}{} } } if len(pending) == 0 { break } for n := range pending { nameSet[n] = struct{}{} } resolveNames(pending) } var constIDs []string for id := range idToName { constIDs = append(constIDs, id) } out := make(map[string]string) ambiguous := map[string]struct{}{} ingest := func(name, v string) { if name == "" || v == "" { return } if _, dropped := ambiguous[name]; dropped { return } if existing, seen := out[name]; seen && existing != v { delete(out, name) ambiguous[name] = struct{}{} return } out[name] = v } if reader != nil && len(constIDs) > 0 { if vals, err := reader.ConstantValuesByNodeIDs(constIDs); err == nil { for id, v := range vals { ingest(idToName[id], v) } } } // Java string-constant fields share the SAME dereference index, with the // same workspace-wide ambiguity rule as Go string constants. for name, v := range javaFieldVals { ingest(name, v) } // Collapse alias chains against the literal map by a bounded fixpoint: an // alias name with no literal of its own inherits its referent's resolved // literal. Cycles never progress and are dropped at the cap. for pass := 0; pass < 8; pass++ { progressed := false for name, ref := range aliasRef { if _, has := out[name]; has { continue } if v, ok := out[ref]; ok && v != "" { out[name] = v progressed = true } } if !progressed { break } } if len(out) == 0 { return nil } 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 // convention); methodsByReceiver groups nodes whose Meta carries a // non-empty receiver. One NodesByKind scan replaces the N AllNodes() // passes the old collectJavaInterfaceMethods + methodsOfJavaType // helpers ran inside the per-interface propagation loop. // // ifaceCount == 0 is a fast no-op; with no tagged interfaces the // indexes are unused so we skip the scan. func buildJavaMethodViews(g graph.Store, ifaceCount int) (map[string][]*graph.Node, map[string][]*graph.Node) { if ifaceCount == 0 { return nil, nil } methodsByFile := map[string][]*graph.Node{} methodsByReceiver := map[string][]*graph.Node{} for n := range g.NodesByKind(graph.KindMethod) { if n == nil || n.Language != "java" { continue } recv, _ := n.Meta["receiver"].(string) if recv == "" { methodsByFile[n.FilePath] = append(methodsByFile[n.FilePath], n) } else { methodsByReceiver[recv] = append(methodsByReceiver[recv], n) } } return methodsByFile, methodsByReceiver } // collectJavaInterfaceMethodsFromIndex returns the interface's method // nodes — flat KindMethod nodes in the interface's file whose // StartLine sits inside the interface's line range. Consumes the // methodsByFile view built by buildJavaMethodViews so the scan is // O(methods in this file) rather than O(every node). func collectJavaInterfaceMethodsFromIndex(iface *graph.Node, methodsByFile map[string][]*graph.Node) []*graph.Node { if iface == nil { return nil } var out []*graph.Node for _, n := range methodsByFile[iface.FilePath] { if n.StartLine < iface.StartLine || (iface.EndLine > 0 && n.StartLine > iface.EndLine) { continue } out = append(out, n) } return out } // methodsOfJavaTypeFromIndex returns the method nodes whose // Meta["receiver"] matches the type's name (or the receiver-suffix // shape on the class node's ID). Consumes the methodsByReceiver view // built by buildJavaMethodViews so the scan is O(methods of this // receiver) rather than O(every node). func methodsOfJavaTypeFromIndex(t *graph.Node, methodsByReceiver map[string][]*graph.Node) []*graph.Node { if t == nil { return nil } out := methodsByReceiver[t.Name] // Honour the legacy id-suffix tie-break: a class node's id is // `::`; a method whose receiver matches that // trailing component is still a member even when the receiver // Meta carries a fully-qualified name. for recv, candidates := range methodsByReceiver { if recv == t.Name { continue } if !strings.HasSuffix(t.ID, "::"+recv) { continue } out = append(out, candidates...) } return out } // resolveTemporalExecutorFields rewrites the dispatch name of a method // stub that reads a receiver field to the string literal the struct was // constructed with at its (possibly remote) construction site. // // PURPOSE — when a struct method reads a field to dispatch an activity/workflow // (e.g. `workflow.ExecuteActivity(ctx, e.ActivityName)`) and the struct was // constructed with a string literal for that field // (`ActivityExecutor{ActivityName: "ChargeCard"}`), this pass rewrites the // method stub's `temporal_name` from the field name to that literal, so the // main resolver sweep lands it on the registered handler. The dispatch happens // IN the method, so the call edge stays anchored to the method (get_callers on // the activity surfaces the dispatching method, not the construction site). // RATIONALE — two-edge join: the method-stub edge carries (recvType, field) // from the dispatch site; the executor-field marker edge carries // (type, field, value) from the construction site. The join key is // `recvType::fieldName`. The rewrite is re-derived from the marker edges on // every pass (never relying on the prior pass's mutation surviving), so it is // recompute-safe under the full-recompute contract of ResolveTemporalCalls: // the parser re-emits the stub with `temporal_name=` on reindex, and // this pass re-applies the literal before the main sweep runs. A // recvType::field with conflicting construction-site literals is left // unresolved — same unique-or-nothing policy as the const-deref join. // KEYWORDS — temporal, executor-field, resolver func resolveTemporalExecutorFields(g graph.Store) { // Phase 1: collect the method-stub edges that read a receiver field, // grouped by `recvType::field`. type dispatch struct { stubs []*graph.Edge } byField := map[string]*dispatch{} for e := range g.EdgesByKind(graph.EdgeCalls) { if e == nil || e.Meta == nil { continue } if v, _ := e.Meta["via"].(string); v != "temporal.stub" { continue } field, _ := e.Meta["temporal_name_field"].(string) rtype, _ := e.Meta["temporal_recv_type"].(string) kind, _ := e.Meta["temporal_kind"].(string) if field == "" || rtype == "" || kind == "" { continue } key := rtype + "::" + field d := byField[key] if d == nil { d = &dispatch{} byField[key] = d } d.stubs = append(d.stubs, e) } if len(byField) == 0 { return } // Phase 2: for each executor-field marker edge, collect the literal // construction value per `recvType::field`. A key with conflicting // values across construction sites is ambiguous and dropped. valByField := map[string]string{} ambiguous := map[string]struct{}{} for e := range g.EdgesByKind(graph.EdgeCalls) { if e == nil || e.Meta == nil || e.From == "" { continue } if v, _ := e.Meta["via"].(string); v != "temporal.executor-field" { continue } rtype, _ := e.Meta["executor_type"].(string) field, _ := e.Meta["executor_field"].(string) value, _ := e.Meta["executor_value"].(string) if rtype == "" || field == "" || value == "" { continue } key := rtype + "::" + field if _, ok := byField[key]; !ok { continue } if existing, seen := valByField[key]; seen && existing != value { ambiguous[key] = struct{}{} continue } valByField[key] = value } for key := range ambiguous { delete(valByField, key) } // Phase 3: rewrite each matched method stub's dispatch name to the // construction literal. e.To is left for the main sweep to recompute // from the new temporal_name; temporal_name_field / temporal_recv_type // are preserved as the join key for the next full-recompute pass. for key, value := range valByField { d := byField[key] if d == nil { continue } for _, e := range d.stubs { e.Meta["temporal_name"] = value e.Meta["temporal_via_executor"] = true } } }