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
1435 lines
51 KiB
Go
1435 lines
51 KiB
Go
// Temporal Go SDK call attribution.
|
|
//
|
|
// Workflows orchestrate activities through a thin set of dispatch
|
|
// helpers exposed by `go.temporal.io/sdk/workflow`:
|
|
//
|
|
// workflow.ExecuteActivity(ctx, ActivityFn, args...)
|
|
// workflow.ExecuteLocalActivity(ctx, ActivityFn, args...)
|
|
// workflow.ExecuteChildWorkflow(ctx, WorkflowFn, args...)
|
|
//
|
|
// and activities / workflows enter the runtime via
|
|
// `go.temporal.io/sdk/worker`:
|
|
//
|
|
// w.RegisterActivity(MyActivity)
|
|
// w.RegisterActivityWithOptions(MyActivity, activity.RegisterOptions{Name: "..."})
|
|
// w.RegisterWorkflow(MyWorkflow)
|
|
// w.RegisterWorkflowWithOptions(MyWorkflow, workflow.RegisterOptions{Name: "..."})
|
|
//
|
|
// Tree-sitter sees `workflow.ExecuteActivity(...)` as a selector_expression
|
|
// call whose receiver text is "workflow" and method is the helper name;
|
|
// `w.RegisterActivity(...)` as a selector call whose method is the
|
|
// register helper. Neither shape resolves to anything useful through
|
|
// the normal Go call-resolution path (the target lives in an external
|
|
// SDK module). The helpers below recognise the call shapes and stamp
|
|
// dedicated `via=temporal.stub` / `via=temporal.register` placeholders
|
|
// that the resolver's ResolveTemporalCalls pass turns into edges from
|
|
// the workflow to the activity (or from one workflow to the child
|
|
// workflow) it dispatches.
|
|
|
|
package languages
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/parser"
|
|
sitter "github.com/zzet/gortex/internal/parser/tsitter"
|
|
)
|
|
|
|
// goWorkflowPkgPath is the canonical import path of the Temporal Go SDK
|
|
// workflow package whose helpers (ExecuteActivity, SetQueryHandler,
|
|
// SignalExternalWorkflow, …) the detectors gate on.
|
|
const goWorkflowPkgPath = "go.temporal.io/sdk/workflow"
|
|
|
|
// goWorkflowReceiverAlias returns the local name the workflow package is
|
|
// imported under in this file — the explicit alias for
|
|
// `import wf "go.temporal.io/sdk/workflow"`, or "workflow" for a plain
|
|
// import. Returns "" when the file does not import the workflow package.
|
|
// The detectors canonicalise a matching receiver to "workflow" so an
|
|
// aliased import (`wf.ExecuteActivity(...)`) is still recognised.
|
|
func goWorkflowReceiverAlias(root *sitter.Node, src []byte) string {
|
|
if root == nil {
|
|
return ""
|
|
}
|
|
var found string
|
|
var walk func(n *sitter.Node)
|
|
walk = func(n *sitter.Node) {
|
|
if n == nil || found != "" {
|
|
return
|
|
}
|
|
if n.Type() == "import_spec" {
|
|
pathNode := n.ChildByFieldName("path")
|
|
if pathNode != nil {
|
|
p := pathNode.Content(src)
|
|
if len(p) >= 2 {
|
|
p = p[1 : len(p)-1] // strip the surrounding quotes
|
|
}
|
|
if p == goWorkflowPkgPath {
|
|
if nameNode := n.ChildByFieldName("name"); nameNode != nil {
|
|
found = nameNode.Content(src)
|
|
} else if i := strings.LastIndex(goWorkflowPkgPath, "/"); i >= 0 {
|
|
found = goWorkflowPkgPath[i+1:]
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walk(n.NamedChild(i))
|
|
if found != "" {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
walk(root)
|
|
return found
|
|
}
|
|
|
|
// goCanonicalWorkflowReceiver maps a call receiver to "workflow" when it
|
|
// matches the file's workflow-package alias, so the receiver-gated
|
|
// detectors recognise an aliased import. Other receivers pass through
|
|
// unchanged. wfAlias == "" (package not imported) is a no-op.
|
|
func goCanonicalWorkflowReceiver(receiver, wfAlias string) string {
|
|
if wfAlias != "" && receiver == wfAlias {
|
|
return "workflow"
|
|
}
|
|
return receiver
|
|
}
|
|
|
|
// goTemporalDispatchKind reports whether (receiver, method) names one
|
|
// of the Temporal workflow dispatch helpers and, if so, returns the
|
|
// canonical kind ("activity" or "workflow") plus whether the call is
|
|
// the `LocalActivity` variant. Returns ok=false for everything else.
|
|
//
|
|
// We require the receiver text to be exactly "workflow" — the
|
|
// canonical SDK alias. Users who alias the import (e.g.
|
|
// `import wf "go.temporal.io/sdk/workflow"`) won't be detected, which
|
|
// matches how the existing gRPC stub detector handles SDK aliasing
|
|
// (the canonical alias dominates >99% of real-world code).
|
|
func goTemporalDispatchKind(receiver, method string) (kind string, local bool, ok bool) {
|
|
if receiver != "workflow" {
|
|
return "", false, false
|
|
}
|
|
switch method {
|
|
case "ExecuteActivity":
|
|
return "activity", false, true
|
|
case "ExecuteLocalActivity":
|
|
return "activity", true, true
|
|
case "ExecuteChildWorkflow":
|
|
return "workflow", false, true
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
// goTemporalRegisterKind reports whether a method name is one of the
|
|
// Temporal worker registration helpers and, if so, returns the kind
|
|
// ("activity" or "workflow") being registered. The receiver isn't
|
|
// required — `RegisterActivity` is distinctive enough across the SDK
|
|
// surface that a name match has zero realistic false positives.
|
|
//
|
|
// `RegisterActivities` (plural — registers every exported method on
|
|
// a struct as an activity) is recognised too; the resolver pass will
|
|
// promote each method of the struct to a temporal activity.
|
|
func goTemporalRegisterKind(method string) (kind string, plural bool, ok bool) {
|
|
switch method {
|
|
case "RegisterActivity", "RegisterActivityWithOptions":
|
|
return "activity", false, true
|
|
case "RegisterWorkflow", "RegisterWorkflowWithOptions":
|
|
return "workflow", false, true
|
|
case "RegisterActivities":
|
|
return "activity", true, true
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
// goTemporalSignalQueryOutKind reports whether (receiver, method) names
|
|
// an OUTBOUND signal-send or query-call against an already-running
|
|
// workflow and, if so, returns the kind ("signal" / "query") plus the
|
|
// 1-based position of the signal/query-name argument.
|
|
//
|
|
// workflow.SignalExternalWorkflow(ctx, wid, rid, "name", arg) // wf -> wf
|
|
// client.SignalWorkflow(ctx, wid, rid, "name", arg) // svc -> wf
|
|
// client.QueryWorkflow(ctx, wid, rid, "name", args...) // svc -> wf
|
|
//
|
|
// SignalExternalWorkflow is gated on the canonical "workflow" receiver
|
|
// (it is a workflow-package function). SignalWorkflow / QueryWorkflow
|
|
// live on the client and are called on an arbitrary client variable, so
|
|
// — like the Register* helpers — they are matched by method name alone;
|
|
// the string-literal name gate below keeps that high-precision. There is
|
|
// deliberately no workflow.QueryWorkflow (querying is client-side) and no
|
|
// SignalExternalWorkflowAsync (SignalExternalWorkflow returns a Future).
|
|
func goTemporalSignalQueryOutKind(receiver, method string) (kind string, namePos int, ok bool) {
|
|
switch method {
|
|
case "SignalExternalWorkflow":
|
|
if receiver == "workflow" {
|
|
return "signal", 4, true
|
|
}
|
|
case "SignalWorkflow":
|
|
return "signal", 4, true
|
|
case "QueryWorkflow":
|
|
return "query", 4, true
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
// goTemporalHandlerKind reports whether (receiver, method) names one of
|
|
// the Temporal in-workflow handler-declaration helpers and, if so,
|
|
// returns the canonical kind ("query" / "signal" / "update").
|
|
//
|
|
// workflow.SetQueryHandler(ctx, "name", fn)
|
|
// workflow.SetQueryHandlerWithOptions(ctx, "name", fn, opts)
|
|
// workflow.GetSignalChannel(ctx, "name")
|
|
// workflow.GetSignalChannelWithOptions(ctx, "name", opts)
|
|
// workflow.SetUpdateHandler(ctx, "name", fn)
|
|
// workflow.SetUpdateHandlerWithOptions(ctx, "name", fn, opts)
|
|
//
|
|
// These mirror the Java SDK's `@QueryMethod` / `@SignalMethod` /
|
|
// `@UpdateMethod` annotations: a workflow declares, from inside its
|
|
// body, the named query / signal / update channels it serves. As with
|
|
// the dispatch helpers we require the receiver text to be exactly the
|
|
// canonical "workflow" alias.
|
|
func goTemporalHandlerKind(receiver, method string) (kind string, ok bool) {
|
|
if receiver != "workflow" {
|
|
return "", false
|
|
}
|
|
switch method {
|
|
case "SetQueryHandler", "SetQueryHandlerWithOptions":
|
|
return "query", true
|
|
case "GetSignalChannel", "GetSignalChannelWithOptions":
|
|
return "signal", true
|
|
case "SetUpdateHandler", "SetUpdateHandlerWithOptions":
|
|
return "update", true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// goTemporalHandlerName extracts the query / signal / update name from a
|
|
// handler-declaration call — the second positional argument (after the
|
|
// workflow.Context). Unlike dispatch names we accept ONLY a string
|
|
// literal: handler names are matched by string at runtime, so a
|
|
// non-literal (variable / selector) can't be pinned to a name here and
|
|
// is left undetected, keeping the detector high-precision. Returns ""
|
|
// when the second argument is missing or is not a string literal.
|
|
func goTemporalHandlerName(callNode *sitter.Node, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return ""
|
|
}
|
|
count := 0
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
if count == 2 {
|
|
switch c.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
return goTemporalNameFromExpr(c, src)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// goTemporalDispatchArg returns the second positional argument node of a
|
|
// dispatch call (`workflow.ExecuteActivity(ctx, X, args...)` → X), or
|
|
// nil. X is either a string literal ("MyActivity"), a bare identifier
|
|
// (MyActivity), or a selector expression (pkg.MyActivity, recv.Method);
|
|
// goTemporalNameFromExpr reduces it to the trailing identifier — the
|
|
// name the worker registers under (the bare function name unless
|
|
// `RegisterActivityWithOptions` overrides it). Returned as a node, not a
|
|
// reduced name, so the env-default refinement can inspect the argument's
|
|
// shape (a bare identifier is the only case it tries to resolve to a
|
|
// literal default). Returns nil when the call has fewer than two
|
|
// positional arguments.
|
|
func goTemporalDispatchArg(callNode *sitter.Node) *sitter.Node {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return nil
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return nil
|
|
}
|
|
count := 0
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
if count == 2 {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// goTemporalRegisterName extracts the registered function name from a
|
|
// `worker.RegisterActivity(F)` / `worker.RegisterWorkflow(F)` call —
|
|
// the first positional argument, which is the function reference.
|
|
// Same expression shapes as the dispatch-name argument.
|
|
func goTemporalRegisterName(callNode *sitter.Node, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return ""
|
|
}
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
return goTemporalNameFromExpr(c, src)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// applyGoTemporalRegisterMeta stamps `via=temporal.register` plus
|
|
// `temporal_kind` (activity / workflow) and `temporal_name` (the
|
|
// function-reference identifier) onto an EdgeCalls edge derived from
|
|
// a Temporal worker-registration call. No-op when c.tempKind isn't
|
|
// the "register_*" form set by goTemporalRegisterKind.
|
|
//
|
|
// The resolver's ResolveTemporalCalls pass walks every edge carrying
|
|
// this meta to discover (name → registered function) pairs, then
|
|
// stamps `temporal_role` on the registered function nodes and uses
|
|
// the map to rewrite matching stub-call placeholders.
|
|
func applyGoTemporalRegisterMeta(edge *graph.Edge, c goDeferredCall) {
|
|
if edge == nil || c.tempKind == "" || c.tempName == "" {
|
|
return
|
|
}
|
|
var kind string
|
|
switch c.tempKind {
|
|
case "register_activity":
|
|
kind = "activity"
|
|
case "register_workflow":
|
|
kind = "workflow"
|
|
default:
|
|
return
|
|
}
|
|
if edge.Meta == nil {
|
|
edge.Meta = map[string]any{}
|
|
}
|
|
edge.Meta["via"] = "temporal.register"
|
|
edge.Meta["temporal_kind"] = kind
|
|
edge.Meta["temporal_name"] = c.tempName
|
|
if c.tempRegisteredName != "" {
|
|
edge.Meta["temporal_registered_name"] = c.tempRegisteredName
|
|
}
|
|
if c.tempRegisterPlural {
|
|
edge.Meta["temporal_register_plural"] = true
|
|
}
|
|
}
|
|
|
|
// goTemporalRegisterStructType returns the struct TYPE name from the first
|
|
// argument of a `w.RegisterActivities(&MyActivities{})` call — the struct
|
|
// whose exported methods are each registered as an activity. Handles the
|
|
// `&T{}` pointer and `T{}` value composite-literal forms and a qualified
|
|
// `pkg.T{}`. Returns "" when the argument is not a composite literal (e.g.
|
|
// a pre-built variable, which carries no static type here).
|
|
func goTemporalRegisterStructType(callNode *sitter.Node, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil || args.NamedChildCount() == 0 {
|
|
return ""
|
|
}
|
|
arg := args.NamedChild(0)
|
|
if arg == nil {
|
|
return ""
|
|
}
|
|
if arg.Type() == "unary_expression" {
|
|
if op := arg.ChildByFieldName("operand"); op != nil {
|
|
arg = op
|
|
}
|
|
}
|
|
if arg.Type() != "composite_literal" {
|
|
return ""
|
|
}
|
|
typ := arg.ChildByFieldName("type")
|
|
if typ == nil {
|
|
return ""
|
|
}
|
|
switch typ.Type() {
|
|
case "type_identifier", "identifier":
|
|
return typ.Content(src)
|
|
case "qualified_type":
|
|
if name := typ.ChildByFieldName("name"); name != nil {
|
|
return name.Content(src)
|
|
}
|
|
case "pointer_type":
|
|
// `&T` already unwrapped above, but a `*T` element type can appear.
|
|
if inner := typ.ChildByFieldName("type"); inner != nil {
|
|
switch inner.Type() {
|
|
case "type_identifier", "identifier":
|
|
return inner.Content(src)
|
|
case "qualified_type":
|
|
if name := inner.ChildByFieldName("name"); name != nil {
|
|
return name.Content(src)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// goTemporalRegisterNameOverride extracts the `Name:` string-literal
|
|
// field from the RegisterOptions composite literal passed as the second
|
|
// argument of a `RegisterActivityWithOptions` / `RegisterWorkflowWithOptions`
|
|
// call — the canonical registered name that overrides the bare function
|
|
// name (the name an `ExecuteActivity(ctx, "<name>", …)` dispatch must
|
|
// match). Returns "" when there is no second composite-literal argument or
|
|
// no string-literal Name field.
|
|
//
|
|
// w.RegisterActivityWithOptions(MyActivity,
|
|
// activity.RegisterOptions{Name: "ChargeCard"})
|
|
func goTemporalRegisterNameOverride(callNode *sitter.Node, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return ""
|
|
}
|
|
// Second positional argument = the options struct.
|
|
var opts *sitter.Node
|
|
count := 0
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
if count == 2 {
|
|
opts = c
|
|
break
|
|
}
|
|
}
|
|
if opts == nil {
|
|
return ""
|
|
}
|
|
// Unwrap a `&RegisterOptions{...}` pointer literal.
|
|
if opts.Type() == "unary_expression" {
|
|
if op := opts.ChildByFieldName("operand"); op != nil {
|
|
opts = op
|
|
}
|
|
}
|
|
if opts.Type() != "composite_literal" {
|
|
return ""
|
|
}
|
|
body := opts.ChildByFieldName("body")
|
|
if body == nil {
|
|
return ""
|
|
}
|
|
unwrap := func(n *sitter.Node) *sitter.Node {
|
|
// A keyed-element key/value may be wrapped in a literal_element
|
|
// node depending on the grammar revision; reduce to the inner node.
|
|
if n != nil && n.Type() == "literal_element" && n.NamedChildCount() == 1 {
|
|
return n.NamedChild(0)
|
|
}
|
|
return n
|
|
}
|
|
for i, _nc := 0, int(body.NamedChildCount()); i < _nc; i++ {
|
|
kv := body.NamedChild(i)
|
|
if kv == nil || kv.Type() != "keyed_element" || kv.NamedChildCount() < 2 {
|
|
continue
|
|
}
|
|
key := unwrap(kv.NamedChild(0))
|
|
val := unwrap(kv.NamedChild(1))
|
|
if key == nil || val == nil || key.Content(src) != "Name" {
|
|
continue
|
|
}
|
|
if lit, ok := goStringLiteralValue(val, src); ok {
|
|
return lit
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// applyGoTemporalHandlerMeta stamps `via=temporal.handler` plus
|
|
// `temporal_kind` (query / signal / update) and `temporal_name` (the
|
|
// handler's string name) onto the EdgeCalls edge derived from a
|
|
// `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler`
|
|
// call. No-op when c.tempHandlerKind / c.tempName are unset.
|
|
//
|
|
// The edge originates from the enclosing workflow function, so the
|
|
// graph records — per workflow — the named query / signal / update
|
|
// handlers it exposes, symmetric with the Java side's per-method
|
|
// `@QueryMethod` / `@SignalMethod` / `@UpdateMethod` annotation edges.
|
|
func applyGoTemporalHandlerMeta(edge *graph.Edge, c goDeferredCall) {
|
|
if edge == nil || c.tempHandlerKind == "" || c.tempName == "" {
|
|
return
|
|
}
|
|
if edge.Meta == nil {
|
|
edge.Meta = map[string]any{}
|
|
}
|
|
edge.Meta["via"] = "temporal.handler"
|
|
edge.Meta["temporal_kind"] = c.tempHandlerKind
|
|
edge.Meta["temporal_name"] = c.tempName
|
|
}
|
|
|
|
// applyGoTemporalSignalQueryMeta stamps the outbound signal-send /
|
|
// query-call meta onto an EdgeCalls edge derived from
|
|
// `SignalExternalWorkflow` / `SignalWorkflow` / `QueryWorkflow`:
|
|
// `via=temporal.signal-send` or `temporal.query-call`, plus
|
|
// `temporal_kind` (signal / query) and `temporal_name` (the literal
|
|
// signal/query name). No-op when c.tempOutKind / c.tempName are unset.
|
|
//
|
|
// These are the consumer side of the signal/query namespaces; the
|
|
// provider side is the in-workflow handler (GetSignalChannel /
|
|
// SetQueryHandler), tagged via=temporal.handler.
|
|
func applyGoTemporalSignalQueryMeta(edge *graph.Edge, c goDeferredCall) {
|
|
if edge == nil || c.tempOutKind == "" || c.tempName == "" {
|
|
return
|
|
}
|
|
var via string
|
|
switch c.tempOutKind {
|
|
case "signal":
|
|
via = "temporal.signal-send"
|
|
case "query":
|
|
via = "temporal.query-call"
|
|
default:
|
|
return
|
|
}
|
|
if edge.Meta == nil {
|
|
edge.Meta = map[string]any{}
|
|
}
|
|
edge.Meta["via"] = via
|
|
edge.Meta["temporal_kind"] = c.tempOutKind
|
|
edge.Meta["temporal_name"] = c.tempName
|
|
}
|
|
|
|
// markGoTemporalWrapper stamps a dispatch-wrapper marker on the enclosing
|
|
// function node: a function that calls workflow.ExecuteActivity /
|
|
// ExecuteChildWorkflow with one of its own parameters as the dispatch
|
|
// name. temporal_wrapper_kind records the kind (activity / workflow) and
|
|
// temporal_wrapper_param the forwarded parameter name. The marker lets a
|
|
// future interprocedural pass propagate a caller's literal/const argument
|
|
// through the wrapper to the real handler; today it documents the wrapper
|
|
// so the unresolvable parameter-named stub is suppressed rather than
|
|
// emitted as noise.
|
|
func markGoTemporalWrapper(result *parser.ExtractionResult, callerID, kind, param string) {
|
|
if result == nil || callerID == "" {
|
|
return
|
|
}
|
|
for _, n := range result.Nodes {
|
|
if n.ID == callerID {
|
|
if n.Meta == nil {
|
|
n.Meta = map[string]any{}
|
|
}
|
|
n.Meta["temporal_wrapper_kind"] = kind
|
|
n.Meta["temporal_wrapper_param"] = param
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// goTemporalStartKind reports whether a method name is one of the
|
|
// service-side workflow-START helpers and, if so, returns the 1-based
|
|
// positional index of the workflow argument.
|
|
//
|
|
// client.ExecuteWorkflow(ctx, opts, workflow, args...) // workflow @ 3
|
|
// client.SignalWithStartWorkflow(ctx, wfID, sig, arg, opts, workflow, args...) // workflow @ 6
|
|
//
|
|
// Both are client methods invoked on an arbitrary client variable, so —
|
|
// like SignalWorkflow / QueryWorkflow and the Register* helpers — they are
|
|
// matched by method name alone; ExecuteWorkflow / SignalWithStartWorkflow
|
|
// are distinctive enough across the SDK surface for that to be precise.
|
|
func goTemporalStartKind(method string) (wfPos int, ok bool) {
|
|
switch method {
|
|
case "ExecuteWorkflow":
|
|
return 3, true
|
|
case "SignalWithStartWorkflow":
|
|
return 6, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// goTemporalNthArgName reduces the n-th (1-based) positional argument of a
|
|
// call to the trailing identifier that names a workflow — handling a func
|
|
// reference (OrderWorkflow), a selector (pkg.OrderWorkflow), or a string
|
|
// type name ("OrderWorkflow"), via goTemporalNameFromExpr. Returns "" when
|
|
// the call has fewer than n positional arguments or the argument is not a
|
|
// reducible name. Unlike goTemporalNthStringLiteralArg this accepts a
|
|
// non-literal, because a workflow START usually passes the workflow
|
|
// function value, whose name is the registered type.
|
|
func goTemporalNthArgName(callNode *sitter.Node, n int, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return ""
|
|
}
|
|
count := 0
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
if count == n {
|
|
return goTemporalNameFromExpr(c, src)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// applyGoTemporalStartMeta stamps `via=temporal.start` plus
|
|
// `temporal_kind=workflow` and `temporal_name` (the started workflow's
|
|
// name) onto the EdgeCalls edge derived from a client.ExecuteWorkflow /
|
|
// SignalWithStartWorkflow call. No-op when c.tempStartName is unset. The
|
|
// resolver rewrites this edge to the registered workflow node, so
|
|
// get_callers on a Go workflow surfaces the services that start it.
|
|
func applyGoTemporalStartMeta(edge *graph.Edge, c goDeferredCall) {
|
|
if edge == nil || c.tempStartName == "" {
|
|
return
|
|
}
|
|
if edge.Meta == nil {
|
|
edge.Meta = map[string]any{}
|
|
}
|
|
edge.Meta["via"] = "temporal.start"
|
|
edge.Meta["temporal_kind"] = "workflow"
|
|
edge.Meta["temporal_name"] = c.tempStartName
|
|
}
|
|
|
|
// goTemporalNthStringLiteralArg returns the unquoted value of the n-th
|
|
// (1-based) positional argument of a call when that argument is a string
|
|
// literal, else "". Used to extract the signal/query name from an
|
|
// outbound send/call — names are matched by string at runtime, so only a
|
|
// literal can be pinned here (a variable / constant is left undetected,
|
|
// keeping the detector high-precision).
|
|
func goTemporalNthStringLiteralArg(callNode *sitter.Node, n int, src []byte) string {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return ""
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return ""
|
|
}
|
|
count := 0
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
if count == n {
|
|
switch c.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
return goTemporalNameFromExpr(c, src)
|
|
}
|
|
return ""
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// goTemporalNameFromExpr reduces a single argument expression to the
|
|
// trailing identifier that names the activity / workflow. Handles
|
|
// string literals (`"MyActivity"` and the Go raw-string variant),
|
|
// bare identifiers (`MyActivity`), and selector expressions
|
|
// (`pkg.MyActivity`, `a.Method`). Returns "" for any other shape
|
|
// (function literals, ternary-style expressions, etc.) — keeps the
|
|
// detector high-precision rather than guessing.
|
|
func goTemporalNameFromExpr(node *sitter.Node, src []byte) string {
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
switch node.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
text := node.Content(src)
|
|
if len(text) >= 2 && (text[0] == '"' || text[0] == '`') {
|
|
return text[1 : len(text)-1]
|
|
}
|
|
return text
|
|
case "identifier":
|
|
return node.Content(src)
|
|
case "selector_expression":
|
|
if field := node.ChildByFieldName("field"); field != nil {
|
|
return field.Content(src)
|
|
}
|
|
case "unary_expression":
|
|
// `&MyActivity` (rare; mostly seen for struct-method registration)
|
|
if op := node.ChildByFieldName("operand"); op != nil {
|
|
return goTemporalNameFromExpr(op, src)
|
|
}
|
|
case "call_expression":
|
|
// `GetX()` / `pkg.GetX()` — the dispatch name is the called func's
|
|
// trailing identifier; the resolver derefs it to the func's return
|
|
// literal via the const-deref map. Recurse into the `function`
|
|
// child: a selector resolves to "GetX", a bare identifier to "GetX".
|
|
if fn := node.ChildByFieldName("function"); fn != nil {
|
|
return goTemporalNameFromExpr(fn, src)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// goTemporalEnvDefaultName attempts to resolve a bare-identifier dispatch
|
|
// name to the string-literal default of an env-var-with-default
|
|
// assignment in the enclosing function. Returns the default and true for
|
|
// one of these shapes (anchored on a literal os.Getenv / os.LookupEnv
|
|
// read so the value is provably env-sourced):
|
|
//
|
|
// name := cmp.Or(os.Getenv("KEY"), "Default") // any call mixing an
|
|
// // os.Getenv read with a
|
|
// // string-literal arg
|
|
// name := os.Getenv("KEY")
|
|
// if name == "" { name = "Default" } // (or `name, ok := os.LookupEnv(...)`
|
|
// // followed by a literal assign)
|
|
//
|
|
// Intra-procedural and literal-only: only assignments lexically before
|
|
// the dispatch call are considered, and anything that isn't an
|
|
// os.Getenv-anchored literal default returns "", false. This is a
|
|
// deliberately narrow data-flow shortcut, not general constant
|
|
// propagation — see the speculative tier the resolver lands it at.
|
|
// The result is reported as exactly one of `litDef` (a string-literal default)
|
|
// or `constName` (a constant-reference default the resolver substitutes through
|
|
// constVal), plus a `source` tier marker: "os_getenv" / "allowlist" (trusted
|
|
// literal), "const_ref" (trusted helper with a constant default — same visible
|
|
// tier as allowlist), or "heuristic" (env-named-helper guess, hidden tier;
|
|
// stays heuristic even when its default is a constant, the helper itself being
|
|
// the weak link).
|
|
func goTemporalEnvDefaultName(callNode *sitter.Node, name string, src []byte, extra map[string]bool) (litDef string, constName string, source string, ok bool) {
|
|
body := goEnclosingFuncBody(callNode)
|
|
if body == nil {
|
|
return "", "", "", false
|
|
}
|
|
limit := callNode.StartByte()
|
|
|
|
// Collect every assignment to `name` lexically before the dispatch
|
|
// call, in source order, WITHOUT descending into nested func_literal
|
|
// bodies — a closure is a separate scope, and matching a shadowing
|
|
// same-named variable declared there would be a false positive.
|
|
var assigns []*sitter.Node
|
|
var walk func(n *sitter.Node)
|
|
walk = func(n *sitter.Node) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
if n.Type() == "func_literal" {
|
|
return // do not descend into nested closures
|
|
}
|
|
if (n.Type() == "short_var_declaration" || n.Type() == "assignment_statement") &&
|
|
n.StartByte() < limit && goAssignHasTarget(n, name, src) {
|
|
assigns = append(assigns, n)
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walk(n.NamedChild(i))
|
|
}
|
|
}
|
|
walk(body)
|
|
|
|
// Replay the writes in order. The dispatch name is env-default-sourced
|
|
// only if, after the LAST write before the call, the variable still
|
|
// holds an env-or-default value: either a `cmp.Or(os.Getenv, "lit")`
|
|
// assignment, an allow-listed / heuristic env-helper call
|
|
// (`GetEnvOrDefault(KEY, "lit")` / `cfg.ActivityFromEnv(KEY, "lit")`),
|
|
// or a string-literal assignment that followed an os.Getenv /
|
|
// os.LookupEnv read (the `name := os.Getenv(...); if name == "" { name =
|
|
// "lit" }` shape). Any other later write — a plain reassignment `name =
|
|
// pick()` — clears the env-sourcing, and we leave the dispatch
|
|
// unresolved rather than guess.
|
|
//
|
|
// resolvedSource records HOW the default was recognised so the resolver
|
|
// can tier the edge: "os_getenv" / "allowlist" / "const_ref" land at the
|
|
// inferred (visible) tier, "heuristic" stays at the hidden speculative
|
|
// tier. The default is reported as exactly one of resLit (a string-literal
|
|
// default) or resConst (a constant-reference default the resolver
|
|
// substitutes through its const-deref index).
|
|
resLit := ""
|
|
resConst := ""
|
|
resolvedSource := ""
|
|
resolvedOK := false
|
|
envReadSeen := false
|
|
// commit records a resolved default, routing a constant-reference value
|
|
// into resConst (and downgrading "const_ref" → "heuristic" when the helper
|
|
// itself is only a heuristic match — the helper, not the constant, is the
|
|
// weak link).
|
|
commit := func(val string, isConst bool, trustedSource string) {
|
|
if isConst {
|
|
resLit, resConst = "", val
|
|
if trustedSource == "heuristic" {
|
|
resolvedSource = "heuristic"
|
|
} else {
|
|
resolvedSource = "const_ref"
|
|
}
|
|
} else {
|
|
resLit, resConst, resolvedSource = val, "", trustedSource
|
|
}
|
|
resolvedOK, envReadSeen = true, false
|
|
}
|
|
clear := func() {
|
|
resLit, resConst, resolvedSource, resolvedOK, envReadSeen = "", "", "", false, false
|
|
}
|
|
for _, a := range assigns {
|
|
rhs := goAssignRHSExpr(a, name, src)
|
|
switch {
|
|
case rhs == nil:
|
|
clear()
|
|
case rhs.Type() == "call_expression" && goIsEnvRead(rhs, src):
|
|
// `name := os.Getenv("K")` — default still pending.
|
|
clear()
|
|
envReadSeen = true
|
|
case rhs.Type() == "call_expression":
|
|
// `name := cmp.Or(os.Getenv("K"), "lit")` — self-contained.
|
|
if v, isC, ok := goCallEnvDefaultLiteral(rhs, src); ok {
|
|
commit(v, isC, "os_getenv")
|
|
} else if v, isC, ok := goEnvHelperDefaultLiteral(rhs, src, extra); ok {
|
|
// Allow-listed env-helper (`GetEnvOrDefault(KEY, "lit")`).
|
|
commit(v, isC, "allowlist")
|
|
} else if v, isC, ok := goEnvHelperHeuristicDefault(rhs, src); ok {
|
|
// Generic "env"-named helper — lower-trust heuristic.
|
|
commit(v, isC, "heuristic")
|
|
} else {
|
|
clear()
|
|
}
|
|
default:
|
|
// `name = "lit"` / `name = CONST` — only a default when it
|
|
// follows an env read.
|
|
if v, isC, ok := goArgDefaultValue(rhs, src); ok && envReadSeen {
|
|
commit(v, isC, "os_getenv")
|
|
} else {
|
|
clear()
|
|
}
|
|
}
|
|
}
|
|
return resLit, resConst, resolvedSource, resolvedOK
|
|
}
|
|
|
|
// goTemporalVarTrace traces a bare-identifier dispatch name to its
|
|
// intra-procedural assignment when that assignment is a plain literal /
|
|
// constant reference / const-returning func call. The env-var-with-default
|
|
// shape is handled by goTemporalEnvDefaultName and takes precedence; this is
|
|
// the general fallback for `name := <value>; workflow.ExecuteActivity(ctx,
|
|
// name, …)` — the "meta_vars" broken-dispatch category (activity /
|
|
// activityName / type). It returns the LAST assignment to `name` lexically
|
|
// before the dispatch, reduced to exactly one of:
|
|
//
|
|
// litDef : string-literal value (`name := "ChargeActivity"`)
|
|
// constName: a constant NAME (`name := pkg.ChargeName` / `name := CHARGE`)
|
|
// funcName : a const-returning func name (`name := GetChargeName()`)
|
|
//
|
|
// The resolver validates constName / funcName against its constVal index, so
|
|
// an identifier that is not actually a string constant simply fails to
|
|
// resolve and stays a broken_dispatch — no false-resolution risk. The
|
|
// last-assignment-wins rule is a best-effort static guess (a later
|
|
// conditional reassignment may not execute), so the resolver lands these at
|
|
// the inferred / convention tier, not the register-confirmed 0.9. Returns
|
|
// ok=false when there is no traceable assignment to `name`.
|
|
func goTemporalVarTrace(callNode *sitter.Node, name string, src []byte) (litDef, constName string, ok bool) {
|
|
body := goEnclosingFuncBody(callNode)
|
|
if body == nil {
|
|
return "", "", false
|
|
}
|
|
limit := callNode.StartByte()
|
|
var rhs *sitter.Node
|
|
assigns := 0
|
|
var walk func(n *sitter.Node)
|
|
walk = func(n *sitter.Node) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
if (n.Type() == "short_var_declaration" || n.Type() == "assignment_statement") &&
|
|
n.StartByte() < limit && goAssignHasTarget(n, name, src) {
|
|
// Count every assignment to `name` (the single-assignment guard
|
|
// below relies on the total); rhs is the index-matched value, which
|
|
// may be nil for a multi-value call.
|
|
assigns++
|
|
rhs = goAssignRHSExpr(n, name, src)
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walk(n.NamedChild(i))
|
|
}
|
|
}
|
|
walk(body)
|
|
// Trace ONLY when the variable has exactly ONE assignment before the
|
|
// dispatch. Multiple assignments mean the live value is conditional /
|
|
// reassigned (e.g. an env-default write later overwritten by a plain
|
|
// call) — guessing the last one would be a false-resolution risk, so we
|
|
// leave the dispatch a broken_dispatch rather than guess.
|
|
if assigns != 1 || rhs == nil {
|
|
return "", "", false
|
|
}
|
|
switch rhs.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
if v, okk := goStringLiteralValue(rhs, src); okk && v != "" {
|
|
return v, "", true
|
|
}
|
|
case "identifier":
|
|
// Bare const reference (`name := CHARGE`). Admitted ONLY when the
|
|
// referenced identifier is a package-level CONST declared in this
|
|
// file — a function parameter or arbitrary local (`actName :=
|
|
// picked`) is exactly the "don't guess at arbitrary variables" case
|
|
// the plain-var path deliberately refuses. The const NAME is emitted
|
|
// as temporal_name so the resolver's const-deref map substitutes the
|
|
// literal. Guarded by length to skip throwaway names; skip
|
|
// self-reference.
|
|
if cn := rhs.Content(src); len(cn) > 2 && cn != name && goIdentIsFileConst(callNode, cn, src) {
|
|
return "", cn, true
|
|
}
|
|
case "selector_expression":
|
|
// Package / receiver const reference (`name := config.ChargeName`).
|
|
if field := rhs.ChildByFieldName("field"); field != nil {
|
|
if cn := field.Content(src); len(cn) > 2 {
|
|
return "", cn, true
|
|
}
|
|
}
|
|
case "call_expression":
|
|
// Const-returning name getter (`name := GetChargeName()`). os.Getenv
|
|
// is the env path's job, not this one. Require ZERO arguments: a call
|
|
// WITH args (`wfutils.PickActivity("KEY", "default")`) is an
|
|
// env/helper-style call whose default is the env path's responsibility
|
|
// (and which the env path deliberately declines for unknown helpers) —
|
|
// treating it as a const-return getter would mislabel it. The callee's
|
|
// returned value is resolved in-file to a literal / const name, since
|
|
// the resolver has no func-returning-name channel.
|
|
if goIsEnvRead(rhs, src) {
|
|
return "", "", false
|
|
}
|
|
if a := rhs.ChildByFieldName("arguments"); a != nil && a.NamedChildCount() > 0 {
|
|
return "", "", false
|
|
}
|
|
if lit, cn, okk := goTemporalFuncReturnName(rhs, callNode, src); okk {
|
|
return lit, cn, true
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// goIdentIsFileConst reports whether `ident` is the name of a constant
|
|
// declared anywhere in the same source file as fromNode. Used to gate the
|
|
// variable-trace bare-identifier case so it admits a `name := chargeName`
|
|
// const reference but refuses a `actName := picked` parameter / local —
|
|
// honouring the "don't guess at arbitrary variables" invariant. The scan
|
|
// inspects every const_spec name under the file root.
|
|
func goIdentIsFileConst(fromNode *sitter.Node, ident string, src []byte) bool {
|
|
root := fromNode
|
|
for root.Parent() != nil {
|
|
root = root.Parent()
|
|
}
|
|
found := false
|
|
var walk func(n *sitter.Node)
|
|
walk = func(n *sitter.Node) {
|
|
if n == nil || found {
|
|
return
|
|
}
|
|
if n.Type() == "const_spec" {
|
|
if nm := n.ChildByFieldName("name"); nm != nil && nm.Content(src) == ident {
|
|
found = true
|
|
return
|
|
}
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walk(n.NamedChild(i))
|
|
}
|
|
}
|
|
walk(root)
|
|
return found
|
|
}
|
|
|
|
// goTemporalFuncReturnName resolves a no-argument func/method call used to
|
|
// supply a dispatch name (`name := GetChargeName()`) to the value the callee
|
|
// unconditionally returns — either a string literal (emitted as the dispatch
|
|
// name directly) or a bare const reference (emitted as a const name for the
|
|
// resolver to dereference). It locates the callee declaration by simple name
|
|
// within the SAME file as the call, then returns the single `return <expr>`
|
|
// it finds when that expr is a string literal or a one-hop identifier
|
|
// reference. Returns ok=false when the callee is not found in-file, is not a
|
|
// plain identifier call, has multiple/none returns, or returns something
|
|
// other than a literal / bare const — keeping resolution precision-safe.
|
|
func goTemporalFuncReturnName(call, fromNode *sitter.Node, src []byte) (litDef, constName string, ok bool) {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil || fn.Type() != "identifier" {
|
|
return "", "", false
|
|
}
|
|
callee := fn.Content(src)
|
|
if callee == "" {
|
|
return "", "", false
|
|
}
|
|
root := fromNode
|
|
for root.Parent() != nil {
|
|
root = root.Parent()
|
|
}
|
|
var decl *sitter.Node
|
|
var find func(n *sitter.Node)
|
|
find = func(n *sitter.Node) {
|
|
if n == nil || decl != nil {
|
|
return
|
|
}
|
|
if n.Type() == "function_declaration" {
|
|
if nm := n.ChildByFieldName("name"); nm != nil && nm.Content(src) == callee {
|
|
decl = n
|
|
return
|
|
}
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
find(n.NamedChild(i))
|
|
}
|
|
}
|
|
find(root)
|
|
if decl == nil {
|
|
return "", "", false
|
|
}
|
|
body := decl.ChildByFieldName("body")
|
|
if body == nil {
|
|
return "", "", false
|
|
}
|
|
var ret *sitter.Node
|
|
count := 0
|
|
var walk func(n *sitter.Node)
|
|
walk = func(n *sitter.Node) {
|
|
if n == nil {
|
|
return
|
|
}
|
|
if n.Type() == "return_statement" {
|
|
count++
|
|
if n.NamedChildCount() == 1 {
|
|
ret = n.NamedChild(0)
|
|
}
|
|
}
|
|
for i, _nc := 0, int(n.NamedChildCount()); i < _nc; i++ {
|
|
walk(n.NamedChild(i))
|
|
}
|
|
}
|
|
walk(body)
|
|
if count != 1 || ret == nil {
|
|
return "", "", false
|
|
}
|
|
// A return_statement's value is wrapped in an expression_list; unwrap a
|
|
// single-expression list down to the lone expression.
|
|
if ret.Type() == "expression_list" {
|
|
if ret.NamedChildCount() != 1 {
|
|
return "", "", false
|
|
}
|
|
ret = ret.NamedChild(0)
|
|
}
|
|
if ret == nil {
|
|
return "", "", false
|
|
}
|
|
switch ret.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
if v, okk := goStringLiteralValue(ret, src); okk && v != "" {
|
|
return v, "", true
|
|
}
|
|
case "identifier":
|
|
if cn := ret.Content(src); len(cn) > 2 {
|
|
return "", cn, true
|
|
}
|
|
case "selector_expression":
|
|
if field := ret.ChildByFieldName("field"); field != nil {
|
|
if cn := field.Content(src); len(cn) > 2 {
|
|
return "", cn, true
|
|
}
|
|
}
|
|
}
|
|
return "", "", false
|
|
}
|
|
|
|
// goEnclosingFuncBody walks up from n to the nearest function-like
|
|
// ancestor and returns its body block, or nil.
|
|
func goEnclosingFuncBody(n *sitter.Node) *sitter.Node {
|
|
for cur := n; cur != nil; cur = cur.Parent() {
|
|
switch cur.Type() {
|
|
case "function_declaration", "method_declaration", "func_literal":
|
|
return cur.ChildByFieldName("body")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// goAssignHasTarget reports whether `name` appears among the left-hand
|
|
// targets of a short_var_declaration / assignment_statement.
|
|
func goAssignHasTarget(assign *sitter.Node, name string, src []byte) bool {
|
|
left := assign.ChildByFieldName("left")
|
|
if left == nil {
|
|
return false
|
|
}
|
|
for i, _nc := 0, int(left.NamedChildCount()); i < _nc; i++ {
|
|
c := left.NamedChild(i)
|
|
if c != nil && c.Type() == "identifier" && c.Content(src) == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// goAssignRHSExpr returns the right-hand expression assigned to `name`,
|
|
// matching the RHS position to the matched LHS target position for a
|
|
// parallel assignment (`a, name := x, "v"` → "v"). A single RHS shared by
|
|
// multiple targets is a multi-value call (`a, b := f()`) with no per-target
|
|
// literal to trace, so it returns nil. Returns nil when `name` is not a
|
|
// target of the assignment.
|
|
func goAssignRHSExpr(assign *sitter.Node, name string, src []byte) *sitter.Node {
|
|
left := assign.ChildByFieldName("left")
|
|
right := assign.ChildByFieldName("right")
|
|
if left == nil || right == nil || right.NamedChildCount() == 0 {
|
|
return nil
|
|
}
|
|
idx := -1
|
|
for i, _nc := 0, int(left.NamedChildCount()); i < _nc; i++ {
|
|
c := left.NamedChild(i)
|
|
if c != nil && c.Type() == "identifier" && c.Content(src) == name {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
// Parallel assignment: take the RHS at the matched target position.
|
|
if left.NamedChildCount() == right.NamedChildCount() {
|
|
return right.NamedChild(idx)
|
|
}
|
|
// Single target, single value.
|
|
if left.NamedChildCount() == 1 && right.NamedChildCount() == 1 {
|
|
return right.NamedChild(0)
|
|
}
|
|
// Multi-value call (`a, b := f()`): no per-target literal to trace.
|
|
return nil
|
|
}
|
|
|
|
// goIsEnvRead reports whether a call_expression is `os.Getenv(...)` or
|
|
// `os.LookupEnv(...)`.
|
|
func goIsEnvRead(call *sitter.Node, src []byte) bool {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil || fn.Type() != "selector_expression" {
|
|
return false
|
|
}
|
|
op := fn.ChildByFieldName("operand")
|
|
field := fn.ChildByFieldName("field")
|
|
if op == nil || field == nil || op.Content(src) != "os" {
|
|
return false
|
|
}
|
|
switch field.Content(src) {
|
|
case "Getenv", "LookupEnv":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// goArgDefaultValue reduces an env-or-default helper's DEFAULT argument to its
|
|
// dispatch-name value. A string literal (`"ChargeActivity"`) yields the literal
|
|
// with isConst=false. A constant REFERENCE — a bare identifier
|
|
// (`ACTIVITY_NAME_DEFAULT`) or a selector_expression
|
|
// (`config.ACTIVITY_NAME_DEFAULT`) — yields the constant's NAME with
|
|
// isConst=true; the resolver later substitutes the constant's literal value
|
|
// through its const-deref index (the constant body usually lives in another
|
|
// package, invisible at extract time). Identifiers are admitted optimistically
|
|
// because the resolver validates them against the const-deref index — an
|
|
// identifier that is not actually a string constant simply fails to resolve and
|
|
// stays a broken_dispatch, so there is no false-resolution risk. Returns
|
|
// ("", false, false) for any other shape.
|
|
func goArgDefaultValue(node *sitter.Node, src []byte) (val string, isConst bool, ok bool) {
|
|
if node == nil {
|
|
return "", false, false
|
|
}
|
|
if lit, okk := goStringLiteralValue(node, src); okk {
|
|
return lit, false, true
|
|
}
|
|
switch node.Type() {
|
|
case "identifier":
|
|
return node.Content(src), true, true
|
|
case "selector_expression":
|
|
if field := node.ChildByFieldName("field"); field != nil {
|
|
return field.Content(src), true, true
|
|
}
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
// goCallEnvDefaultLiteral inspects a `cmp.Or(os.Getenv("KEY"), "Default")`
|
|
// call and returns its literal default. cmp.Or returns the FIRST non-zero
|
|
// argument, so when the env read yields "" at runtime the value is the
|
|
// first string-literal argument that follows — hence we return the FIRST
|
|
// literal, not the last. Gated on the cmp.Or callee: an arbitrary user
|
|
// function mixing an env read with a literal (`combine(os.Getenv("K"),
|
|
// "Suffix")`) is deliberately NOT treated as env-or-default — only the
|
|
// stdlib cmp.Or idiom qualifies, since cmp.Or is the one combinator whose
|
|
// "first non-zero" semantics make the literal a provable default. Returns
|
|
// ("", false, false) when the callee is not cmp.Or, no os.Getenv /
|
|
// os.LookupEnv read is present, or there is no default argument. The default
|
|
// may be a string literal (isConst=false) or a constant reference
|
|
// (isConst=true) the resolver substitutes through its const-deref index.
|
|
func goCallEnvDefaultLiteral(call *sitter.Node, src []byte) (val string, isConst bool, ok bool) {
|
|
if !goIsCmpOr(call, src) {
|
|
return "", false, false
|
|
}
|
|
args := call.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return "", false, false
|
|
}
|
|
hasEnvRead := false
|
|
firstVal := ""
|
|
firstConst := false
|
|
haveDefault := false
|
|
for i, _nc := 0, int(args.NamedChildCount()); i < _nc; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
if c.Type() == "call_expression" && goIsEnvRead(c, src) {
|
|
hasEnvRead = true
|
|
continue
|
|
}
|
|
if v, isC, okk := goArgDefaultValue(c, src); okk && !haveDefault {
|
|
firstVal, firstConst, haveDefault = v, isC, true
|
|
}
|
|
}
|
|
if hasEnvRead && haveDefault {
|
|
return firstVal, firstConst, true
|
|
}
|
|
return "", false, false
|
|
}
|
|
|
|
// goIsCmpOr reports whether a call_expression is a call to the stdlib
|
|
// `cmp.Or` — the canonical "first non-zero" combinator used for the
|
|
// env-or-default idiom. Matched by the canonical `cmp` package alias
|
|
// (consistent with the os.Getenv / "workflow" receiver gates elsewhere).
|
|
func goIsCmpOr(call *sitter.Node, src []byte) bool {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil || fn.Type() != "selector_expression" {
|
|
return false
|
|
}
|
|
op := fn.ChildByFieldName("operand")
|
|
field := fn.ChildByFieldName("field")
|
|
return op != nil && field != nil &&
|
|
op.Content(src) == "cmp" && field.Content(src) == "Or"
|
|
}
|
|
|
|
// goEnvHelperNames is the built-in allow-list of project-local env-or-default
|
|
// helper function names whose 2nd argument is the literal default. Matched
|
|
// case-insensitively; a corporate fork extends it at runtime via the per-repo
|
|
// allow-list threaded through `extra` (see goEnvHelperDefaultLiteral).
|
|
var goEnvHelperNames = []string{
|
|
"GetEnvOrDefault",
|
|
"GetEnvOrDefaultValue",
|
|
"EnvOr",
|
|
"GetenvDefault",
|
|
"GetEnvDefault",
|
|
}
|
|
|
|
// goEnvHelperDefaultLiteral recognises a call to a project-local
|
|
// env-or-default helper by name — `wfutils.GetEnvOrDefault(KEY, "Default")`
|
|
// or the bare `EnvOr(KEY, "Default")` — and returns the string-literal 2nd
|
|
// argument as the default. The callee name is taken from a bare identifier
|
|
// or, for a selector_expression, its trailing `field`; it is compared
|
|
// case-insensitively (strings.EqualFold) against the built-in goEnvHelperNames
|
|
// PLUS `extra` — the per-repo corporate allow-list (lower-cased keys) loaded
|
|
// from the git-ignored `.gortex/temporal-allowlist.yaml`. A match here is
|
|
// "allowlist"-sourced, so the resolver lands the edge at the inferred (visible)
|
|
// tier — that is how a corporate agent PROMOTES its own helper above the
|
|
// generic "env"-name heuristic. Returns ("", false) for any non-matching name
|
|
// or a non-string-literal 2nd arg.
|
|
func goEnvHelperDefaultLiteral(call *sitter.Node, src []byte, extra map[string]bool) (val string, isConst bool, ok bool) {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil {
|
|
return "", false, false
|
|
}
|
|
var callee string
|
|
switch fn.Type() {
|
|
case "identifier":
|
|
callee = fn.Content(src)
|
|
case "selector_expression":
|
|
if field := fn.ChildByFieldName("field"); field != nil {
|
|
callee = field.Content(src)
|
|
}
|
|
}
|
|
if callee == "" {
|
|
return "", false, false
|
|
}
|
|
matched := false
|
|
for _, name := range goEnvHelperNames {
|
|
if strings.EqualFold(callee, name) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched && extra[strings.ToLower(callee)] {
|
|
matched = true
|
|
}
|
|
if !matched {
|
|
return "", false, false
|
|
}
|
|
args := call.ChildByFieldName("arguments")
|
|
if args == nil || args.NamedChildCount() < 2 {
|
|
return "", false, false
|
|
}
|
|
return goArgDefaultValue(args.NamedChild(1), src)
|
|
}
|
|
|
|
// goEnvHelperHeuristicDefault is the generic-recall fallback for env-or-default
|
|
// helpers whose NAME is not in the allow-list. It fires on a structural anchor
|
|
// — the callee's (bare or selector-trailing) name contains "env"
|
|
// (case-insensitive), the near-universal marker of an env-reading helper
|
|
// (`cfg.ActivityFromEnv("KEY", "Default")`, `getEnvActivity(...)`) — and takes
|
|
// the 2nd argument's string literal as the default. Deliberately lower-trust
|
|
// than the allow-list path: the caller tags the resulting edge
|
|
// `temporal_env_source=heuristic` so the resolver keeps it at the hidden
|
|
// speculative tier (where the LLM cleaning pass verifies or prunes it), rather
|
|
// than asserting it as a real dispatch. Returns ("", false) when the name lacks
|
|
// the "env" marker or the 2nd argument is not a string literal — so a plain
|
|
// `pick(KEY, "X")` (no env marker) is left untouched, preserving precision.
|
|
func goEnvHelperHeuristicDefault(call *sitter.Node, src []byte) (val string, isConst bool, ok bool) {
|
|
fn := call.ChildByFieldName("function")
|
|
if fn == nil {
|
|
return "", false, false
|
|
}
|
|
var callee string
|
|
switch fn.Type() {
|
|
case "identifier":
|
|
callee = fn.Content(src)
|
|
case "selector_expression":
|
|
if field := fn.ChildByFieldName("field"); field != nil {
|
|
callee = field.Content(src)
|
|
}
|
|
}
|
|
if callee == "" || !strings.Contains(strings.ToLower(callee), "env") {
|
|
return "", false, false
|
|
}
|
|
args := call.ChildByFieldName("arguments")
|
|
if args == nil || args.NamedChildCount() < 2 {
|
|
return "", false, false
|
|
}
|
|
return goArgDefaultValue(args.NamedChild(1), src)
|
|
}
|
|
|
|
// goStringLiteralValue returns the unquoted value of a Go string literal
|
|
// node, or ("", false) for any other node type.
|
|
func goStringLiteralValue(n *sitter.Node, src []byte) (string, bool) {
|
|
if n == nil {
|
|
return "", false
|
|
}
|
|
switch n.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
return goTemporalNameFromExpr(n, src), true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// goTemporalCallArgNames extracts positional arg names from a call expression.
|
|
//
|
|
// PURPOSE — extract positional arg names from a call expression for wrapper-following
|
|
// RATIONALE — qualifying args are string literals, selectors, Capitalized
|
|
//
|
|
// identifiers, OR a bare lowercase identifier that is one of the ENCLOSING
|
|
// function's own parameters (a name forwarded THROUGH this call) — the latter
|
|
// is what lets depth>1 wrapper-following propagate a name across multiple hops.
|
|
// `callerParams` is the enclosing function's parameter-name set (may be nil).
|
|
//
|
|
// KEYWORDS — arg_names, wrapper-following, call_expression, forwarded-param
|
|
func goTemporalCallArgNames(callNode *sitter.Node, src []byte, callerParams map[string]bool) ([]string, bool) {
|
|
if callNode == nil || callNode.Type() != "call_expression" {
|
|
return nil, false
|
|
}
|
|
args := callNode.ChildByFieldName("arguments")
|
|
if args == nil {
|
|
return nil, false
|
|
}
|
|
const maxArgs = 8
|
|
var out []string
|
|
qualifying := false
|
|
count := 0
|
|
for i := 0; i < int(args.NamedChildCount()) && count < maxArgs; i++ {
|
|
c := args.NamedChild(i)
|
|
if c == nil {
|
|
continue
|
|
}
|
|
count++
|
|
name := ""
|
|
switch c.Type() {
|
|
case "interpreted_string_literal", "raw_string_literal":
|
|
name = goTemporalNameFromExpr(c, src)
|
|
qualifying = true
|
|
case "selector_expression":
|
|
name = goTemporalNameFromExpr(c, src)
|
|
qualifying = true
|
|
case "identifier":
|
|
name = c.Content(src)
|
|
if name != "" && name[0] >= 'A' && name[0] <= 'Z' {
|
|
qualifying = true
|
|
} else if callerParams[name] {
|
|
// A bare lowercase identifier that is the enclosing function's
|
|
// own parameter: this call forwards a caller-supplied name
|
|
// THROUGH (`runStep(ctx, name, …)` inside a wrapper). Recording
|
|
// it lets the wrapper-following resolver discover the caller as
|
|
// a transitive wrapper and propagate the name up another level.
|
|
qualifying = true
|
|
}
|
|
}
|
|
out = append(out, name)
|
|
}
|
|
if !qualifying {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// attachGoTemporalCallArgNames attaches arg_names + callee meta to a call edge.
|
|
//
|
|
// PURPOSE — attach arg_names and callee meta to a call edge for wrapper-following
|
|
// RATIONALE — the resolver's wrapper pass needs both the arg values and the callee name
|
|
//
|
|
// to match caller edges to wrapper definitions
|
|
//
|
|
// KEYWORDS — arg_names, callee, wrapper-following, edge meta
|
|
func attachGoTemporalCallArgNames(edge *graph.Edge, c goDeferredCall, callNode *sitter.Node, src []byte, callerParams map[string]bool) {
|
|
names, ok := goTemporalCallArgNames(callNode, src, callerParams)
|
|
if !ok {
|
|
return
|
|
}
|
|
if edge.Meta == nil {
|
|
edge.Meta = map[string]any{}
|
|
}
|
|
edge.Meta["arg_names"] = names
|
|
// callee: the function/method name being called
|
|
if c.isSelector {
|
|
edge.Meta["callee"] = c.method
|
|
} else if c.callName != "" {
|
|
edge.Meta["callee"] = c.callName
|
|
}
|
|
}
|
|
|
|
// goCompositeLiteralType walks up from a keyed_element node to find the
|
|
// enclosing composite_literal and returns the simple type name.
|
|
//
|
|
// PURPOSE — extracts the receiver struct type from a struct literal so the
|
|
// executor-field pass can key the field assignment by (type, field).
|
|
// RATIONALE — tree-sitter does not expose a direct parent-of-kind API;
|
|
// walking the Parent chain is the standard idiom in this codebase.
|
|
// KEYWORDS — composite-literal, type-name, executor-field
|
|
func goCompositeLiteralType(keyed *sitter.Node, src []byte) string {
|
|
for n := keyed; n != nil; n = n.Parent() {
|
|
if n.Type() != "composite_literal" {
|
|
continue
|
|
}
|
|
t := n.ChildByFieldName("type")
|
|
if t == nil {
|
|
return ""
|
|
}
|
|
switch t.Type() {
|
|
case "type_identifier":
|
|
return t.Content(src)
|
|
case "pointer_type":
|
|
if inner := t.NamedChild(0); inner != nil && inner.Type() == "type_identifier" {
|
|
return inner.Content(src)
|
|
}
|
|
case "qualified_type":
|
|
if f := t.ChildByFieldName("name"); f != nil {
|
|
return f.Content(src)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
return ""
|
|
}
|