a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
1911 lines
66 KiB
Go
1911 lines
66 KiB
Go
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::<kind>::<name>`).
|
|
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"]=<F>`;
|
|
// (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::<kind>::<name>` 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 `<caller>#param:<name>` 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>::<name>.
|
|
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 "<kind>::<name>" → 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 "<kind>::<name>" 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
|
|
// <file>::<name> 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
|
|
// `<filePath>::<ClassName>`; 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=<field>` 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
|
|
}
|
|
}
|
|
}
|