fix(temporal): match repo-local env-helper names case-insensitively

The repo-local Temporal env-helper allow-list matched user-supplied
names with a raw, case-sensitive map lookup, while the built-in names
went through strings.EqualFold. A capitalisation mismatch between
.gortex/temporal-allowlist.yaml and the call site failed silently: the
dispatch fell back to the hidden "heuristic" tier when the callee
carried an "env" marker, and lost its env attribution entirely when it
did not. Both doc comments already described the intended behaviour —
case-insensitive matching over lower-cased keys — so only the code had
drifted.

Lower-case the keys where the map is built and lower-case the callee at
lookup, restoring parity with the built-in list. Cover the whole opt-in
chain (env gate, allow-list file, indexer options, Go extractor) with a
case-mismatch regression test, and document the feature: the gate and
the file shape were previously undocumented outside source comments.

Closes #524
This commit is contained in:
Andrey Kumanyaev
2026-08-19 22:03:46 +02:00
parent c45e00d483
commit 02490300d0
5 changed files with 110 additions and 8 deletions
+32
View File
@@ -38,3 +38,35 @@ The Go and Java extractors tag Temporal call sites with a `via` Meta value on th
| `temporal.query-call` | caller → running workflow | `client.QueryWorkflow` | `query` | consumer edge |
Extra Meta on these edges: `temporal_registered_name` (the `RegisterOptions{Name}` override that is the actual dispatch key), `temporal_register_plural` (a `RegisterActivities(&Struct{})` registration whose exported methods are each promoted), and `temporal_name_origin=env_default` (a dispatch name resolved from an env-var-with-literal-default, landed at the speculative tier). Node roles are stamped as `temporal_role` (`activity` / `workflow` / `activity_interface` / `workflow_interface` / `signal` / `query` / `update`). Aliased `import wf "go.temporal.io/sdk/workflow"` receivers are canonicalised before detection.
### Repo-local env-helper allow-list (Go)
A workflow often picks its activity name through a project-local env-or-default helper rather than a literal:
```go
name := wfutils.GetEnvOrDefault("ACTIVITY_NAME", "ChargeCard")
workflow.ExecuteActivity(ctx, name)
```
The Go extractor recognises a small built-in set of such helper names (`GetEnvOrDefault`, `GetEnvOrDefaultValue`, `EnvOr`, `GetenvDefault`, `GetEnvDefault`) and takes the second argument as the dispatch name. A recognised name is stamped `temporal_env_source=allowlist` and the resolver lands the edge at the **inferred (visible)** tier. Any other helper whose name merely contains `env` falls back to `temporal_env_source=heuristic`, which stays at the **speculative (hidden)** tier.
To promote your own helper names into the allow-list tier, declare them per repository:
```yaml
# .gortex/temporal-allowlist.yaml — git-ignore this file
env_helpers:
- GetEnvOrFallback
- ActivityNameFor
```
The file is read **only** when the opt-in gate is set, because a checked-out repository could otherwise change how the indexer attributes dispatch:
```bash
export GORTEX_ALLOW_LOCAL_TEMPORAL=1
```
Notes:
- Declare the **bare function name**, without a package qualifier: only the trailing identifier of the call site is matched, so `ActivityNameFor` covers both `ActivityNameFor(...)` and `cfgutil.ActivityNameFor(...)`. Matching is **case-insensitive**, same as the built-in names.
- The list is loaded once per indexed repository, at the repository root, and applies to that repository only — a multi-repo daemon never leaks one repo's names into another. Editing the file takes effect on the next daemon start.
- Everything fails soft: gate unset, file missing, or file malformed all mean "no extra names". The built-in list and the heuristic still apply, so you never lose edges by getting this wrong — you only lose the promotion to the visible tier.
@@ -127,3 +127,54 @@ func TestRepositoryExtractionOptionsIsolationAndOverlayParity(t *testing.T) {
}
}
}
// TestRepositoryExtractionOptionsMatchHelperCaseInsensitively walks the whole
// opt-in chain — env gate, repo-local allow-list file, indexer options, Go
// extractor — and asserts a declared helper name matches its call site
// regardless of capitalisation. A case near-miss used to fail silently: the
// dispatch fell back to the hidden "heuristic" tier when the callee carried an
// "env" marker, and left the graph entirely when it did not.
func TestRepositoryExtractionOptionsMatchHelperCaseInsensitively(t *testing.T) {
t.Setenv(config.LocalTemporalOptInEnv, "true")
for _, testCase := range []struct {
name string
declared string
callSite string
}{
{name: "exact", declared: "CorpEnvLookup", callSite: "CorpEnvLookup"},
{name: "declared lower", declared: "corpenvlookup", callSite: "CorpEnvLookup"},
{name: "declared upper", declared: "CORPENVLOOKUP", callSite: "CorpEnvLookup"},
// No "env" marker in the name, so a miss here drops the edge's env
// attribution entirely instead of degrading it to the heuristic tier.
{name: "no env marker", declared: "getCorpValue", callSite: "GetCorpValue"},
// Entries are bare function names; only the call site's trailing
// identifier is matched, so a package-qualified call still resolves.
{name: "package qualified call", declared: "corpEnvLookup", callSite: "cfgutil.CorpEnvLookup"},
} {
t.Run(testCase.name, func(t *testing.T) {
root := t.TempDir()
writeIndexerTemporalAllowlist(t, root, testCase.declared)
reg := parser.NewRegistry()
languages.RegisterAll(reg)
idx := New(graph.New(), reg, config.IndexConfig{}, zap.NewNop())
idx.SetRootPath(root)
defer idx.Close()
result, err := idx.ExtractBuffer("go", "sample.go", indexerTemporalSource(testCase.callSite))
if err != nil {
t.Fatal(err)
}
meta := indexerTemporalMeta(result)
if meta == nil {
t.Fatalf("declared %q vs call site %q: Temporal edge missing",
testCase.declared, testCase.callSite)
}
if got := meta["temporal_env_source"]; got != "allowlist" {
t.Fatalf("declared %q vs call site %q: temporal_env_source = %#v, want %q",
testCase.declared, testCase.callSite, got, "allowlist")
}
})
}
}
+5 -1
View File
@@ -357,9 +357,13 @@ func (e *GoExtractor) Extract(filePath string, src []byte) (*parser.ExtractionRe
// mutating this shared extractor. Configured Temporal helpers extend the
// built-in helper set for this extraction only.
func (e *GoExtractor) ExtractWithOptions(filePath string, src []byte, opts parser.ExtractionOptions) (*parser.ExtractionResult, error) {
// Keys are lower-cased so a repo-local helper name matches its call site
// the same case-insensitive way a built-in goEnvHelperNames entry does.
// Without this, `env_helpers: [getCorpValue]` silently fails to promote a
// `GetCorpValue(...)` dispatch — see goEnvHelperDefaultLiteral.
envHelperExtra := make(map[string]bool)
for _, name := range opts.TemporalEnvHelpers() {
envHelperExtra[name] = true
envHelperExtra[strings.ToLower(name)] = true
}
tree, err := parser.ParseFile(src, e.lang)
if err != nil {
+1 -1
View File
@@ -1254,7 +1254,7 @@ func goEnvHelperDefaultLiteral(call *sitter.Node, src []byte, extra map[string]b
break
}
}
if !matched && extra[callee] {
if !matched && extra[strings.ToLower(callee)] {
matched = true
}
if !matched {
@@ -98,12 +98,27 @@ func Run(ctx workflow.Context) {
t.Fatalf("constant reference = %#v", got)
}
wrongCase, err := ext.ExtractWithOptions("case.go", temporalOptionSource("CorporateHelper", ""), parser.NewExtractionOptions([]string{"corporatehelper"}))
if err != nil {
t.Fatal(err)
}
if got := temporalDispatchMeta(t, wrongCase)["temporal_env_source"]; got != nil {
t.Fatalf("case-insensitive local match = %#v", got)
// A repo-local allow-list entry matches its call site case-insensitively,
// exactly like a built-in goEnvHelperNames entry does. The YAML author
// must not have to reproduce the call site's capitalisation: a near-miss
// used to fail silently, dropping the dispatch to the hidden heuristic
// tier (or off the graph entirely for a name without an "env" marker).
for _, tc := range []struct{ entry, callSite string }{
{entry: "corporatehelper", callSite: "CorporateHelper"},
{entry: "CorporateHelper", callSite: "corporatehelper"},
} {
mixedCase, err := ext.ExtractWithOptions(
"case.go",
temporalOptionSource(tc.callSite, ""),
parser.NewExtractionOptions([]string{tc.entry}),
)
if err != nil {
t.Fatal(err)
}
if got := temporalDispatchMeta(t, mixedCase)["temporal_env_source"]; got != "allowlist" {
t.Fatalf("entry %q vs call site %q: source = %#v, want %q",
tc.entry, tc.callSite, got, "allowlist")
}
}
}